@remodex/rmx 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (588) hide show
  1. package/AGENTS_INSTALL.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +242 -0
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/claude-code-models.gif +0 -0
  7. package/assets/codex-app-picker.png +0 -0
  8. package/bin/ocx.mjs +584 -0
  9. package/bin/package-main.mjs +9 -0
  10. package/gui/dist/assets/index-CZqebSPQ.css +1 -0
  11. package/gui/dist/assets/index-CkETtt7P.js +71 -0
  12. package/gui/dist/favicon.png +0 -0
  13. package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
  14. package/gui/dist/fonts/google-sans-latin.woff2 +0 -0
  15. package/gui/dist/icons.svg +24 -0
  16. package/gui/dist/index.html +25 -0
  17. package/gui/dist/logo.png +0 -0
  18. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  19. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  20. package/gui/dist/provider-icons/claude-color.svg +1 -0
  21. package/gui/dist/provider-icons/cline-color.svg +16 -0
  22. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  23. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  24. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  25. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  26. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  27. package/gui/dist/provider-icons/discord.svg +1 -0
  28. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  29. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok.svg +1 -0
  34. package/gui/dist/provider-icons/groq-color.svg +1 -0
  35. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  36. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  37. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  38. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  39. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  40. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  41. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  42. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  43. package/gui/dist/provider-icons/openai.svg +1 -0
  44. package/gui/dist/provider-icons/opencode.svg +2 -0
  45. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  46. package/gui/dist/provider-icons/pi.svg +21 -0
  47. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  48. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  49. package/gui/dist/provider-icons/telegram.svg +1 -0
  50. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  51. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  52. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  53. package/package.json +118 -0
  54. package/src/AGENTS.md +28 -0
  55. package/src/adapters/anthropic-image-guard.ts +251 -0
  56. package/src/adapters/anthropic-image-normalize.ts +518 -0
  57. package/src/adapters/anthropic.ts +1205 -0
  58. package/src/adapters/azure.ts +36 -0
  59. package/src/adapters/base.ts +83 -0
  60. package/src/adapters/client-fingerprint.ts +59 -0
  61. package/src/adapters/command-code.ts +453 -0
  62. package/src/adapters/cursor/arg-codec.ts +38 -0
  63. package/src/adapters/cursor/arg-normalize.ts +104 -0
  64. package/src/adapters/cursor/cursor-errors.ts +165 -0
  65. package/src/adapters/cursor/discovery.ts +276 -0
  66. package/src/adapters/cursor/effort-map.ts +139 -0
  67. package/src/adapters/cursor/exec-policy.ts +88 -0
  68. package/src/adapters/cursor/framing.ts +250 -0
  69. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  70. package/src/adapters/cursor/kv-store.ts +52 -0
  71. package/src/adapters/cursor/live-models.ts +153 -0
  72. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  73. package/src/adapters/cursor/live-transport.ts +1235 -0
  74. package/src/adapters/cursor/mcp-config.ts +42 -0
  75. package/src/adapters/cursor/mcp-manager.ts +333 -0
  76. package/src/adapters/cursor/message-mapper.ts +49 -0
  77. package/src/adapters/cursor/native-exec-common.ts +59 -0
  78. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  79. package/src/adapters/cursor/native-exec-fs.ts +332 -0
  80. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  81. package/src/adapters/cursor/native-exec-network.ts +43 -0
  82. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  83. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  84. package/src/adapters/cursor/native-exec.ts +604 -0
  85. package/src/adapters/cursor/protobuf-events.ts +735 -0
  86. package/src/adapters/cursor/protobuf-request.ts +719 -0
  87. package/src/adapters/cursor/request-builder.ts +280 -0
  88. package/src/adapters/cursor/thread-continuity.ts +67 -0
  89. package/src/adapters/cursor/tool-definitions.ts +621 -0
  90. package/src/adapters/cursor/transport-retry.ts +132 -0
  91. package/src/adapters/cursor/transport.ts +57 -0
  92. package/src/adapters/cursor/types.ts +59 -0
  93. package/src/adapters/cursor.ts +196 -0
  94. package/src/adapters/google-antigravity-replay.ts +520 -0
  95. package/src/adapters/google-antigravity-wire.ts +140 -0
  96. package/src/adapters/google-errors.ts +85 -0
  97. package/src/adapters/google-http.ts +100 -0
  98. package/src/adapters/google-tool-schema.ts +173 -0
  99. package/src/adapters/google-truncation.ts +24 -0
  100. package/src/adapters/google-wire-compiler.ts +232 -0
  101. package/src/adapters/google.ts +859 -0
  102. package/src/adapters/identity.ts +77 -0
  103. package/src/adapters/image.ts +23 -0
  104. package/src/adapters/kiro-constants.ts +16 -0
  105. package/src/adapters/kiro-errors.ts +208 -0
  106. package/src/adapters/kiro-events.ts +197 -0
  107. package/src/adapters/kiro-images.ts +129 -0
  108. package/src/adapters/kiro-retry.ts +312 -0
  109. package/src/adapters/kiro-thinking.ts +104 -0
  110. package/src/adapters/kiro-tool-fallback.ts +36 -0
  111. package/src/adapters/kiro-tools.ts +224 -0
  112. package/src/adapters/kiro-truncation.ts +33 -0
  113. package/src/adapters/kiro-wire.ts +129 -0
  114. package/src/adapters/kiro.ts +1924 -0
  115. package/src/adapters/mimo-free.ts +263 -0
  116. package/src/adapters/openai-chat.ts +1265 -0
  117. package/src/adapters/openai-responses.ts +1309 -0
  118. package/src/adapters/run-turn-queue.ts +114 -0
  119. package/src/adapters/tool-catalog-nudge.ts +71 -0
  120. package/src/adapters/upstream-http-error.ts +48 -0
  121. package/src/android-remote/assets.ts +218 -0
  122. package/src/android-remote/attachments.ts +168 -0
  123. package/src/android-remote/auth.ts +237 -0
  124. package/src/android-remote/cloudflare-provisioning.ts +409 -0
  125. package/src/android-remote/cloudflare-secret.ts +106 -0
  126. package/src/android-remote/cloudflare-tunnel.ts +488 -0
  127. package/src/android-remote/cloudflared.ts +286 -0
  128. package/src/android-remote/codex-app-server.ts +565 -0
  129. package/src/android-remote/desktop-history-page.ts +936 -0
  130. package/src/android-remote/desktop-ipc.ts +3643 -0
  131. package/src/android-remote/desktop-ownership-store.ts +98 -0
  132. package/src/android-remote/desktop-project-registration.ts +129 -0
  133. package/src/android-remote/desktop-session-stream.ts +1110 -0
  134. package/src/android-remote/desktop-workspace-state.ts +443 -0
  135. package/src/android-remote/file-change-parser.ts +112 -0
  136. package/src/android-remote/gateway.ts +9108 -0
  137. package/src/android-remote/mutation-store.ts +299 -0
  138. package/src/android-remote/projection.ts +1780 -0
  139. package/src/android-remote/queued-turn-store.ts +248 -0
  140. package/src/android-remote/session-command-recovery.ts +1384 -0
  141. package/src/android-remote/store.ts +466 -0
  142. package/src/android-remote/thread-reconciliation.ts +133 -0
  143. package/src/android-remote/thread-source-paths.ts +307 -0
  144. package/src/android-remote/thread-stream.ts +546 -0
  145. package/src/android-remote/turn-activity.ts +235 -0
  146. package/src/android-remote/user-message-identity.ts +94 -0
  147. package/src/bridge.ts +1793 -0
  148. package/src/chat/inbound.ts +295 -0
  149. package/src/chat/outbound.ts +821 -0
  150. package/src/claude/agents-inject.ts +267 -0
  151. package/src/claude/alias.ts +149 -0
  152. package/src/claude/auth-detect.ts +229 -0
  153. package/src/claude/auth-mode-migration.ts +32 -0
  154. package/src/claude/auth-mode.ts +62 -0
  155. package/src/claude/context-windows.ts +189 -0
  156. package/src/claude/desktop-3p-guard.ts +35 -0
  157. package/src/claude/desktop-3p-paths.ts +84 -0
  158. package/src/claude/desktop-3p.ts +601 -0
  159. package/src/claude/desktop-health.ts +26 -0
  160. package/src/claude/desktop-profile.ts +263 -0
  161. package/src/claude/gateway-cache.ts +70 -0
  162. package/src/claude/inbound-debug.ts +163 -0
  163. package/src/claude/inbound.ts +519 -0
  164. package/src/claude/model-info.ts +154 -0
  165. package/src/claude/outbound.ts +898 -0
  166. package/src/cli/access.ts +108 -0
  167. package/src/cli/account-api.ts +296 -0
  168. package/src/cli/account-auth.ts +250 -0
  169. package/src/cli/account-catalog-refresh.ts +14 -0
  170. package/src/cli/account-extended.ts +476 -0
  171. package/src/cli/account-main.ts +317 -0
  172. package/src/cli/account.ts +297 -0
  173. package/src/cli/agent-driven.ts +70 -0
  174. package/src/cli/agent.ts +184 -0
  175. package/src/cli/catalog-prewarm.ts +27 -0
  176. package/src/cli/claude-desktop.ts +211 -0
  177. package/src/cli/claude.ts +302 -0
  178. package/src/cli/codex-shim-autorestore.ts +45 -0
  179. package/src/cli/codex-shim-readiness.ts +69 -0
  180. package/src/cli/combo.ts +124 -0
  181. package/src/cli/config-command.ts +183 -0
  182. package/src/cli/debug.ts +228 -0
  183. package/src/cli/desktop-first-run.ts +25 -0
  184. package/src/cli/doctor.ts +1022 -0
  185. package/src/cli/export-command.ts +201 -0
  186. package/src/cli/help.ts +370 -0
  187. package/src/cli/index.ts +1565 -0
  188. package/src/cli/init.ts +224 -0
  189. package/src/cli/integrations.ts +225 -0
  190. package/src/cli/interactive-confirm.ts +133 -0
  191. package/src/cli/internal-dispatch.ts +35 -0
  192. package/src/cli/launcher-context.ts +77 -0
  193. package/src/cli/models-runtime.ts +224 -0
  194. package/src/cli/models.ts +340 -0
  195. package/src/cli/observe.ts +170 -0
  196. package/src/cli/opencode.ts +587 -0
  197. package/src/cli/provider-runtime.ts +179 -0
  198. package/src/cli/provider.ts +476 -0
  199. package/src/cli/ready.ts +301 -0
  200. package/src/cli/route-policy.ts +92 -0
  201. package/src/cli/runtime-api.ts +328 -0
  202. package/src/cli/star-prompt.ts +211 -0
  203. package/src/cli/status-oauth.ts +78 -0
  204. package/src/cli/status.ts +321 -0
  205. package/src/cli/system-command.ts +196 -0
  206. package/src/cli/system-restart-client.ts +146 -0
  207. package/src/cli/tray-proxy.ts +205 -0
  208. package/src/cli/v2.ts +200 -0
  209. package/src/cli.ts +10 -0
  210. package/src/clients/config-export.ts +1109 -0
  211. package/src/codex/account-id.ts +34 -0
  212. package/src/codex/account-label.ts +34 -0
  213. package/src/codex/account-lifecycle.ts +172 -0
  214. package/src/codex/account-namespace-match.ts +63 -0
  215. package/src/codex/account-namespaces.ts +195 -0
  216. package/src/codex/account-pause.ts +20 -0
  217. package/src/codex/account-priority.ts +83 -0
  218. package/src/codex/account-runtime-state.ts +31 -0
  219. package/src/codex/account-store.ts +517 -0
  220. package/src/codex/account-usability.ts +40 -0
  221. package/src/codex/admission.ts +263 -0
  222. package/src/codex/app-server-processes.ts +799 -0
  223. package/src/codex/auth-api.ts +2098 -0
  224. package/src/codex/auth-collision.ts +107 -0
  225. package/src/codex/auth-context.ts +480 -0
  226. package/src/codex/autostart-health.ts +156 -0
  227. package/src/codex/catalog/account-models.ts +67 -0
  228. package/src/codex/catalog/aggregation.ts +471 -0
  229. package/src/codex/catalog/bundled.ts +533 -0
  230. package/src/codex/catalog/effort.ts +432 -0
  231. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  232. package/src/codex/catalog/kinds.ts +2 -0
  233. package/src/codex/catalog/metadata.ts +287 -0
  234. package/src/codex/catalog/native-models.ts +7 -0
  235. package/src/codex/catalog/parsing.ts +503 -0
  236. package/src/codex/catalog/provider-fetch.ts +2267 -0
  237. package/src/codex/catalog/sync.ts +1606 -0
  238. package/src/codex/catalog-admission.ts +197 -0
  239. package/src/codex/catalog-refresh-status.ts +87 -0
  240. package/src/codex/catalog-write-serialization.ts +242 -0
  241. package/src/codex/catalog.ts +15 -0
  242. package/src/codex/codex-write-lock.ts +384 -0
  243. package/src/codex/convergence-types.ts +593 -0
  244. package/src/codex/convergence.ts +580 -0
  245. package/src/codex/custom-model-catalog-migration.ts +176 -0
  246. package/src/codex/data/upstream-models.json +830 -0
  247. package/src/codex/desired-state.ts +230 -0
  248. package/src/codex/desktop-client-processes.ts +521 -0
  249. package/src/codex/exec-invocation.ts +22 -0
  250. package/src/codex/features.ts +1091 -0
  251. package/src/codex/generation.ts +202 -0
  252. package/src/codex/history-job.ts +347 -0
  253. package/src/codex/history-lock.ts +242 -0
  254. package/src/codex/history-migration-guardian.ts +115 -0
  255. package/src/codex/history-provider.ts +1075 -0
  256. package/src/codex/history-transition.ts +105 -0
  257. package/src/codex/history-worker.ts +204 -0
  258. package/src/codex/home.ts +206 -0
  259. package/src/codex/inject-coordination.ts +257 -0
  260. package/src/codex/inject.ts +1857 -0
  261. package/src/codex/injected-marker.ts +79 -0
  262. package/src/codex/integration-record.ts +266 -0
  263. package/src/codex/internal/catalog-writer.ts +203 -0
  264. package/src/codex/internal/history-writer.ts +105 -0
  265. package/src/codex/journal.ts +172 -0
  266. package/src/codex/main-account-cache.ts +56 -0
  267. package/src/codex/main-account.ts +40 -0
  268. package/src/codex/management-convergence.ts +114 -0
  269. package/src/codex/model-cache.ts +267 -0
  270. package/src/codex/native-main-admission.ts +47 -0
  271. package/src/codex/native-main-auth-temp.ts +187 -0
  272. package/src/codex/native-main-claim.ts +167 -0
  273. package/src/codex/native-main-lock-file.ts +162 -0
  274. package/src/codex/native-main-owner.ts +329 -0
  275. package/src/codex/native-profile-api.ts +247 -0
  276. package/src/codex/native-profile-manager.ts +1531 -0
  277. package/src/codex/native-profile-processes.ts +121 -0
  278. package/src/codex/native-profile-recovery.ts +99 -0
  279. package/src/codex/native-profile-stage-store.ts +387 -0
  280. package/src/codex/native-profile-startup.ts +348 -0
  281. package/src/codex/native-profile-store.ts +855 -0
  282. package/src/codex/native-profile-types.ts +120 -0
  283. package/src/codex/native-residue.ts +691 -0
  284. package/src/codex/paths.ts +78 -0
  285. package/src/codex/plugins-doctor.ts +242 -0
  286. package/src/codex/pool-rotation.ts +295 -0
  287. package/src/codex/project-config-warnings.ts +426 -0
  288. package/src/codex/prompt-journal.ts +311 -0
  289. package/src/codex/prompt-layers.ts +967 -0
  290. package/src/codex/prompt-lock.ts +143 -0
  291. package/src/codex/provider-adoption.ts +242 -0
  292. package/src/codex/quota-rejection.ts +224 -0
  293. package/src/codex/quota.ts +494 -0
  294. package/src/codex/refresh.ts +60 -0
  295. package/src/codex/routing.ts +1855 -0
  296. package/src/codex/runtime.ts +659 -0
  297. package/src/codex/shim.ts +1215 -0
  298. package/src/codex/subagent-defaults.ts +557 -0
  299. package/src/codex/subagent-model-fallback.ts +560 -0
  300. package/src/codex/sync.ts +238 -0
  301. package/src/codex/transition-state.ts +612 -0
  302. package/src/codex/upstream-host-health.ts +368 -0
  303. package/src/codex/user-identity.ts +374 -0
  304. package/src/codex/warmup.ts +192 -0
  305. package/src/codex/websocket-registry.ts +100 -0
  306. package/src/codex/write-coordination.ts +114 -0
  307. package/src/combos/failover.ts +140 -0
  308. package/src/combos/index.ts +44 -0
  309. package/src/combos/request.ts +64 -0
  310. package/src/combos/resolve.ts +232 -0
  311. package/src/combos/types.ts +392 -0
  312. package/src/config.ts +3270 -0
  313. package/src/generated/model-metadata.ts +144 -0
  314. package/src/github/star-state.ts +203 -0
  315. package/src/grok/inject.ts +540 -0
  316. package/src/grok/inspect.ts +45 -0
  317. package/src/grok/status.ts +127 -0
  318. package/src/grok/sync.ts +66 -0
  319. package/src/images/artifacts.ts +516 -0
  320. package/src/images/fulfill-video.ts +163 -0
  321. package/src/images/fulfill.ts +149 -0
  322. package/src/images/index.ts +4 -0
  323. package/src/images/loop.ts +922 -0
  324. package/src/images/plan.ts +133 -0
  325. package/src/images/synthetic-tool.ts +133 -0
  326. package/src/images/types.ts +41 -0
  327. package/src/images/xai-client.ts +141 -0
  328. package/src/images/xai-video-client.ts +163 -0
  329. package/src/index.ts +22 -0
  330. package/src/integrations/config-io.ts +151 -0
  331. package/src/integrations/journal.ts +315 -0
  332. package/src/integrations/merge.ts +135 -0
  333. package/src/integrations/native/ownership-preflight.ts +202 -0
  334. package/src/integrations/ownership.ts +111 -0
  335. package/src/integrations/registry.ts +108 -0
  336. package/src/integrations/serialize.ts +235 -0
  337. package/src/integrations/state.ts +290 -0
  338. package/src/integrations/store.ts +103 -0
  339. package/src/integrations/writer.ts +492 -0
  340. package/src/lib/abort.ts +146 -0
  341. package/src/lib/admin-secrets.ts +25 -0
  342. package/src/lib/admission.ts +83 -0
  343. package/src/lib/app-owned-memory-stores.ts +173 -0
  344. package/src/lib/app-owned-memory.ts +265 -0
  345. package/src/lib/bounded-body.ts +242 -0
  346. package/src/lib/bun-binary-validator.d.mts +3 -0
  347. package/src/lib/bun-binary-validator.mjs +18 -0
  348. package/src/lib/bun-runtime.ts +184 -0
  349. package/src/lib/bun-stream-caps.ts +127 -0
  350. package/src/lib/config-ownership.ts +438 -0
  351. package/src/lib/crash-guard.ts +344 -0
  352. package/src/lib/debug-log-buffer.ts +83 -0
  353. package/src/lib/debug-settings.ts +108 -0
  354. package/src/lib/debug.ts +31 -0
  355. package/src/lib/destination-policy.ts +316 -0
  356. package/src/lib/errors.ts +364 -0
  357. package/src/lib/eventstream-decoder.ts +253 -0
  358. package/src/lib/gcp-adc.ts +341 -0
  359. package/src/lib/injection-debug-log.ts +58 -0
  360. package/src/lib/local-management-attestation.ts +51 -0
  361. package/src/lib/open-url.ts +25 -0
  362. package/src/lib/pinned-http.ts +182 -0
  363. package/src/lib/privacy.ts +20 -0
  364. package/src/lib/process-control.ts +168 -0
  365. package/src/lib/provider-environment.ts +470 -0
  366. package/src/lib/provider-outbound.ts +203 -0
  367. package/src/lib/provider-url.ts +14 -0
  368. package/src/lib/proxy-env.ts +18 -0
  369. package/src/lib/redact.ts +510 -0
  370. package/src/lib/remodex-home.ts +616 -0
  371. package/src/lib/retry-after.ts +55 -0
  372. package/src/lib/service-secrets.ts +178 -0
  373. package/src/lib/shadow-call.ts +54 -0
  374. package/src/lib/sidecar-tracker.ts +52 -0
  375. package/src/lib/sse-decoder.ts +364 -0
  376. package/src/lib/state-store-registrations.ts +109 -0
  377. package/src/lib/state-store-sweeper.ts +184 -0
  378. package/src/lib/system-restart-contract.ts +73 -0
  379. package/src/lib/test-home-guard.ts +98 -0
  380. package/src/lib/token-estimate.ts +69 -0
  381. package/src/lib/translator-budget.ts +366 -0
  382. package/src/lib/upstream-reachability.ts +91 -0
  383. package/src/lib/upstream-retry.ts +508 -0
  384. package/src/lib/win-exec.ts +115 -0
  385. package/src/lib/win-paths.ts +68 -0
  386. package/src/lib/windows-elevation.ts +705 -0
  387. package/src/lib/windows-secret-acl.ts +817 -0
  388. package/src/lib/windows-user-principal.ts +283 -0
  389. package/src/lib/winsw.ts +402 -0
  390. package/src/model-sources.ts +73 -0
  391. package/src/oauth/anthropic-routing.ts +594 -0
  392. package/src/oauth/anthropic.ts +177 -0
  393. package/src/oauth/callback-server.ts +294 -0
  394. package/src/oauth/chatgpt.ts +150 -0
  395. package/src/oauth/command-code.ts +239 -0
  396. package/src/oauth/cursor.ts +231 -0
  397. package/src/oauth/github-copilot.ts +428 -0
  398. package/src/oauth/google-antigravity.ts +230 -0
  399. package/src/oauth/health.ts +443 -0
  400. package/src/oauth/index.ts +1280 -0
  401. package/src/oauth/key-providers.ts +128 -0
  402. package/src/oauth/kimi.ts +213 -0
  403. package/src/oauth/kiro-credentials.ts +726 -0
  404. package/src/oauth/kiro.ts +621 -0
  405. package/src/oauth/local-token-detect.ts +121 -0
  406. package/src/oauth/log.ts +48 -0
  407. package/src/oauth/login-cli.ts +163 -0
  408. package/src/oauth/pkce.ts +15 -0
  409. package/src/oauth/store.ts +655 -0
  410. package/src/oauth/token-guardian.ts +309 -0
  411. package/src/oauth/types.ts +62 -0
  412. package/src/oauth/xai.ts +241 -0
  413. package/src/providers/alibaba-region-backup.ts +75 -0
  414. package/src/providers/alibaba-region-migration.ts +156 -0
  415. package/src/providers/alibaba-region-startup.ts +36 -0
  416. package/src/providers/antigravity-models.ts +317 -0
  417. package/src/providers/api-keys.ts +140 -0
  418. package/src/providers/base-url-choices.ts +64 -0
  419. package/src/providers/codex-capacity.ts +288 -0
  420. package/src/providers/command-code-efforts.ts +85 -0
  421. package/src/providers/context-cap.ts +73 -0
  422. package/src/providers/derive.ts +451 -0
  423. package/src/providers/free-directory.ts +187 -0
  424. package/src/providers/github-copilot-transport.ts +56 -0
  425. package/src/providers/google-vertex-location.ts +14 -0
  426. package/src/providers/key-failover.ts +271 -0
  427. package/src/providers/kiro-models.ts +67 -0
  428. package/src/providers/label.ts +19 -0
  429. package/src/providers/model-discovery-limits.ts +16 -0
  430. package/src/providers/model-discovery.ts +361 -0
  431. package/src/providers/openai-sidecar.ts +235 -0
  432. package/src/providers/openai-tier-startup.ts +27 -0
  433. package/src/providers/openai-tiers.ts +301 -0
  434. package/src/providers/openai-virtual-models.ts +83 -0
  435. package/src/providers/openrouter-routing.ts +102 -0
  436. package/src/providers/provider-id-rewrite.ts +179 -0
  437. package/src/providers/quota.ts +1942 -0
  438. package/src/providers/reasoning-capabilities.ts +336 -0
  439. package/src/providers/registry.ts +2375 -0
  440. package/src/providers/slug-codec.ts +74 -0
  441. package/src/providers/xai-transport.ts +149 -0
  442. package/src/reasoning-effort.ts +243 -0
  443. package/src/responses/compaction.ts +124 -0
  444. package/src/responses/hosted-tool-policy.ts +9 -0
  445. package/src/responses/parser.ts +714 -0
  446. package/src/responses/reasoning-envelope.ts +60 -0
  447. package/src/responses/reasoning-replay-cache.ts +106 -0
  448. package/src/responses/schema.ts +159 -0
  449. package/src/responses/spill-store.ts +431 -0
  450. package/src/responses/state.ts +1039 -0
  451. package/src/responses/tool-groups.ts +19 -0
  452. package/src/router.ts +802 -0
  453. package/src/routing/analytics.ts +377 -0
  454. package/src/routing/capability.ts +205 -0
  455. package/src/routing/cost.ts +77 -0
  456. package/src/routing/evaluator.ts +444 -0
  457. package/src/routing/health.ts +401 -0
  458. package/src/routing/history/cursor.ts +43 -0
  459. package/src/routing/history/indexer.ts +605 -0
  460. package/src/routing/history/schema.ts +72 -0
  461. package/src/routing/profile-namespace.ts +15 -0
  462. package/src/routing/profile.ts +424 -0
  463. package/src/routing/quota.ts +145 -0
  464. package/src/routing/request-evidence.ts +45 -0
  465. package/src/routing/trace.ts +686 -0
  466. package/src/server/adapter-resolve.ts +83 -0
  467. package/src/server/auth-cors.ts +606 -0
  468. package/src/server/chat-completions.ts +379 -0
  469. package/src/server/claude-messages.ts +980 -0
  470. package/src/server/effort-policy.ts +251 -0
  471. package/src/server/github-copilot-responses-repair.ts +338 -0
  472. package/src/server/gui-static.ts +152 -0
  473. package/src/server/image-retry.ts +42 -0
  474. package/src/server/images.ts +485 -0
  475. package/src/server/index.ts +1633 -0
  476. package/src/server/lifecycle.ts +482 -0
  477. package/src/server/live.ts +609 -0
  478. package/src/server/management/agent-settings-routes.ts +1180 -0
  479. package/src/server/management/android-remote-routes.ts +390 -0
  480. package/src/server/management/api-access.ts +141 -0
  481. package/src/server/management/api-key-usage.ts +167 -0
  482. package/src/server/management/body.ts +35 -0
  483. package/src/server/management/combo-routes.ts +244 -0
  484. package/src/server/management/config-routes.ts +602 -0
  485. package/src/server/management/context.ts +88 -0
  486. package/src/server/management/integration-routes.ts +538 -0
  487. package/src/server/management/logs-usage-routes.ts +516 -0
  488. package/src/server/management/model-routes.ts +519 -0
  489. package/src/server/management/model-rows.ts +143 -0
  490. package/src/server/management/native-integration-routes.ts +781 -0
  491. package/src/server/management/oauth-account-routes.ts +573 -0
  492. package/src/server/management/provider-routes.ts +781 -0
  493. package/src/server/management/request-history-routes.ts +191 -0
  494. package/src/server/management/routing-analytics-routes.ts +74 -0
  495. package/src/server/management/routing-profile-routes.ts +384 -0
  496. package/src/server/management/shared.ts +277 -0
  497. package/src/server/management/sidebar-routes.ts +106 -0
  498. package/src/server/management/sync-response.ts +69 -0
  499. package/src/server/management/system-restart.ts +433 -0
  500. package/src/server/management/system-routes.ts +141 -0
  501. package/src/server/management/usage-summary-cache.ts +86 -0
  502. package/src/server/management-api.ts +269 -0
  503. package/src/server/management-auth.ts +353 -0
  504. package/src/server/memory-watchdog.ts +156 -0
  505. package/src/server/port-reclaim.ts +307 -0
  506. package/src/server/ports.ts +156 -0
  507. package/src/server/proxy-liveness.ts +326 -0
  508. package/src/server/proxy-stop.ts +92 -0
  509. package/src/server/readiness.ts +99 -0
  510. package/src/server/relay-eager.ts +353 -0
  511. package/src/server/relay.ts +1179 -0
  512. package/src/server/request-decompress.ts +132 -0
  513. package/src/server/request-log-conversation.ts +168 -0
  514. package/src/server/request-log.ts +1072 -0
  515. package/src/server/responses/collaboration.ts +409 -0
  516. package/src/server/responses/compact.ts +710 -0
  517. package/src/server/responses/core.ts +3561 -0
  518. package/src/server/responses/encrypted-payload.ts +308 -0
  519. package/src/server/responses/fetch-helpers.ts +171 -0
  520. package/src/server/responses/passthrough-error.ts +78 -0
  521. package/src/server/responses/policy-fallback.ts +152 -0
  522. package/src/server/responses/terminal-guard.ts +230 -0
  523. package/src/server/responses/upstream-error.ts +48 -0
  524. package/src/server/responses-image-gen-repair.ts +132 -0
  525. package/src/server/responses-item-id-repair.ts +272 -0
  526. package/src/server/responses-json-events.ts +52 -0
  527. package/src/server/responses-model-rewrite.ts +29 -0
  528. package/src/server/responses-snapshot-repair.ts +621 -0
  529. package/src/server/responses.ts +10 -0
  530. package/src/server/search.ts +181 -0
  531. package/src/server/sse-frame-buffer.ts +292 -0
  532. package/src/server/sse-payload-rewrite.ts +263 -0
  533. package/src/server/startup-action-control.ts +308 -0
  534. package/src/server/startup-health-cache.ts +119 -0
  535. package/src/server/system-env.ts +418 -0
  536. package/src/server/windows-tcp-drop.ts +184 -0
  537. package/src/server/windows-tray-control.ts +41 -0
  538. package/src/server/ws-bridge.ts +470 -0
  539. package/src/service-manager-probe.ts +824 -0
  540. package/src/service.ts +3011 -0
  541. package/src/stall-timeout.ts +20 -0
  542. package/src/storage/cleanup-job.ts +57 -0
  543. package/src/storage/cleanup.ts +3085 -0
  544. package/src/storage/policy-job.ts +457 -0
  545. package/src/storage/policy-scheduler.ts +40 -0
  546. package/src/storage/policy-worker.ts +59 -0
  547. package/src/storage/policy.ts +527 -0
  548. package/src/storage/restore-job.ts +299 -0
  549. package/src/storage/restore-worker.ts +58 -0
  550. package/src/storage/scanner.ts +238 -0
  551. package/src/storage/storage-mutation-coordinator.ts +139 -0
  552. package/src/storage/worker-lifecycle.ts +215 -0
  553. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  554. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  555. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  556. package/src/tray/assets/opencodex-tray.png +0 -0
  557. package/src/tray/windows-tray.ps1 +364 -0
  558. package/src/tray/windows.ts +738 -0
  559. package/src/types.ts +1531 -0
  560. package/src/update/badge.ts +72 -0
  561. package/src/update/desktop-release.ts +1620 -0
  562. package/src/update/index.ts +402 -0
  563. package/src/update/job.ts +1906 -0
  564. package/src/update/notify.ts +261 -0
  565. package/src/update/npm-cache-preflight.d.mts +47 -0
  566. package/src/update/npm-cache-preflight.mjs +201 -0
  567. package/src/update/npm-invocation.d.mts +23 -0
  568. package/src/update/npm-invocation.mjs +94 -0
  569. package/src/update/tray-update-plan.d.mts +18 -0
  570. package/src/update/tray-update-plan.mjs +38 -0
  571. package/src/usage/cost.ts +0 -0
  572. package/src/usage/debug.ts +97 -0
  573. package/src/usage/expected-prices.ts +283 -0
  574. package/src/usage/log.ts +695 -0
  575. package/src/usage/summary.ts +585 -0
  576. package/src/usage/totals.ts +14 -0
  577. package/src/vision/anthropic-describe.ts +185 -0
  578. package/src/vision/describe.ts +127 -0
  579. package/src/vision/index.ts +558 -0
  580. package/src/vision/reasoning.ts +55 -0
  581. package/src/web-search/anthropic-executor.ts +189 -0
  582. package/src/web-search/executor.ts +105 -0
  583. package/src/web-search/format-result.ts +89 -0
  584. package/src/web-search/index.ts +196 -0
  585. package/src/web-search/loop.ts +791 -0
  586. package/src/web-search/parse.ts +235 -0
  587. package/src/web-search/progress-stream.ts +342 -0
  588. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,3643 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createConnection, type Socket } from "node:net";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { posix, win32 } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import type { CodexJsonRpcMessage } from "./codex-app-server";
7
+ import {
8
+ createAndroidDesktopOwnershipStore,
9
+ type AndroidDesktopOwnershipStore,
10
+ } from "./desktop-ownership-store";
11
+ import { canonicalUserMessageText } from "./user-message-identity";
12
+ import {
13
+ DESKTOP_HISTORY_PAGE_MAX_ITEMS,
14
+ DESKTOP_HISTORY_PAGE_MAX_TURNS,
15
+ DesktopHistoryPageStore,
16
+ type DesktopBoundedHistoryPage,
17
+ type DesktopContentChunk,
18
+ type DesktopHistoryPageDirection,
19
+ type DesktopJsonRecord,
20
+ } from "./desktop-history-page";
21
+
22
+ type JsonRecord = Record<string, unknown>;
23
+ type RequestId = string | number;
24
+ type Timer = ReturnType<typeof setTimeout>;
25
+
26
+ const FRAME_HEADER_BYTES = 4;
27
+ const MAX_FRAME_BYTES = 64 * 1024 * 1024;
28
+ const MAX_FRAME_BUFFER_BYTES = FRAME_HEADER_BYTES + MAX_FRAME_BYTES;
29
+ const SNAPSHOT_DEBOUNCE_MS = 75;
30
+ const RECONNECT_MS = 1_500;
31
+ const REQUEST_TIMEOUT_MS = 10_000;
32
+ const FOLLOW_CONFIRM_MS = 1_500;
33
+ const FOLLOW_MAX_ATTEMPTS = 3;
34
+ const FOLLOW_STATE_TIMEOUT_MS = 3_000;
35
+ const OWNER_REACQUIRE_TIMEOUT_MS = 1_500;
36
+ const FOLLOWER_MOUNT_BASELINE_DELAY_MS = 250;
37
+ const SIDEBAR_REFRESH_DELAY_MS = 1_200;
38
+ const INITIAL_HISTORY_RETRY_MS = 1_000;
39
+ const INITIAL_HISTORY_MAX_ATTEMPTS = 5;
40
+ const FOLLOWER_BASELINE_RETRY_MS = 1_000;
41
+ const FOLLOWER_BASELINE_MAX_RETRY_MS = 15_000;
42
+ const FOLLOWER_BASELINE_MAX_ATTEMPTS = 5;
43
+ const MAX_QUEUED_FOLLOWER_CHANGES = 300;
44
+ const MAX_PATCH_COUNT = 2_000;
45
+ const MAX_PATCH_BYTES = 512 * 1024;
46
+ const MAX_STREAM_TEXT_BYTES = 1024 * 1024;
47
+ /**
48
+ * A Desktop renderer can publish its stream snapshot before it has registered
49
+ * the follower request handler. A router-level no-client-found is the one
50
+ * pre-delivery failure that is safe to retry. Keep this schedule centralized
51
+ * so every follower mutation has identical delivery semantics.
52
+ */
53
+ const FOLLOWER_OWNER_RETRY_DELAYS_MS = [0, 125, 350] as const;
54
+ const THREAD_STREAM_STATE_CHANGED = "thread-stream-state-changed";
55
+ const THREAD_STREAM_FOLLOWING_CHANGED = "thread-stream-following-changed";
56
+ const CLIENT_STATUS_CHANGED = "client-status-changed";
57
+ const THREAD_ARCHIVED = "thread-archived";
58
+ /**
59
+ * Metadata-only route probe. This method must never cause a renderer to
60
+ * materialize or broadcast conversation history; the router uses the
61
+ * discovery response (and its handledByClientId) solely to identify the
62
+ * current writer.
63
+ */
64
+ export const DESKTOP_IPC_OWNER_DISCOVERY_METHOD = "thread-owner-discovery";
65
+ const THREAD_OWNER_DISCOVERY = DESKTOP_IPC_OWNER_DISCOVERY_METHOD;
66
+ const THREAD_FOLLOWER_LOAD_HISTORY_PAGE = "thread-follower-load-history-page";
67
+ const THREAD_FOLLOWER_READ_CONTENT_CHUNK = "thread-follower-read-content-chunk";
68
+ const HOST_ID = "local";
69
+ const OWNER_SOURCE = "opencodex-android-live-owner";
70
+
71
+ /**
72
+ * Single source of truth for the private Codex Desktop IPC protocol. The
73
+ * Desktop renderer validates these versions before dispatching a request; a
74
+ * stale version is a protocol error, never evidence that the thread writer was
75
+ * released.
76
+ */
77
+ export const DESKTOP_IPC_METHOD_VERSIONS = new Map<string, number>([
78
+ ["initialize", 1],
79
+ [CLIENT_STATUS_CHANGED, 1],
80
+ [THREAD_STREAM_STATE_CHANGED, 11],
81
+ [THREAD_STREAM_FOLLOWING_CHANGED, 1],
82
+ ["thread-stream-following-status-requested", 1],
83
+ [THREAD_OWNER_DISCOVERY, 1],
84
+ [THREAD_FOLLOWER_LOAD_HISTORY_PAGE, 1],
85
+ [THREAD_FOLLOWER_READ_CONTENT_CHUNK, 1],
86
+ [THREAD_ARCHIVED, 2],
87
+ ["thread-unarchived", 1],
88
+ ["thread-read-state-changed", 2],
89
+ ["thread-queued-followups-changed", 1],
90
+ ["thread-follower-start-turn", 2],
91
+ ["thread-follower-load-complete-history", 1],
92
+ ["thread-follower-update-thread-settings", 1],
93
+ ["thread-follower-compact-thread", 1],
94
+ ["thread-follower-steer-turn", 1],
95
+ ["thread-follower-interrupt-turn", 4],
96
+ ["thread-follower-rollback-thread", 1],
97
+ ["thread-follower-edit-last-user-turn", 2],
98
+ ["thread-follower-set-model-and-reasoning", 1],
99
+ ["thread-follower-set-collaboration-mode", 1],
100
+ ["thread-follower-command-approval-decision", 1],
101
+ ["thread-follower-file-approval-decision", 1],
102
+ ["thread-follower-permissions-request-approval-response", 1],
103
+ ["thread-follower-submit-user-input", 1],
104
+ ["thread-follower-submit-mcp-server-elicitation-response", 1],
105
+ ["thread-follower-set-queued-follow-ups-state", 1],
106
+ ]);
107
+
108
+ /** Compatibility rule retained deliberately for old stop buttons. */
109
+ export function desktopIpcRequestVersion(method: string, params: unknown = {}): number {
110
+ if (
111
+ method === "thread-follower-interrupt-turn"
112
+ && (!record(params)?.expectedTurnId && !record(params)?.expected_turn_id)
113
+ ) {
114
+ return 3;
115
+ }
116
+ return DESKTOP_IPC_METHOD_VERSIONS.get(method) ?? 1;
117
+ }
118
+
119
+ const FOLLOWER_METHODS = new Set([
120
+ "thread-follower-start-turn",
121
+ "thread-follower-load-complete-history",
122
+ THREAD_FOLLOWER_LOAD_HISTORY_PAGE,
123
+ THREAD_FOLLOWER_READ_CONTENT_CHUNK,
124
+ "thread-follower-update-thread-settings",
125
+ "thread-follower-compact-thread",
126
+ "thread-follower-steer-turn",
127
+ "thread-follower-interrupt-turn",
128
+ "thread-follower-rollback-thread",
129
+ "thread-follower-set-model-and-reasoning",
130
+ "thread-follower-set-collaboration-mode",
131
+ "thread-follower-command-approval-decision",
132
+ "thread-follower-file-approval-decision",
133
+ "thread-follower-permissions-request-approval-response",
134
+ "thread-follower-submit-user-input",
135
+ "thread-follower-submit-mcp-server-elicitation-response",
136
+ "thread-follower-set-queued-follow-ups-state",
137
+ ]);
138
+
139
+ const SERVER_REQUEST_METHODS = new Set([
140
+ "item/commandExecution/requestApproval",
141
+ "item/fileChange/requestApproval",
142
+ "item/fileRead/requestApproval",
143
+ "item/permissions/requestApproval",
144
+ "item/tool/requestUserInput",
145
+ "item/tool/requestMcpServerElicitation",
146
+ ]);
147
+
148
+ const ALLOWED_TURN_START_KEYS = new Set([
149
+ "threadId",
150
+ "clientUserMessageId",
151
+ "input",
152
+ "cwd",
153
+ "approvalPolicy",
154
+ "approvalsReviewer",
155
+ "sandboxPolicy",
156
+ "model",
157
+ "serviceTier",
158
+ "effort",
159
+ "summary",
160
+ "personality",
161
+ "outputSchema",
162
+ "collaborationMode",
163
+ ]);
164
+
165
+ export type DesktopIpcEnvelope = JsonRecord & {
166
+ type?: string;
167
+ method?: string;
168
+ requestId?: RequestId;
169
+ sourceClientId?: string;
170
+ targetClientId?: string;
171
+ targetClientIds?: string[];
172
+ timeoutMs?: number;
173
+ params?: JsonRecord;
174
+ };
175
+
176
+ export type DesktopIpcResponseSettled = {
177
+ requestId: RequestId;
178
+ method: string;
179
+ threadId: string;
180
+ commandId: string;
181
+ localClientId: string;
182
+ handledByClientId: string;
183
+ targetClientId?: string;
184
+ resultType: "success" | "error";
185
+ };
186
+
187
+ export type DesktopThreadOwnershipState = "unknown" | "desktop-owned" | "local-owned";
188
+
189
+ export type DesktopThreadOwnership = {
190
+ state: DesktopThreadOwnershipState;
191
+ /** Current Desktop renderer owner, when one has been observed. */
192
+ ownerClientId: string | null;
193
+ /** True once any authoritative Desktop state/response has named an owner. */
194
+ everDesktopOwned: boolean;
195
+ };
196
+
197
+ export type DesktopIpcConnectionSnapshot = {
198
+ connected: boolean;
199
+ generation: number;
200
+ localClientId: string;
201
+ };
202
+
203
+ export type DesktopIpcDiscoveryResult = {
204
+ canHandle: boolean;
205
+ handledByClientId?: string;
206
+ timedOut?: boolean;
207
+ };
208
+
209
+ export interface AndroidDesktopIpcSync {
210
+ start(): void;
211
+ stop(): void;
212
+ claimThread(input: {
213
+ threadId: string;
214
+ turnStartParams: JsonRecord;
215
+ cwd?: string;
216
+ title?: string;
217
+ }): void;
218
+ releaseThread(threadId: string): void;
219
+ observeCodexMessage(message: CodexJsonRpcMessage): void;
220
+ isThreadOwned(threadId: string): boolean;
221
+ /**
222
+ * Distinguishes the real app-server writer from this bridge's presentation
223
+ * mirror. Older test doubles may omit this optional method; callers then
224
+ * conservatively derive local ownership from `isThreadOwned`.
225
+ */
226
+ threadOwnership?(threadId: string): DesktopThreadOwnership;
227
+ desktopOwnerClientId?(threadId: string): string | null;
228
+ hasObservedDesktopOwner?(threadId: string): boolean;
229
+ /** Explicit, authoritative owner-release/local-adoption transition. */
230
+ releaseDesktopOwnership?(threadId: string): void;
231
+ /** Record that the private app-server has already mounted this thread. */
232
+ adoptLocalThread?(threadId: string): void;
233
+ /**
234
+ * Ask Codex Desktop to perform an action on a task it already owns.
235
+ * A missing Desktop handler is reported as an error so the caller can use
236
+ * the normal local Codex connection instead.
237
+ */
238
+ requestFollowerAction?(method: string, params: JsonRecord): Promise<unknown>;
239
+ readFollowerThreadState?(
240
+ threadId: string,
241
+ options?: { fresh?: boolean },
242
+ ): Promise<JsonRecord | null>;
243
+ /** Read one bounded recent/older/newer page without registering a follower. */
244
+ readFollowerHistoryPage?(
245
+ threadId: string,
246
+ options?: {
247
+ direction?: DesktopHistoryPageDirection;
248
+ pageToken?: string | null;
249
+ },
250
+ ): Promise<JsonRecord | null>;
251
+ /** Read one bounded opaque content chunk associated with a page handle. */
252
+ readFollowerContentChunk?(
253
+ threadId: string,
254
+ input: { handle: string; sourceRevision: string; offset: number },
255
+ ): Promise<DesktopContentChunk>;
256
+ /**
257
+ * Read-only route preflight used before a prompt is written to Desktop IPC.
258
+ * `absent` means no Desktop owner handled the request, while `unhealthy`
259
+ * means the route existed but could not publish a trustworthy state.
260
+ */
261
+ probeFollowerRoute?(
262
+ threadId: string,
263
+ ): Promise<"ready" | "absent" | "unhealthy">;
264
+ /**
265
+ * Reacquire the current renderer id for a remembered Desktop-owned task.
266
+ * This is a read-only owner probe. A caller must not send a follower
267
+ * mutation until this resolves to a concrete client id.
268
+ */
269
+ reacquireDesktopOwner?(threadId: string): Promise<string | null>;
270
+ /**
271
+ * Non-sensitive connection metadata for route diagnostics. This never
272
+ * includes prompt text, attachments, credentials, or raw IPC payloads.
273
+ */
274
+ connectionSnapshot?(): DesktopIpcConnectionSnapshot;
275
+ /**
276
+ * Bring a Desktop-owned task back into the live IPC owner set. Desktop may
277
+ * release an idle task while its append-only transcript still contains a
278
+ * pending question, so Android response delivery must be able to reattach it.
279
+ */
280
+ activateFollowerThread?(threadId: string): Promise<void>;
281
+ }
282
+
283
+ export type DesktopIpcSyncOptions = {
284
+ platform?: NodeJS.Platform;
285
+ socketPaths?: () => string[];
286
+ now?: () => number;
287
+ connect?: (path: string) => Socket;
288
+ openUrl?: (url: string) => Promise<void>;
289
+ readThread: (threadId: string) => Promise<JsonRecord | null>;
290
+ /** Optional byte-bounded recent-page reader used during local hydration. */
291
+ readThreadPage?: (threadId: string) => Promise<JsonRecord | null>;
292
+ sendCodexRequest: (method: string, params: JsonRecord) => Promise<unknown>;
293
+ respondToCodexRequest: (id: RequestId, result: JsonRecord) => void;
294
+ log?: (message: string) => void;
295
+ warn?: (message: string) => void;
296
+ reconnectMs?: number;
297
+ snapshotDebounceMs?: number;
298
+ followConfirmMs?: number;
299
+ followMaxAttempts?: number;
300
+ initialHistoryRetryMs?: number;
301
+ initialHistoryMaxAttempts?: number;
302
+ followerBaselineRetryMs?: number;
303
+ followerBaselineMaxRetryMs?: number;
304
+ followerBaselineMaxAttempts?: number;
305
+ followerStateTimeoutMs?: number;
306
+ ownerReacquireTimeoutMs?: number;
307
+ ownershipStore?: AndroidDesktopOwnershipStore;
308
+ historyPageStore?: DesktopHistoryPageStore;
309
+ transport?: DesktopIpcTransportLike;
310
+ };
311
+
312
+ export type CodexDesktopOpenCommand = {
313
+ command: string;
314
+ args: string[];
315
+ };
316
+
317
+ export interface DesktopIpcTransportLike {
318
+ start(): void;
319
+ stop(): void;
320
+ sendBroadcast(method: string, params: JsonRecord): boolean;
321
+ request(
322
+ method: string,
323
+ params: JsonRecord,
324
+ options?: { targetClientId?: string },
325
+ ): Promise<unknown>;
326
+ /**
327
+ * Read-only owner discovery through the router's public request path. The
328
+ * discovery method itself returns no history or transcript state.
329
+ */
330
+ discover?(
331
+ method: string,
332
+ params: JsonRecord,
333
+ options?: { targetClientId?: string; timeoutMs?: number },
334
+ ): Promise<DesktopIpcDiscoveryResult>;
335
+ reset?(reason?: string): void;
336
+ setHandlers(handlers: DesktopIpcTransportHandlers): void;
337
+ readonly connected: boolean;
338
+ readonly localClientId?: string;
339
+ readonly generation?: number;
340
+ }
341
+
342
+ type DesktopIpcTransportHandlers = {
343
+ onConnected: () => void;
344
+ onDisconnected?: () => void;
345
+ onBroadcast: (envelope: DesktopIpcEnvelope) => void;
346
+ canHandleRequest: (envelope: DesktopIpcEnvelope) => boolean;
347
+ handleRequest: (envelope: DesktopIpcEnvelope) => Promise<unknown>;
348
+ };
349
+
350
+ type ConversationTurn = JsonRecord & {
351
+ id: string;
352
+ turnId: string;
353
+ params: JsonRecord;
354
+ items: JsonRecord[];
355
+ status: unknown;
356
+ };
357
+
358
+ type ConversationState = JsonRecord & {
359
+ id: string;
360
+ hostId: string;
361
+ turns: ConversationTurn[];
362
+ requests: JsonRecord[];
363
+ updatedAt: number;
364
+ };
365
+
366
+ type JsonPatch = {
367
+ op: "add" | "remove" | "replace";
368
+ path: Array<string | number>;
369
+ value?: unknown;
370
+ };
371
+
372
+ function record(value: unknown): JsonRecord | null {
373
+ return value && typeof value === "object" && !Array.isArray(value)
374
+ ? value as JsonRecord
375
+ : null;
376
+ }
377
+
378
+ function stringValue(value: unknown): string {
379
+ return typeof value === "string" ? value.trim() : "";
380
+ }
381
+
382
+ function threadSettingsUpdateUnavailable(error: unknown): boolean {
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ return /method not found|unknown method|unsupported method|thread\/settings\/update.*(?:unsupported|unavailable)|experimental.*disabled|-32601/iu.test(message);
385
+ }
386
+
387
+ function numberValue(value: unknown): number | null {
388
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
389
+ }
390
+
391
+ function requestIdKey(value: unknown): string {
392
+ if (typeof value === "string" && value) return value;
393
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
394
+ }
395
+
396
+ function clone<T>(value: T): T {
397
+ return structuredClone(value);
398
+ }
399
+
400
+ function normalizeToken(value: unknown): string {
401
+ return stringValue(value).replace(/[^a-z0-9]/giu, "").toLowerCase();
402
+ }
403
+
404
+ function threadIdFromParams(params: unknown): string {
405
+ const row = record(params);
406
+ const turn = record(row?.turn);
407
+ const thread = record(row?.thread);
408
+ return stringValue(row?.threadId)
409
+ || stringValue(row?.thread_id)
410
+ || stringValue(row?.conversationId)
411
+ || stringValue(row?.conversation_id)
412
+ || stringValue(turn?.threadId)
413
+ || stringValue(thread?.id);
414
+ }
415
+
416
+ function commandIdFromParams(params: unknown): string {
417
+ const row = record(params);
418
+ const turnStart = record(row?.turnStart);
419
+ const turnStartRequest = record(turnStart?.request);
420
+ const turnStartParams = record(row?.turnStartParams) ?? record(row?.turn_start_params);
421
+ const turnSteerParams = record(row?.turnSteerParams) ?? record(row?.turn_steer_params);
422
+ return stringValue(row?.senderRequestId)
423
+ || stringValue(row?.sender_request_id)
424
+ || stringValue(row?.commandId)
425
+ || stringValue(row?.command_id)
426
+ || stringValue(row?.clientUserMessageId)
427
+ || stringValue(row?.client_user_message_id)
428
+ || stringValue(turnStartRequest?.clientUserMessageId)
429
+ || stringValue(turnStartRequest?.client_user_message_id)
430
+ || stringValue(turnStartParams?.clientUserMessageId)
431
+ || stringValue(turnStartParams?.client_user_message_id)
432
+ || stringValue(turnSteerParams?.clientUserMessageId)
433
+ || stringValue(turnSteerParams?.client_user_message_id);
434
+ }
435
+
436
+ function diagnosticToken(value: unknown, maximum = 160): string {
437
+ const normalized = stringValue(value)
438
+ .replace(/[^a-z0-9._:/-]/giu, "_")
439
+ .slice(0, maximum);
440
+ return normalized || "-";
441
+ }
442
+
443
+ function desktopFollowerRouteIsAbsent(error: unknown): boolean {
444
+ const message = error instanceof Error ? error.message : String(error);
445
+ return /no codex ipc client can handle|no-client-found|conversation-not-owned|thread not found|conversation not found|not connected/iu.test(
446
+ message,
447
+ );
448
+ }
449
+
450
+ function desktopFollowerNoClientFound(error: unknown): boolean {
451
+ const message = error instanceof Error ? error.message : String(error);
452
+ // Only router-level absence is retryable. A timeout, socket close, protocol
453
+ // mismatch, or Desktop application error may have happened after the write.
454
+ return /no codex ipc client can handle|no-client-found/iu.test(message);
455
+ }
456
+
457
+ function desktopFollowerMethodUnsupported(error: unknown): boolean {
458
+ const message = error instanceof Error ? error.message : String(error);
459
+ return /(?:method|request).*(?:not found|unknown|unsupported|unavailable)|unknown method|unsupported method|-32601/iu.test(
460
+ message,
461
+ );
462
+ }
463
+
464
+ export class DesktopIpcOwnershipError extends Error {
465
+ constructor(
466
+ message: string,
467
+ readonly threadId: string,
468
+ readonly method: string,
469
+ readonly attempts: number,
470
+ readonly reason: "no-client-found" | "owner-unavailable" | "ownership-conflict" = "ownership-conflict",
471
+ ) {
472
+ super(message);
473
+ this.name = "DesktopIpcOwnershipError";
474
+ }
475
+ }
476
+
477
+ function turnIdFromParams(params: unknown): string {
478
+ const row = record(params);
479
+ const turn = record(row?.turn);
480
+ return stringValue(row?.turnId)
481
+ || stringValue(row?.turn_id)
482
+ || stringValue(turn?.id)
483
+ || stringValue(turn?.turnId);
484
+ }
485
+
486
+ function timestampMs(value: unknown, fallback: number): number {
487
+ return typeof value === "number" && Number.isFinite(value) && value > 0
488
+ ? Math.round(value < 10_000_000_000 ? value * 1_000 : value)
489
+ : fallback;
490
+ }
491
+
492
+ function waitMs(delayMs: number): Promise<void> {
493
+ return new Promise(resolve => {
494
+ const timer = setTimeout(resolve, Math.max(0, delayMs));
495
+ timer.unref?.();
496
+ });
497
+ }
498
+
499
+ export function desktopIpcSocketPaths(
500
+ platform: NodeJS.Platform = process.platform,
501
+ environment: NodeJS.ProcessEnv = process.env,
502
+ ): string[] {
503
+ if (platform === "win32") return ["\\\\.\\pipe\\codex-ipc"];
504
+ if (platform !== "darwin" && platform !== "linux") return [];
505
+ const configuredHome = stringValue(environment.CODEX_HOME);
506
+ const codexHome = configuredHome
507
+ ? posix.normalize(configuredHome.replace(/\\/gu, "/"))
508
+ : posix.join(homedir().replace(/\\/gu, "/"), ".codex");
509
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
510
+ return [
511
+ posix.join(codexHome, "ipc", "ipc.sock"),
512
+ posix.join(tmpdir().replace(/\\/gu, "/"), "codex-ipc", `ipc-${uid}.sock`),
513
+ ];
514
+ }
515
+
516
+ export function encodeDesktopIpcFrame(envelope: DesktopIpcEnvelope): Buffer {
517
+ const body = Buffer.from(JSON.stringify(envelope), "utf8");
518
+ if (body.length > MAX_FRAME_BYTES) {
519
+ throw new RangeError("Codex Desktop IPC frame exceeds the 64 MB limit");
520
+ }
521
+ const header = Buffer.alloc(FRAME_HEADER_BYTES);
522
+ header.writeUInt32LE(body.length, 0);
523
+ return Buffer.concat([header, body]);
524
+ }
525
+
526
+ export class DesktopIpcFrameReader {
527
+ private buffer = Buffer.alloc(0);
528
+
529
+ constructor(
530
+ private readonly onFrame: (envelope: DesktopIpcEnvelope) => void,
531
+ private readonly onCorruption: (error: Error) => void = () => undefined,
532
+ ) {}
533
+
534
+ push(chunk: Buffer): void {
535
+ if (chunk.length === 0) return;
536
+ if (
537
+ chunk.length > MAX_FRAME_BUFFER_BYTES
538
+ || this.buffer.length > MAX_FRAME_BUFFER_BYTES - chunk.length
539
+ ) {
540
+ this.fail("Codex Desktop IPC receive buffer exceeded the 64 MB frame limit");
541
+ return;
542
+ }
543
+ try {
544
+ this.buffer = this.buffer.length === 0
545
+ ? Buffer.from(chunk)
546
+ : Buffer.concat([this.buffer, chunk], this.buffer.length + chunk.length);
547
+ } catch {
548
+ this.fail("Codex Desktop IPC could not allocate its bounded receive buffer");
549
+ return;
550
+ }
551
+ while (this.buffer.length >= FRAME_HEADER_BYTES) {
552
+ const frameLength = this.buffer.readUInt32LE(0);
553
+ if (frameLength > MAX_FRAME_BYTES) {
554
+ this.fail("Codex Desktop IPC frame length exceeded the 64 MB limit");
555
+ return;
556
+ }
557
+ if (this.buffer.length < FRAME_HEADER_BYTES + frameLength) return;
558
+ const text = this.buffer.subarray(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
559
+ this.buffer = this.buffer.subarray(FRAME_HEADER_BYTES + frameLength);
560
+ try {
561
+ const envelope = record(JSON.parse(text));
562
+ if (!envelope) {
563
+ this.fail("Codex Desktop IPC frame did not contain an object envelope");
564
+ return;
565
+ }
566
+ this.onFrame(envelope as DesktopIpcEnvelope);
567
+ } catch {
568
+ // A malformed body or throwing frame handler means this connection can
569
+ // no longer be trusted. Reconnect instead of attempting to continue on
570
+ // a potentially misaligned byte stream.
571
+ this.fail("Codex Desktop IPC received a malformed frame");
572
+ return;
573
+ }
574
+ }
575
+ }
576
+
577
+ reset(): void {
578
+ this.buffer = Buffer.alloc(0);
579
+ }
580
+
581
+ private fail(message: string): void {
582
+ this.buffer = Buffer.alloc(0);
583
+ this.onCorruption(new Error(message));
584
+ }
585
+ }
586
+
587
+ export class DesktopIpcTransport implements DesktopIpcTransportLike {
588
+ private socket: Socket | null = null;
589
+ private reader: DesktopIpcFrameReader | null = null;
590
+ private connecting = false;
591
+ private initialized = false;
592
+ private clientId = "";
593
+ private connectionGeneration = 0;
594
+ private shouldReconnect = false;
595
+ private reconnectTimer: Timer | null = null;
596
+ private remainingPaths: string[] = [];
597
+ private handlers: DesktopIpcTransportHandlers = {
598
+ onConnected: () => undefined,
599
+ onBroadcast: () => undefined,
600
+ canHandleRequest: () => false,
601
+ handleRequest: async () => null,
602
+ };
603
+ private readonly pending = new Map<string, {
604
+ method: string;
605
+ threadId: string;
606
+ commandId: string;
607
+ resolve: (value: unknown) => void;
608
+ reject: (error: Error) => void;
609
+ timer: Timer;
610
+ generation: number;
611
+ targetClientId?: string;
612
+ }>();
613
+ private readonly pendingDiscoveries = new Map<string, {
614
+ resolve: (result: DesktopIpcDiscoveryResult) => void;
615
+ timer: Timer;
616
+ }>();
617
+
618
+ constructor(private readonly options: {
619
+ paths: () => string[];
620
+ connect: (path: string) => Socket;
621
+ now: () => number;
622
+ reconnectMs: number;
623
+ requestTimeoutMs?: number;
624
+ warn: (message: string) => void;
625
+ onResponseSettled?: (response: DesktopIpcResponseSettled) => void;
626
+ }) {}
627
+
628
+ get connected(): boolean {
629
+ return Boolean(this.socket && !this.socket.destroyed && this.initialized);
630
+ }
631
+
632
+ get localClientId(): string {
633
+ return this.clientId;
634
+ }
635
+
636
+ get generation(): number {
637
+ return this.connectionGeneration;
638
+ }
639
+
640
+ setHandlers(handlers: DesktopIpcTransportHandlers): void {
641
+ this.handlers = handlers;
642
+ }
643
+
644
+ start(): void {
645
+ this.shouldReconnect = true;
646
+ this.ensureConnected();
647
+ }
648
+
649
+ stop(): void {
650
+ this.shouldReconnect = false;
651
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
652
+ this.reconnectTimer = null;
653
+ this.closeSocket();
654
+ for (const waiter of this.pending.values()) {
655
+ clearTimeout(waiter.timer);
656
+ waiter.reject(new Error("Codex Desktop IPC stopped"));
657
+ }
658
+ this.pending.clear();
659
+ for (const pending of this.pendingDiscoveries.values()) {
660
+ clearTimeout(pending.timer);
661
+ pending.resolve({ canHandle: false, timedOut: true });
662
+ }
663
+ this.pendingDiscoveries.clear();
664
+ }
665
+
666
+ reset(reason = "Codex Desktop IPC connection was reset"): void {
667
+ this.closeSocket(new Error(reason));
668
+ }
669
+
670
+ sendBroadcast(method: string, params: JsonRecord): boolean {
671
+ this.ensureConnected();
672
+ if (!this.connected) return false;
673
+ return this.write({
674
+ type: "broadcast",
675
+ method,
676
+ sourceClientId: this.clientId,
677
+ version: desktopIpcRequestVersion(method, params),
678
+ params,
679
+ });
680
+ }
681
+
682
+ request(
683
+ method: string,
684
+ params: JsonRecord,
685
+ options: { targetClientId?: string } = {},
686
+ ): Promise<unknown> {
687
+ this.ensureConnected();
688
+ return this.sendRequest(method, params, false, undefined, options);
689
+ }
690
+
691
+ discover(
692
+ method: string,
693
+ params: JsonRecord,
694
+ options: { targetClientId?: string; timeoutMs?: number } = {},
695
+ ): Promise<DesktopIpcDiscoveryResult> {
696
+ this.ensureConnected();
697
+ const socket = this.socket;
698
+ const generation = this.connectionGeneration;
699
+ if (
700
+ !socket
701
+ || socket.destroyed
702
+ || !this.isCurrentConnection(socket, generation)
703
+ ) return Promise.resolve({ canHandle: false, timedOut: true });
704
+ const requestId = `opencodex-discovery-${this.options.now().toString(36)}-${randomUUID()}`;
705
+ const timeoutMs = Math.max(1, options.timeoutMs ?? OWNER_REACQUIRE_TIMEOUT_MS);
706
+ return new Promise(resolvePromise => {
707
+ const timer = setTimeout(() => {
708
+ this.pendingDiscoveries.delete(requestId);
709
+ resolvePromise({ canHandle: false, timedOut: true });
710
+ }, timeoutMs);
711
+ timer.unref?.();
712
+ this.pendingDiscoveries.set(requestId, { resolve: resolvePromise, timer });
713
+ // Clients send an ordinary request to the Desktop router. The router
714
+ // creates its private client-discovery-request envelope when asking
715
+ // renderer candidates whether they can handle this metadata-only method.
716
+ // Sending that private envelope from here makes the router reject it as
717
+ // an unexpected server-side message and every Stop/Steer probe times out.
718
+ const sent = this.write({
719
+ type: "request",
720
+ requestId,
721
+ sourceClientId: this.clientId || "opencodex-android-bridge",
722
+ version: desktopIpcRequestVersion(method, params),
723
+ method,
724
+ params,
725
+ timeoutMs,
726
+ ...(stringValue(options.targetClientId)
727
+ ? { targetClientId: stringValue(options.targetClientId) }
728
+ : {}),
729
+ }, socket, generation);
730
+ if (!sent) {
731
+ clearTimeout(timer);
732
+ this.pendingDiscoveries.delete(requestId);
733
+ resolvePromise({ canHandle: false, timedOut: true });
734
+ }
735
+ });
736
+ }
737
+
738
+ private ensureConnected(): void {
739
+ if (!this.shouldReconnect || this.socket || this.connecting) return;
740
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
741
+ this.reconnectTimer = null;
742
+ this.remainingPaths = this.options.paths();
743
+ this.connectNext();
744
+ }
745
+
746
+ private connectNext(): void {
747
+ const path = this.remainingPaths.shift();
748
+ if (!path) {
749
+ this.connecting = false;
750
+ this.scheduleReconnect();
751
+ return;
752
+ }
753
+ this.connecting = true;
754
+ let socket: Socket;
755
+ try {
756
+ socket = this.options.connect(path);
757
+ } catch (error) {
758
+ this.connecting = false;
759
+ if (this.remainingPaths.length > 0) {
760
+ this.connectNext();
761
+ } else {
762
+ this.scheduleReconnect();
763
+ }
764
+ this.options.warn(
765
+ `Codex Desktop IPC connection failed: ${error instanceof Error ? error.message : "unknown error"}`,
766
+ );
767
+ return;
768
+ }
769
+ const generation = this.connectionGeneration + 1;
770
+ this.connectionGeneration = generation;
771
+ this.socket = socket;
772
+ const reader = new DesktopIpcFrameReader(
773
+ envelope => {
774
+ if (!this.isCurrentConnection(socket, generation)) return;
775
+ this.dispatch(envelope);
776
+ },
777
+ error => {
778
+ if (!this.isCurrentConnection(socket, generation)) return;
779
+ this.options.warn(error.message);
780
+ this.closeSocket(error, socket, generation);
781
+ },
782
+ );
783
+ this.reader = reader;
784
+ socket.once("connect", () => {
785
+ if (!this.isCurrentConnection(socket, generation)) return;
786
+ this.connecting = false;
787
+ void this.sendRequest(
788
+ "initialize",
789
+ { clientType: "opencodex-android-bridge" },
790
+ true,
791
+ { socket, generation },
792
+ )
793
+ .then(result => {
794
+ if (!this.isCurrentConnection(socket, generation)) return;
795
+ this.clientId = stringValue(record(result)?.clientId) || this.clientId;
796
+ this.initialized = true;
797
+ this.handlers.onConnected();
798
+ })
799
+ .catch(error => {
800
+ if (!this.isCurrentConnection(socket, generation)) return;
801
+ this.options.warn(`Codex Desktop IPC initialization failed: ${error instanceof Error ? error.message : "unknown error"}`);
802
+ this.closeSocket(
803
+ new Error("Codex Desktop IPC initialization failed"),
804
+ socket,
805
+ generation,
806
+ );
807
+ });
808
+ });
809
+ socket.on("data", chunk => {
810
+ if (!this.isCurrentConnection(socket, generation)) return;
811
+ try {
812
+ reader.push(Buffer.from(chunk));
813
+ } catch {
814
+ const error = new Error("Codex Desktop IPC failed while reading a frame");
815
+ this.options.warn(error.message);
816
+ this.closeSocket(error, socket, generation);
817
+ }
818
+ });
819
+ socket.once("close", () => this.handleClose(socket, generation));
820
+ socket.once("error", error => {
821
+ if (!this.isCurrentConnection(socket, generation)) return;
822
+ const code = (error as NodeJS.ErrnoException).code;
823
+ if (code === "ENOENT" || code === "ECONNREFUSED") {
824
+ if (this.remainingPaths.length > 0) {
825
+ this.socket = null;
826
+ this.reader = null;
827
+ this.connecting = false;
828
+ this.initialized = false;
829
+ this.clientId = "";
830
+ reader.reset();
831
+ socket.destroy();
832
+ this.connectNext();
833
+ } else {
834
+ this.closeSocket(
835
+ new Error("Codex Desktop IPC endpoint is unavailable"),
836
+ socket,
837
+ generation,
838
+ );
839
+ }
840
+ return;
841
+ }
842
+ if (code !== "ENOENT" && code !== "ECONNREFUSED") {
843
+ this.options.warn(`Codex Desktop IPC connection failed: ${error.message}`);
844
+ this.closeSocket(
845
+ new Error("Codex Desktop IPC connection failed"),
846
+ socket,
847
+ generation,
848
+ );
849
+ }
850
+ });
851
+ }
852
+
853
+ private sendRequest(
854
+ method: string,
855
+ params: JsonRecord,
856
+ initializing = false,
857
+ expected?: { socket: Socket; generation: number },
858
+ options: { targetClientId?: string } = {},
859
+ ): Promise<unknown> {
860
+ const socket = expected?.socket ?? this.socket;
861
+ const generation = expected?.generation ?? this.connectionGeneration;
862
+ if (
863
+ !socket
864
+ || socket.destroyed
865
+ || !this.isCurrentConnection(socket, generation)
866
+ ) {
867
+ return Promise.reject(new Error("Codex Desktop IPC is not connected"));
868
+ }
869
+ const requestId = `opencodex-${this.options.now().toString(36)}-${randomUUID()}`;
870
+ return new Promise((resolvePromise, reject) => {
871
+ const timer = setTimeout(() => {
872
+ this.pending.delete(requestId);
873
+ const error = new Error(`Codex Desktop IPC request timed out: ${method}`);
874
+ reject(error);
875
+ if (initializing) {
876
+ this.closeSocket(error, socket, generation);
877
+ return;
878
+ }
879
+ // A thread-specific timeout is not proof that the whole Desktop IPC
880
+ // connection is broken. Keep the connection and fail this request
881
+ // without retrying it through another owner.
882
+ }, this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
883
+ timer.unref?.();
884
+ this.pending.set(requestId, {
885
+ method,
886
+ threadId: threadIdFromParams(params),
887
+ commandId: commandIdFromParams(params),
888
+ resolve: resolvePromise,
889
+ reject,
890
+ timer,
891
+ generation,
892
+ ...(stringValue(options.targetClientId)
893
+ ? { targetClientId: stringValue(options.targetClientId) }
894
+ : {}),
895
+ });
896
+ const sent = this.write({
897
+ type: "request",
898
+ requestId,
899
+ sourceClientId: initializing ? "initializing-client" : this.clientId || "opencodex-android-bridge",
900
+ version: desktopIpcRequestVersion(method, params),
901
+ method,
902
+ params,
903
+ ...(stringValue(options.targetClientId)
904
+ ? { targetClientId: stringValue(options.targetClientId) }
905
+ : {}),
906
+ }, socket, generation);
907
+ if (!sent) {
908
+ clearTimeout(timer);
909
+ this.pending.delete(requestId);
910
+ reject(new Error("Codex Desktop IPC write failed"));
911
+ }
912
+ });
913
+ }
914
+
915
+ private dispatch(envelope: DesktopIpcEnvelope): void {
916
+ if (envelope.type === "client-discovery-response") {
917
+ const key = requestIdKey(envelope.requestId);
918
+ const pending = key ? this.pendingDiscoveries.get(key) : null;
919
+ if (!pending) return;
920
+ this.pendingDiscoveries.delete(key);
921
+ clearTimeout(pending.timer);
922
+ const response = record(envelope.response) ?? {};
923
+ const handledByClientId = stringValue(
924
+ response.handledByClientId
925
+ ?? response.handled_by_client_id
926
+ ?? envelope.handledByClientId,
927
+ );
928
+ pending.resolve({
929
+ canHandle: response.canHandle === true,
930
+ ...(handledByClientId ? { handledByClientId } : {}),
931
+ });
932
+ return;
933
+ }
934
+ if (envelope.type === "response") {
935
+ const key = requestIdKey(envelope.requestId);
936
+ const discovery = key ? this.pendingDiscoveries.get(key) : null;
937
+ if (discovery) {
938
+ this.pendingDiscoveries.delete(key);
939
+ clearTimeout(discovery.timer);
940
+ const result = record(envelope.result);
941
+ const handledByClientId = stringValue(
942
+ envelope.handledByClientId
943
+ ?? result?.handledByClientId
944
+ ?? result?.handled_by_client_id,
945
+ );
946
+ if (envelope.resultType === "error") {
947
+ const error = stringValue(envelope.error);
948
+ discovery.resolve({
949
+ canHandle: false,
950
+ ...(!desktopFollowerNoClientFound(error) ? { timedOut: true } : {}),
951
+ });
952
+ } else {
953
+ discovery.resolve({
954
+ canHandle: Boolean(handledByClientId),
955
+ ...(handledByClientId ? { handledByClientId } : { timedOut: true }),
956
+ });
957
+ }
958
+ return;
959
+ }
960
+ const waiter = key ? this.pending.get(key) : null;
961
+ if (!waiter) return;
962
+ this.pending.delete(key);
963
+ clearTimeout(waiter.timer);
964
+ const responseMethod = stringValue(envelope.method);
965
+ const methodMismatch = Boolean(responseMethod && responseMethod !== waiter.method);
966
+ try {
967
+ this.options.onResponseSettled?.({
968
+ requestId: envelope.requestId ?? key,
969
+ method: waiter.method,
970
+ threadId: waiter.threadId,
971
+ commandId: waiter.commandId,
972
+ localClientId: this.clientId,
973
+ handledByClientId: stringValue(envelope.handledByClientId),
974
+ ...(stringValue(waiter.targetClientId)
975
+ ? { targetClientId: stringValue(waiter.targetClientId) }
976
+ : {}),
977
+ resultType: envelope.resultType === "error" || methodMismatch ? "error" : "success",
978
+ });
979
+ } catch {
980
+ // Diagnostics must never affect IPC delivery.
981
+ }
982
+ if (methodMismatch) {
983
+ waiter.reject(new Error(
984
+ `Codex Desktop IPC response method mismatch: expected ${waiter.method}, received ${responseMethod}`,
985
+ ));
986
+ return;
987
+ }
988
+ if (envelope.resultType === "error") {
989
+ waiter.reject(new Error(stringValue(envelope.error) || `Codex Desktop IPC request failed: ${waiter.method}`));
990
+ } else {
991
+ waiter.resolve(envelope.result ?? null);
992
+ }
993
+ return;
994
+ }
995
+ if (envelope.type === "broadcast") {
996
+ this.handlers.onBroadcast(envelope);
997
+ return;
998
+ }
999
+ if (envelope.type === "client-discovery-request") {
1000
+ const request = record(envelope.request) ?? envelope;
1001
+ const canHandle = this.handlers.canHandleRequest(request as DesktopIpcEnvelope);
1002
+ this.write({
1003
+ type: "client-discovery-response",
1004
+ requestId: envelope.requestId,
1005
+ response: {
1006
+ canHandle,
1007
+ ...(canHandle
1008
+ ? { handledByClientId: this.clientId }
1009
+ : {}),
1010
+ },
1011
+ });
1012
+ return;
1013
+ }
1014
+ if (envelope.type === "request") {
1015
+ void this.handlers.handleRequest(envelope)
1016
+ .then(result => this.write({
1017
+ type: "response",
1018
+ requestId: envelope.requestId,
1019
+ resultType: "success",
1020
+ method: envelope.method,
1021
+ handledByClientId: this.clientId,
1022
+ result: result ?? null,
1023
+ }))
1024
+ .catch(error => this.write({
1025
+ type: "response",
1026
+ requestId: envelope.requestId,
1027
+ resultType: "error",
1028
+ method: envelope.method,
1029
+ handledByClientId: this.clientId,
1030
+ error: error instanceof Error ? error.message : "Remodex Desktop IPC request failed",
1031
+ }));
1032
+ }
1033
+ }
1034
+
1035
+ private handleClose(
1036
+ socket: Socket,
1037
+ generation: number,
1038
+ error = new Error("Codex Desktop IPC connection closed"),
1039
+ ): void {
1040
+ if (!this.isCurrentConnection(socket, generation)) return;
1041
+ this.socket = null;
1042
+ this.reader = null;
1043
+ this.connecting = false;
1044
+ this.initialized = false;
1045
+ this.clientId = "";
1046
+ for (const [requestId, waiter] of this.pending) {
1047
+ if (waiter.generation !== generation) continue;
1048
+ clearTimeout(waiter.timer);
1049
+ waiter.reject(error);
1050
+ this.pending.delete(requestId);
1051
+ }
1052
+ for (const [requestId, pending] of this.pendingDiscoveries) {
1053
+ clearTimeout(pending.timer);
1054
+ pending.resolve({ canHandle: false, timedOut: true });
1055
+ this.pendingDiscoveries.delete(requestId);
1056
+ }
1057
+ this.handlers.onDisconnected?.();
1058
+ this.scheduleReconnect();
1059
+ }
1060
+
1061
+ private scheduleReconnect(): void {
1062
+ if (!this.shouldReconnect || this.reconnectTimer) return;
1063
+ this.reconnectTimer = setTimeout(() => {
1064
+ this.reconnectTimer = null;
1065
+ this.ensureConnected();
1066
+ }, this.options.reconnectMs);
1067
+ this.reconnectTimer.unref?.();
1068
+ }
1069
+
1070
+ private closeSocket(
1071
+ error = new Error("Codex Desktop IPC connection closed"),
1072
+ expectedSocket = this.socket,
1073
+ expectedGeneration = this.connectionGeneration,
1074
+ ): void {
1075
+ const socket = expectedSocket;
1076
+ if (!socket) {
1077
+ this.reader?.reset();
1078
+ return;
1079
+ }
1080
+ if (!this.isCurrentConnection(socket, expectedGeneration)) return;
1081
+ this.reader?.reset();
1082
+ this.handleClose(socket, expectedGeneration, error);
1083
+ if (!socket.destroyed) socket.destroy();
1084
+ }
1085
+
1086
+ private write(
1087
+ envelope: DesktopIpcEnvelope,
1088
+ expectedSocket = this.socket,
1089
+ expectedGeneration = this.connectionGeneration,
1090
+ ): boolean {
1091
+ const socket = expectedSocket;
1092
+ if (
1093
+ !socket
1094
+ || socket.destroyed
1095
+ || !this.isCurrentConnection(socket, expectedGeneration)
1096
+ ) return false;
1097
+ try {
1098
+ socket.write(encodeDesktopIpcFrame(envelope));
1099
+ return true;
1100
+ } catch {
1101
+ this.closeSocket(
1102
+ new Error("Codex Desktop IPC write failed"),
1103
+ socket,
1104
+ expectedGeneration,
1105
+ );
1106
+ return false;
1107
+ }
1108
+ }
1109
+
1110
+ private isCurrentConnection(socket: Socket, generation: number): boolean {
1111
+ return this.socket === socket && this.connectionGeneration === generation;
1112
+ }
1113
+ }
1114
+
1115
+ function isPlainRecord(value: unknown): value is JsonRecord {
1116
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1117
+ const prototype = Object.getPrototypeOf(value);
1118
+ return prototype === Object.prototype || prototype === null;
1119
+ }
1120
+
1121
+ export function buildDesktopStatePatches(previous: unknown, current: unknown): JsonPatch[] | null {
1122
+ if (!isPlainRecord(previous) || !isPlainRecord(current)) return null;
1123
+ const patches: JsonPatch[] = [];
1124
+ if (!collectPatches(previous, current, [], patches)) return null;
1125
+ if (patches.length === 0) return patches;
1126
+ return Buffer.byteLength(JSON.stringify(patches), "utf8") <= MAX_PATCH_BYTES ? patches : null;
1127
+ }
1128
+
1129
+ function collectPatches(
1130
+ previous: unknown,
1131
+ current: unknown,
1132
+ path: Array<string | number>,
1133
+ patches: JsonPatch[],
1134
+ ): boolean {
1135
+ if (previous === current) return true;
1136
+ if (patches.length >= MAX_PATCH_COUNT) return false;
1137
+ if (Array.isArray(previous) || Array.isArray(current)) {
1138
+ if (!Array.isArray(previous) || !Array.isArray(current)) {
1139
+ return pushPatch(patches, { op: "replace", path, value: clone(current) });
1140
+ }
1141
+ const shared = Math.min(previous.length, current.length);
1142
+ for (let index = 0; index < shared; index += 1) {
1143
+ if (!collectPatches(previous[index], current[index], [...path, index], patches)) return false;
1144
+ }
1145
+ for (let index = previous.length - 1; index >= current.length; index -= 1) {
1146
+ if (!pushPatch(patches, { op: "remove", path: [...path, index] })) return false;
1147
+ }
1148
+ for (let index = shared; index < current.length; index += 1) {
1149
+ if (!pushPatch(patches, { op: "add", path: [...path, index], value: clone(current[index]) })) return false;
1150
+ }
1151
+ return true;
1152
+ }
1153
+ if (isPlainRecord(previous) || isPlainRecord(current)) {
1154
+ if (!isPlainRecord(previous) || !isPlainRecord(current)) {
1155
+ return pushPatch(patches, { op: "replace", path, value: clone(current) });
1156
+ }
1157
+ for (const key of Object.keys(previous)) {
1158
+ if (!(key in current) && !pushPatch(patches, { op: "remove", path: [...path, key] })) return false;
1159
+ }
1160
+ for (const [key, value] of Object.entries(current)) {
1161
+ if (!(key in previous)) {
1162
+ if (!pushPatch(patches, { op: "add", path: [...path, key], value: clone(value) })) return false;
1163
+ } else if (!collectPatches(previous[key], value, [...path, key], patches)) {
1164
+ return false;
1165
+ }
1166
+ }
1167
+ return true;
1168
+ }
1169
+ return pushPatch(patches, { op: "replace", path, value: clone(current) });
1170
+ }
1171
+
1172
+ function pushPatch(patches: JsonPatch[], patch: JsonPatch): boolean {
1173
+ if (patch.path.length === 0) return false;
1174
+ patches.push(patch);
1175
+ return patches.length <= MAX_PATCH_COUNT;
1176
+ }
1177
+
1178
+ function followerStateChange(value: unknown): FollowerStateChange | null {
1179
+ const change = record(value);
1180
+ const type = normalizeToken(change?.type);
1181
+ const revision = finiteRevision(change?.revision);
1182
+ if (type === "snapshot") {
1183
+ const conversationState = record(change?.conversationState) ?? record(change?.conversation_state);
1184
+ return conversationState ? { type: "snapshot", revision, conversationState } : null;
1185
+ }
1186
+ if (type !== "patches") return null;
1187
+ const patches: JsonPatch[] = [];
1188
+ let patchBytes = 2; // JSON array delimiters
1189
+ if (Array.isArray(change?.patches)) {
1190
+ for (const candidate of change.patches) {
1191
+ const patch = record(candidate);
1192
+ const op = stringValue(patch?.op) as JsonPatch["op"];
1193
+ if ((op !== "add" && op !== "remove" && op !== "replace") || !Array.isArray(patch?.path)) continue;
1194
+ const path = patch.path.length <= 64 && patch.path.every(segment =>
1195
+ (typeof segment === "string" && Buffer.byteLength(segment, "utf8") <= 512)
1196
+ || (typeof segment === "number" && Number.isSafeInteger(segment)))
1197
+ ? patch.path as Array<string | number>
1198
+ : null;
1199
+ if (!path) continue;
1200
+ if (op === "remove") {
1201
+ const patchSize = Buffer.byteLength(JSON.stringify(path), "utf8") + 16;
1202
+ if (patchBytes + patchSize > MAX_PATCH_BYTES) break;
1203
+ patches.push({ op, path });
1204
+ patchBytes += patchSize;
1205
+ } else {
1206
+ const bounded = boundInboundPatchValue(patch?.value);
1207
+ const encoded = JSON.stringify(bounded);
1208
+ const pathBytes = Buffer.byteLength(JSON.stringify(path), "utf8");
1209
+ const patchSize = pathBytes + Buffer.byteLength(encoded, "utf8") + 32;
1210
+ if (patchBytes + patchSize > MAX_PATCH_BYTES) break;
1211
+ patches.push({ op, path, value: bounded });
1212
+ patchBytes += patchSize;
1213
+ }
1214
+ if (patches.length > MAX_PATCH_COUNT) break;
1215
+ }
1216
+ }
1217
+ if (patches.length > MAX_PATCH_COUNT || patchBytes > MAX_PATCH_BYTES) return null;
1218
+ return {
1219
+ type: "patches",
1220
+ revision,
1221
+ baseRevision: finiteRevision(change?.baseRevision ?? change?.base_revision),
1222
+ patches,
1223
+ };
1224
+ }
1225
+
1226
+ const INBOUND_PATCH_MAX_INLINE_BYTES = 256 * 1024;
1227
+ const INBOUND_PATCH_MAX_ENTRIES = 64;
1228
+ const INBOUND_PATCH_MAX_DEPTH = 64;
1229
+ // Retain enough turn shells for bounded older-page navigation, while the
1230
+ // global item trim below keeps payload memory bounded.
1231
+ const INBOUND_HISTORY_MAX_TURNS = 64;
1232
+ const INBOUND_HISTORY_MAX_ITEMS_PER_TURN = 500;
1233
+ const INBOUND_ITEM_MAX_LARGE_TEXT_BYTES = 64 * 1024 * 1024;
1234
+
1235
+ function boundInboundPatchValue(
1236
+ value: unknown,
1237
+ seen = new WeakSet<object>(),
1238
+ depth = 0,
1239
+ allowLargeText = false,
1240
+ ): unknown {
1241
+ if (typeof value === "string") {
1242
+ const rawBytes = Buffer.byteLength(value, "utf8");
1243
+ return rawBytes <= (allowLargeText ? INBOUND_ITEM_MAX_LARGE_TEXT_BYTES : INBOUND_PATCH_MAX_INLINE_BYTES)
1244
+ && (allowLargeText || Buffer.byteLength(JSON.stringify(value), "utf8") <= INBOUND_PATCH_MAX_INLINE_BYTES)
1245
+ ? value
1246
+ : { kind: "missing", reason: "not-retained" };
1247
+ }
1248
+ if (value === null || typeof value === "boolean" || typeof value === "number") return value;
1249
+ if (typeof value !== "object") return String(value);
1250
+ if (depth >= INBOUND_PATCH_MAX_DEPTH) return { kind: "missing", reason: "not-retained" };
1251
+ if (seen.has(value)) return { kind: "missing", reason: "not-retained" };
1252
+ seen.add(value);
1253
+ try {
1254
+ if (Array.isArray(value)) {
1255
+ if (value.length > INBOUND_PATCH_MAX_ENTRIES) return { kind: "missing", reason: "not-retained" };
1256
+ return value.map(entry => boundInboundPatchValue(entry, seen, depth + 1, allowLargeText));
1257
+ }
1258
+ const output: JsonRecord = {};
1259
+ let entries = 0;
1260
+ for (const key in value) {
1261
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1262
+ entries += 1;
1263
+ if (entries > INBOUND_PATCH_MAX_ENTRIES || Buffer.byteLength(key, "utf8") > 512) {
1264
+ return { kind: "missing", reason: "not-retained" };
1265
+ }
1266
+ output[key] = boundInboundPatchValue((value as JsonRecord)[key], seen, depth + 1, allowLargeText);
1267
+ }
1268
+ return output;
1269
+ } finally {
1270
+ seen.delete(value);
1271
+ }
1272
+ }
1273
+
1274
+ function finiteRevision(value: unknown): number | undefined {
1275
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
1276
+ }
1277
+
1278
+ function applyDesktopStatePatches(previous: JsonRecord, patches: JsonPatch[]): JsonRecord | null {
1279
+ const next = clone(previous);
1280
+ for (const patch of patches) {
1281
+ if (patch.path.length === 0) return null;
1282
+ let parent: unknown = next;
1283
+ for (let index = 0; index < patch.path.length - 1; index += 1) {
1284
+ const segment = patch.path[index]!;
1285
+ if (typeof segment === "string" && isUnsafePatchSegment(segment)) return null;
1286
+ if (!parent || typeof parent !== "object") return null;
1287
+ parent = (parent as Record<string | number, unknown>)[segment];
1288
+ }
1289
+ const final = patch.path.at(-1)!;
1290
+ if (typeof final === "string" && isUnsafePatchSegment(final)) return null;
1291
+ if (Array.isArray(parent)) {
1292
+ if (typeof final !== "number" || final < 0 || final > parent.length) return null;
1293
+ if (patch.op === "remove") {
1294
+ if (final >= parent.length) return null;
1295
+ parent.splice(final, 1);
1296
+ } else if (patch.op === "add") {
1297
+ parent.splice(final, 0, clone(patch.value));
1298
+ } else {
1299
+ if (final >= parent.length) return null;
1300
+ parent[final] = clone(patch.value);
1301
+ }
1302
+ continue;
1303
+ }
1304
+ if (!isPlainRecord(parent) || typeof final !== "string") return null;
1305
+ if (patch.op === "remove") {
1306
+ if (!(final in parent)) return null;
1307
+ delete parent[final];
1308
+ } else {
1309
+ parent[final] = clone(patch.value);
1310
+ }
1311
+ }
1312
+ return next;
1313
+ }
1314
+
1315
+ function isUnsafePatchSegment(segment: string): boolean {
1316
+ return segment === "__proto__" || segment === "prototype" || segment === "constructor";
1317
+ }
1318
+
1319
+ function snapshotShowsActiveTurn(change: FollowerStateChange): boolean {
1320
+ const state = record(change.conversationState);
1321
+ const turns = Array.isArray(state?.turns) ? state.turns : [];
1322
+ return turns.some(candidate => {
1323
+ const status = normalizeToken(record(candidate)?.status);
1324
+ return status === "inprogress" || status === "running" || status === "active";
1325
+ });
1326
+ }
1327
+
1328
+ const DESKTOP_THREAD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
1329
+
1330
+ export function codexDesktopOpenCommands(
1331
+ url: string,
1332
+ platform: NodeJS.Platform = process.platform,
1333
+ environment: NodeJS.ProcessEnv = process.env,
1334
+ ): CodexDesktopOpenCommand[] {
1335
+ if (platform === "win32") {
1336
+ return [{
1337
+ command: environment.SystemRoot
1338
+ ? win32.join(environment.SystemRoot, "System32", "rundll32.exe")
1339
+ : "rundll32.exe",
1340
+ args: ["url.dll,FileProtocolHandler", url],
1341
+ }];
1342
+ }
1343
+ if (platform === "darwin") {
1344
+ return [
1345
+ { command: "/usr/bin/open", args: ["-b", "com.openai.codex", url] },
1346
+ { command: "/usr/bin/open", args: ["-a", "/Applications/Codex.app", url] },
1347
+ ];
1348
+ }
1349
+ if (platform === "linux") {
1350
+ return [
1351
+ { command: "xdg-open", args: [url] },
1352
+ { command: "gio", args: ["open", url] },
1353
+ ];
1354
+ }
1355
+ return [];
1356
+ }
1357
+
1358
+ export async function openCodexDesktopThread(input: {
1359
+ threadId: string;
1360
+ platform?: NodeJS.Platform;
1361
+ activationToken?: string;
1362
+ run?: (command: string, args: string[]) => Promise<void>;
1363
+ }): Promise<void> {
1364
+ const threadId = stringValue(input.threadId);
1365
+ if (!DESKTOP_THREAD_ID.test(threadId)) throw new Error("Codex task id is not valid");
1366
+ const token = encodeURIComponent(input.activationToken || randomUUID());
1367
+ const url = `codex://threads/${encodeURIComponent(threadId)}?opencodex-follow=${token}`;
1368
+ await openCodexDesktopUrl({ url, platform: input.platform, run: input.run });
1369
+ }
1370
+
1371
+ /** Open a supported Codex Desktop deep link on the host operating system. */
1372
+ export async function openCodexDesktopUrl(input: {
1373
+ url: string;
1374
+ platform?: NodeJS.Platform;
1375
+ run?: (command: string, args: string[]) => Promise<void>;
1376
+ }): Promise<void> {
1377
+ const commands = codexDesktopOpenCommands(input.url, input.platform);
1378
+ if (commands.length === 0) throw new Error("Codex Desktop live follow is not supported on this operating system");
1379
+ const run = input.run ?? runDesktopOpenCommand;
1380
+ let lastError: unknown = null;
1381
+ for (const command of commands) {
1382
+ try {
1383
+ await run(command.command, command.args);
1384
+ return;
1385
+ } catch (error) {
1386
+ lastError = error;
1387
+ }
1388
+ }
1389
+ throw lastError instanceof Error ? lastError : new Error("Could not open Codex Desktop");
1390
+ }
1391
+
1392
+ function runDesktopOpenCommand(command: string, args: string[]): Promise<void> {
1393
+ return new Promise((resolvePromise, reject) => {
1394
+ execFile(command, args, { timeout: 10_000, windowsHide: true }, error => {
1395
+ if (error) reject(error);
1396
+ else resolvePromise();
1397
+ });
1398
+ });
1399
+ }
1400
+
1401
+ type PendingTurnStart = {
1402
+ params: JsonRecord;
1403
+ fallbackTurnId: string;
1404
+ };
1405
+
1406
+ type FollowerStateChange = {
1407
+ type: "snapshot" | "patches";
1408
+ revision?: number;
1409
+ baseRevision?: number;
1410
+ conversationState?: JsonRecord;
1411
+ patches?: JsonPatch[];
1412
+ };
1413
+
1414
+ type FollowerRecoveryState = {
1415
+ attempts: number;
1416
+ timer: Timer | null;
1417
+ inFlight: boolean;
1418
+ };
1419
+
1420
+ export class AndroidDesktopIpcLiveSync implements AndroidDesktopIpcSync {
1421
+ private readonly platform: NodeJS.Platform;
1422
+ private readonly now: () => number;
1423
+ private readonly readThread: DesktopIpcSyncOptions["readThread"];
1424
+ private readonly readThreadPage: DesktopIpcSyncOptions["readThreadPage"];
1425
+ private readonly sendCodexRequest: DesktopIpcSyncOptions["sendCodexRequest"];
1426
+ private readonly respondToCodexRequest: DesktopIpcSyncOptions["respondToCodexRequest"];
1427
+ private readonly openUrl: (url: string) => Promise<void>;
1428
+ private readonly log: (message: string) => void;
1429
+ private readonly warn: (message: string) => void;
1430
+ private readonly snapshotDebounceMs: number;
1431
+ private readonly followConfirmMs: number;
1432
+ private readonly followMaxAttempts: number;
1433
+ private readonly initialHistoryRetryMs: number;
1434
+ private readonly initialHistoryMaxAttempts: number;
1435
+ private readonly followerBaselineRetryMs: number;
1436
+ private readonly followerBaselineMaxRetryMs: number;
1437
+ private readonly followerBaselineMaxAttempts: number;
1438
+ private readonly followerStateTimeoutMs: number;
1439
+ private readonly ownerReacquireTimeoutMs: number;
1440
+ private readonly ownershipStore: AndroidDesktopOwnershipStore;
1441
+ private readonly transport: DesktopIpcTransportLike;
1442
+ /**
1443
+ * The current renderer that emitted authoritative state for each thread.
1444
+ * This map is intentionally separate from the local mirror ownership map.
1445
+ */
1446
+ private readonly desktopOwnerClientIds = new Map<string, string>();
1447
+ /** Waiters used only while reacquiring a renderer id after reconnect. */
1448
+ private readonly desktopOwnerWaiters = new Map<string, Set<{
1449
+ resolve: (clientId: string | null) => void;
1450
+ timer: Timer;
1451
+ }>>();
1452
+ /**
1453
+ * Monotonic safety fact: once Desktop has been observed as the owner, a
1454
+ * missing handler or a disconnected socket can never authorize local resume.
1455
+ * It is cleared only by an explicit release/local-owner transition.
1456
+ */
1457
+ private readonly desktopOwnedThreadIds = new Set<string>();
1458
+ private readonly ownedThreadIds = new Set<string>();
1459
+ private readonly conversations = new Map<string, ConversationState>();
1460
+ private readonly revisions = new Map<string, number>();
1461
+ /** Last bounded wire state, never the unbounded in-memory conversation. */
1462
+ private readonly lastBroadcastStates = new Map<string, JsonRecord>();
1463
+ private readonly pendingTurnStarts = new Map<string, PendingTurnStart[]>();
1464
+ private readonly dirtyThreadIds = new Set<string>();
1465
+ private readonly followerClientIds = new Map<string, Set<string>>();
1466
+ /** Desktop-owned tasks that this Android bridge follows for live requests. */
1467
+ private readonly followedDesktopThreadIds = new Set<string>();
1468
+ private readonly followAttempts = new Map<string, number>();
1469
+ private readonly followTimers = new Map<string, Timer>();
1470
+ private readonly followerBaselineTimers = new Map<string, Timer>();
1471
+ private readonly sidebarTimers = new Map<string, Timer>();
1472
+ private readonly announcedSidebarThreadIds = new Set<string>();
1473
+ private readonly runtimeOverrides = new Map<string, JsonRecord>();
1474
+ private readonly hydrationPromises = new Map<string, Promise<boolean>>();
1475
+ private readonly threadsAwaitingInitialHistory = new Set<string>();
1476
+ private readonly initialHistoryAttempts = new Map<string, number>();
1477
+ private readonly initialHistoryTimers = new Map<string, Timer>();
1478
+ /** Connection-scoped baselines for tasks owned by another Desktop client. */
1479
+ private readonly followerStates = new Map<string, JsonRecord>();
1480
+ private readonly followerRevisions = new Map<string, number>();
1481
+ private readonly queuedFollowerChanges = new Map<string, FollowerStateChange[]>();
1482
+ private readonly followerRecovery = new Map<string, FollowerRecoveryState>();
1483
+ private readonly followerStateWaiters = new Map<string, Set<{
1484
+ resolve: (state: JsonRecord | null) => void;
1485
+ reject: (error: Error) => void;
1486
+ timer: Timer;
1487
+ }>>();
1488
+ /** Connection-local opaque page/content references for Desktop followers. */
1489
+ private readonly historyPages: DesktopHistoryPageStore;
1490
+ /** Capability is scoped to the current IPC connection generation. */
1491
+ private boundedHistoryUnsupportedGeneration: number | null = null;
1492
+ private snapshotTimer: Timer | null = null;
1493
+
1494
+ constructor(options: DesktopIpcSyncOptions) {
1495
+ this.platform = options.platform ?? process.platform;
1496
+ this.now = options.now ?? Date.now;
1497
+ this.readThread = options.readThread;
1498
+ this.readThreadPage = options.readThreadPage;
1499
+ this.sendCodexRequest = options.sendCodexRequest;
1500
+ this.respondToCodexRequest = options.respondToCodexRequest;
1501
+ this.log = options.log ?? (() => undefined);
1502
+ this.warn = options.warn ?? (() => undefined);
1503
+ this.snapshotDebounceMs = options.snapshotDebounceMs ?? SNAPSHOT_DEBOUNCE_MS;
1504
+ this.followConfirmMs = options.followConfirmMs ?? FOLLOW_CONFIRM_MS;
1505
+ this.followMaxAttempts = options.followMaxAttempts ?? FOLLOW_MAX_ATTEMPTS;
1506
+ this.initialHistoryRetryMs = options.initialHistoryRetryMs ?? INITIAL_HISTORY_RETRY_MS;
1507
+ this.initialHistoryMaxAttempts = options.initialHistoryMaxAttempts ?? INITIAL_HISTORY_MAX_ATTEMPTS;
1508
+ this.followerBaselineRetryMs = options.followerBaselineRetryMs ?? FOLLOWER_BASELINE_RETRY_MS;
1509
+ this.followerBaselineMaxRetryMs = options.followerBaselineMaxRetryMs ?? FOLLOWER_BASELINE_MAX_RETRY_MS;
1510
+ this.followerBaselineMaxAttempts = options.followerBaselineMaxAttempts ?? FOLLOWER_BASELINE_MAX_ATTEMPTS;
1511
+ this.followerStateTimeoutMs = options.followerStateTimeoutMs ?? FOLLOW_STATE_TIMEOUT_MS;
1512
+ this.ownerReacquireTimeoutMs = Math.max(1, options.ownerReacquireTimeoutMs ?? OWNER_REACQUIRE_TIMEOUT_MS);
1513
+ this.ownershipStore = options.ownershipStore ?? createAndroidDesktopOwnershipStore();
1514
+ this.historyPages = options.historyPageStore ?? new DesktopHistoryPageStore({ now: this.now });
1515
+ for (const threadId of this.ownershipStore.list()) this.desktopOwnedThreadIds.add(threadId);
1516
+ this.openUrl = options.openUrl ?? (url => openUrlWithPlatform(url, this.platform));
1517
+ this.transport = options.transport ?? new DesktopIpcTransport({
1518
+ paths: options.socketPaths ?? (() => desktopIpcSocketPaths(this.platform)),
1519
+ connect: options.connect ?? (path => createConnection(path)),
1520
+ now: this.now,
1521
+ reconnectMs: options.reconnectMs ?? RECONNECT_MS,
1522
+ warn: this.warn,
1523
+ onResponseSettled: response => {
1524
+ if (
1525
+ !response.method.startsWith("thread-follower-")
1526
+ && response.method !== THREAD_OWNER_DISCOVERY
1527
+ ) return;
1528
+ if (response.resultType === "success") {
1529
+ this.rememberDesktopOwner(
1530
+ response.threadId,
1531
+ response.handledByClientId,
1532
+ );
1533
+ }
1534
+ this.log(
1535
+ `Desktop IPC response method=${diagnosticToken(response.method)}`
1536
+ + ` thread=${diagnosticToken(response.threadId)}`
1537
+ + ` command=${diagnosticToken(response.commandId)}`
1538
+ + ` generation=${this.transport.generation ?? 0}`
1539
+ + ` localClient=${diagnosticToken(response.localClientId)}`
1540
+ + ` handledBy=${diagnosticToken(response.handledByClientId)}`
1541
+ + ` target=${diagnosticToken(response.targetClientId)}`
1542
+ + ` result=${response.resultType}`,
1543
+ );
1544
+ },
1545
+ });
1546
+ this.transport.setHandlers({
1547
+ onConnected: () => this.onConnected(),
1548
+ onDisconnected: () => this.onDisconnected(),
1549
+ onBroadcast: envelope => this.onBroadcast(envelope),
1550
+ canHandleRequest: envelope => this.canHandleRequest(envelope),
1551
+ handleRequest: envelope => this.handleRequest(envelope),
1552
+ });
1553
+ }
1554
+
1555
+ start(): void {
1556
+ this.transport.start();
1557
+ }
1558
+
1559
+ stop(): void {
1560
+ if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
1561
+ this.snapshotTimer = null;
1562
+ for (const timer of this.followTimers.values()) clearTimeout(timer);
1563
+ this.followTimers.clear();
1564
+ for (const timer of this.followerBaselineTimers.values()) clearTimeout(timer);
1565
+ this.followerBaselineTimers.clear();
1566
+ for (const timer of this.sidebarTimers.values()) clearTimeout(timer);
1567
+ this.sidebarTimers.clear();
1568
+ for (const timer of this.initialHistoryTimers.values()) clearTimeout(timer);
1569
+ this.initialHistoryTimers.clear();
1570
+ for (const recovery of this.followerRecovery.values()) {
1571
+ if (recovery.timer) clearTimeout(recovery.timer);
1572
+ }
1573
+ this.followerRecovery.clear();
1574
+ for (const threadId of [...this.followedDesktopThreadIds]) {
1575
+ // Desktop-owned tasks are observed through metadata discovery and their
1576
+ // bounded read path. They are not follower registrations; in particular
1577
+ // do not emit a later `following:false` when this bridge stops.
1578
+ if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
1579
+ this.followedDesktopThreadIds.delete(threadId);
1580
+ continue;
1581
+ }
1582
+ this.transport.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
1583
+ conversationId: threadId,
1584
+ hostId: HOST_ID,
1585
+ following: false,
1586
+ });
1587
+ }
1588
+ this.followedDesktopThreadIds.clear();
1589
+ this.transport.stop();
1590
+ this.boundedHistoryUnsupportedGeneration = null;
1591
+ const ownedBeforeStop = [...this.ownedThreadIds];
1592
+ this.ownedThreadIds.clear();
1593
+ this.desktopOwnerClientIds.clear();
1594
+ for (const waiters of this.desktopOwnerWaiters.values()) {
1595
+ for (const waiter of waiters) {
1596
+ clearTimeout(waiter.timer);
1597
+ waiter.resolve(null);
1598
+ }
1599
+ }
1600
+ this.desktopOwnerWaiters.clear();
1601
+ this.conversations.clear();
1602
+ this.revisions.clear();
1603
+ this.lastBroadcastStates.clear();
1604
+ // Page tokens/handles are connection-local. Dropping them on stop keeps
1605
+ // an old Desktop renderer from replaying content after a new connection.
1606
+ for (const threadId of ownedBeforeStop) this.historyPages.clearThread(threadId);
1607
+ this.pendingTurnStarts.clear();
1608
+ this.dirtyThreadIds.clear();
1609
+ this.followerClientIds.clear();
1610
+ this.followAttempts.clear();
1611
+ this.announcedSidebarThreadIds.clear();
1612
+ this.runtimeOverrides.clear();
1613
+ this.hydrationPromises.clear();
1614
+ this.threadsAwaitingInitialHistory.clear();
1615
+ this.initialHistoryAttempts.clear();
1616
+ this.followerStates.clear();
1617
+ this.followerRevisions.clear();
1618
+ this.queuedFollowerChanges.clear();
1619
+ for (const waiters of this.followerStateWaiters.values()) {
1620
+ for (const waiter of waiters) {
1621
+ clearTimeout(waiter.timer);
1622
+ waiter.reject(new Error("Codex Desktop IPC stopped"));
1623
+ }
1624
+ }
1625
+ this.followerStateWaiters.clear();
1626
+ }
1627
+
1628
+ isThreadOwned(threadId: string): boolean {
1629
+ return this.ownedThreadIds.has(stringValue(threadId));
1630
+ }
1631
+
1632
+ threadOwnership(threadIdValue: string): DesktopThreadOwnership {
1633
+ const threadId = stringValue(threadIdValue);
1634
+ if (this.ownedThreadIds.has(threadId)) {
1635
+ return {
1636
+ state: "local-owned",
1637
+ ownerClientId: null,
1638
+ everDesktopOwned: this.desktopOwnedThreadIds.has(threadId),
1639
+ };
1640
+ }
1641
+ if (this.desktopOwnedThreadIds.has(threadId)) {
1642
+ return {
1643
+ state: "desktop-owned",
1644
+ ownerClientId: this.desktopOwnerClientIds.get(threadId) ?? null,
1645
+ everDesktopOwned: true,
1646
+ };
1647
+ }
1648
+ return { state: "unknown", ownerClientId: null, everDesktopOwned: false };
1649
+ }
1650
+
1651
+ desktopOwnerClientId(threadIdValue: string): string | null {
1652
+ return this.desktopOwnerClientIds.get(stringValue(threadIdValue)) ?? null;
1653
+ }
1654
+
1655
+ hasObservedDesktopOwner(threadIdValue: string): boolean {
1656
+ return this.desktopOwnedThreadIds.has(stringValue(threadIdValue));
1657
+ }
1658
+
1659
+ async reacquireDesktopOwner(threadIdValue: string): Promise<string | null> {
1660
+ const threadId = stringValue(threadIdValue);
1661
+ if (!threadId || !this.desktopOwnedThreadIds.has(threadId)) return null;
1662
+ const current = this.desktopOwnerClientIds.get(threadId);
1663
+ if (current) return current;
1664
+
1665
+ let waiter!: { resolve: (clientId: string | null) => void; timer: Timer };
1666
+ const owner = new Promise<string | null>(resolve => {
1667
+ const timer = setTimeout(() => {
1668
+ const waiters = this.desktopOwnerWaiters.get(threadId);
1669
+ if (waiters) {
1670
+ waiters.delete(waiter);
1671
+ if (waiters.size === 0) this.desktopOwnerWaiters.delete(threadId);
1672
+ }
1673
+ resolve(this.desktopOwnerClientIds.get(threadId) ?? null);
1674
+ }, this.ownerReacquireTimeoutMs);
1675
+ timer.unref?.();
1676
+ waiter = { resolve, timer };
1677
+ const waiters = this.desktopOwnerWaiters.get(threadId) ?? new Set();
1678
+ waiters.add(waiter);
1679
+ this.desktopOwnerWaiters.set(threadId, waiters);
1680
+ });
1681
+
1682
+ // `start()` is intentionally non-blocking. If a reconnect is already in
1683
+ // progress, onConnected() below will issue the same metadata-only probe
1684
+ // and resolve this waiter without registering a transcript follower.
1685
+ this.transport.start();
1686
+ if (this.transport.connected) {
1687
+ const discovery = await this.discoverDesktopOwner(threadId);
1688
+ if (discovery.handledByClientId) {
1689
+ clearTimeout(waiter.timer);
1690
+ const waiters = this.desktopOwnerWaiters.get(threadId);
1691
+ if (waiters) {
1692
+ waiters.delete(waiter);
1693
+ if (waiters.size === 0) this.desktopOwnerWaiters.delete(threadId);
1694
+ }
1695
+ return this.desktopOwnerClientIds.get(threadId) ?? discovery.handledByClientId;
1696
+ }
1697
+ }
1698
+ return owner;
1699
+ }
1700
+
1701
+ /**
1702
+ * Ask the IPC router which renderer owns a thread without invoking any
1703
+ * follower action. `discover` is preferred because it stops at the
1704
+ * metadata-only canHandle path; the direct request fallback is retained for
1705
+ * older test/transport implementations and still returns an empty result,
1706
+ * never transcript state.
1707
+ */
1708
+ private async discoverDesktopOwner(threadId: string): Promise<DesktopIpcDiscoveryResult> {
1709
+ const params = { hostId: HOST_ID, conversationId: threadId };
1710
+ const discover = this.transport.discover;
1711
+ if (discover) {
1712
+ const result = await discover.call(
1713
+ this.transport,
1714
+ THREAD_OWNER_DISCOVERY,
1715
+ params,
1716
+ { timeoutMs: this.ownerReacquireTimeoutMs },
1717
+ );
1718
+ if (result.handledByClientId) {
1719
+ this.rememberDesktopOwner(threadId, result.handledByClientId);
1720
+ }
1721
+ return result;
1722
+ }
1723
+ try {
1724
+ const result = await this.transport.request(THREAD_OWNER_DISCOVERY, params);
1725
+ this.rememberDesktopOwnerFromResult(threadId, result);
1726
+ const handledByClientId = this.desktopOwnerClientIds.get(threadId) ?? undefined;
1727
+ return {
1728
+ canHandle: Boolean(handledByClientId),
1729
+ ...(handledByClientId ? { handledByClientId } : {}),
1730
+ };
1731
+ } catch (error) {
1732
+ return {
1733
+ canHandle: false,
1734
+ ...(desktopFollowerNoClientFound(error) ? {} : { timedOut: true }),
1735
+ };
1736
+ }
1737
+ }
1738
+
1739
+ releaseDesktopOwnership(threadIdValue: string): void {
1740
+ const threadId = stringValue(threadIdValue);
1741
+ if (!threadId) return;
1742
+ this.desktopOwnerClientIds.delete(threadId);
1743
+ this.desktopOwnedThreadIds.delete(threadId);
1744
+ this.ownershipStore.release(threadId);
1745
+ }
1746
+
1747
+ adoptLocalThread(threadIdValue: string): void {
1748
+ const threadId = stringValue(threadIdValue);
1749
+ if (!threadId) return;
1750
+ if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
1751
+ throw new DesktopIpcOwnershipError(
1752
+ `Codex Desktop still owns task ${threadId}; local adoption requires an explicit owner release`,
1753
+ threadId,
1754
+ "local-adopt",
1755
+ 0,
1756
+ );
1757
+ }
1758
+ this.ownedThreadIds.add(threadId);
1759
+ }
1760
+
1761
+ connectionSnapshot(): DesktopIpcConnectionSnapshot {
1762
+ return {
1763
+ connected: this.transport.connected,
1764
+ generation: this.transport.generation ?? 0,
1765
+ localClientId: stringValue(this.transport.localClientId),
1766
+ };
1767
+ }
1768
+
1769
+ async requestFollowerAction(method: string, params: JsonRecord): Promise<unknown> {
1770
+ const threadId = threadIdFromParams(params);
1771
+ const knownDesktopOwner = Boolean(
1772
+ threadId && this.desktopOwnedThreadIds.has(threadId),
1773
+ );
1774
+ let initialOwner = threadId ? this.desktopOwnerClientIds.get(threadId) ?? "" : "";
1775
+ // A remembered Desktop-owned task may have lost its renderer id when the
1776
+ // socket disconnected. Reacquire that id through the read-only stream
1777
+ // probe before sending *any* follower mutation. An untargeted mutation
1778
+ // during this window could be picked up by a different renderer (or by a
1779
+ // stale local mirror), so fail closed when reacquisition is inconclusive.
1780
+ if (knownDesktopOwner && threadId && !initialOwner) {
1781
+ const reacquired = await this.reacquireDesktopOwner(threadId);
1782
+ if (!reacquired) {
1783
+ throw new DesktopIpcOwnershipError(
1784
+ `Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
1785
+ threadId,
1786
+ method,
1787
+ 0,
1788
+ "owner-unavailable",
1789
+ );
1790
+ }
1791
+ initialOwner = reacquired;
1792
+ }
1793
+ let lastError: unknown = null;
1794
+ for (let attempt = 0; attempt < FOLLOWER_OWNER_RETRY_DELAYS_MS.length; attempt += 1) {
1795
+ const delayMs = FOLLOWER_OWNER_RETRY_DELAYS_MS[attempt] ?? 0;
1796
+ if (delayMs > 0) await waitMs(delayMs);
1797
+ // Keep a known owner target stable across the handler-registration race.
1798
+ // A newer authoritative snapshot may replace it between attempts; use
1799
+ // that new id, but never broaden a known-owner request to local Codex.
1800
+ let targetClientId = threadId
1801
+ ? this.desktopOwnerClientIds.get(threadId) || initialOwner
1802
+ : "";
1803
+ if (knownDesktopOwner && threadId && !targetClientId) {
1804
+ const reacquired = await this.reacquireDesktopOwner(threadId);
1805
+ if (!reacquired) {
1806
+ throw new DesktopIpcOwnershipError(
1807
+ `Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
1808
+ threadId,
1809
+ method,
1810
+ attempt,
1811
+ "owner-unavailable",
1812
+ );
1813
+ }
1814
+ targetClientId = reacquired;
1815
+ }
1816
+ try {
1817
+ const result = await this.transport.request(method, params, {
1818
+ ...(targetClientId ? { targetClientId } : {}),
1819
+ });
1820
+ this.rememberDesktopOwnerFromResult(threadId, result);
1821
+ return result;
1822
+ } catch (error) {
1823
+ lastError = error;
1824
+ if (!desktopFollowerNoClientFound(error)) throw error;
1825
+ // A targeted no-client-found is safe to retry, but never broaden it
1826
+ // into an untargeted request. If the renderer was replaced, the next
1827
+ // attempt must first obtain the replacement sourceClientId.
1828
+ if (targetClientId) {
1829
+ // The router has conclusively rejected this connection-scoped
1830
+ // renderer id. Drop only that stale mapping while preserving the
1831
+ // monotonic Desktop-owned fact, then let the read-only discovery
1832
+ // path identify the replacement renderer before another mutation.
1833
+ this.removeDesktopOwnerClient(targetClientId);
1834
+ if (initialOwner === targetClientId) initialOwner = "";
1835
+ }
1836
+ if (knownDesktopOwner && threadId && !this.desktopOwnerClientIds.get(threadId)) {
1837
+ const reacquired = await this.reacquireDesktopOwner(threadId);
1838
+ if (!reacquired && attempt < FOLLOWER_OWNER_RETRY_DELAYS_MS.length - 1) {
1839
+ throw new DesktopIpcOwnershipError(
1840
+ `Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
1841
+ threadId,
1842
+ method,
1843
+ attempt + 1,
1844
+ "owner-unavailable",
1845
+ );
1846
+ }
1847
+ if (reacquired) initialOwner = reacquired;
1848
+ }
1849
+ if (attempt === FOLLOWER_OWNER_RETRY_DELAYS_MS.length - 1) break;
1850
+ }
1851
+ }
1852
+ if (knownDesktopOwner && threadId) {
1853
+ throw new DesktopIpcOwnershipError(
1854
+ `Codex Desktop still owns task ${threadId}; its follower IPC handler could not be reached`,
1855
+ threadId,
1856
+ method,
1857
+ FOLLOWER_OWNER_RETRY_DELAYS_MS.length,
1858
+ "no-client-found",
1859
+ );
1860
+ }
1861
+ throw lastError instanceof Error
1862
+ ? lastError
1863
+ : new Error(`Codex Desktop IPC request failed: ${method}`);
1864
+ }
1865
+
1866
+ async activateFollowerThread(threadIdValue: string): Promise<void> {
1867
+ const threadId = stringValue(threadIdValue);
1868
+ if (!DESKTOP_THREAD_ID.test(threadId)) throw new Error("Codex task id is not valid");
1869
+ this.transport.start();
1870
+ // This task is owned by Desktop and Android is the follower. Reopening an
1871
+ // already-active route without changing its URL is a navigation no-op, so
1872
+ // the owner never re-registers after an IPC reconnect. A unique neutral
1873
+ // query value forces route activation without assigning the opposite
1874
+ // Remodex-owned "follow" role.
1875
+ const token = encodeURIComponent(randomUUID());
1876
+ await this.openUrl(
1877
+ `codex://threads/${encodeURIComponent(threadId)}?opencodex-reactivate=${token}`,
1878
+ );
1879
+ }
1880
+
1881
+ async readFollowerHistoryPage(
1882
+ threadIdValue: string,
1883
+ options: {
1884
+ direction?: DesktopHistoryPageDirection;
1885
+ pageToken?: string | null;
1886
+ } = {},
1887
+ ): Promise<JsonRecord | null> {
1888
+ const threadId = stringValue(threadIdValue);
1889
+ if (!threadId) return null;
1890
+ const direction = options.direction ?? "recent";
1891
+ if (this.boundedHistoryUnsupportedForCurrentConnection()) {
1892
+ return this.readFollowerHistoryPageFallback(threadId, direction, options.pageToken ?? null);
1893
+ }
1894
+ try {
1895
+ const result = await this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
1896
+ hostId: HOST_ID,
1897
+ conversationId: threadId,
1898
+ direction,
1899
+ pageToken: options.pageToken ?? null,
1900
+ });
1901
+ const response = record(result);
1902
+ const state = record(response?.conversationState ?? response?.conversation_state ?? response?.state);
1903
+ if (state) {
1904
+ return direction === "recent"
1905
+ ? clone(this.historyPages.recentPage(threadId, state).state)
1906
+ : clone(state);
1907
+ }
1908
+ return response ? clone(response) : null;
1909
+ } catch (error) {
1910
+ if (!desktopFollowerMethodUnsupported(error)) throw error;
1911
+ this.markBoundedHistoryUnsupported();
1912
+ return this.readFollowerHistoryPageFallback(threadId, direction, options.pageToken ?? null);
1913
+ }
1914
+ }
1915
+
1916
+ async readFollowerContentChunk(
1917
+ threadIdValue: string,
1918
+ input: { handle: string; sourceRevision: string; offset: number },
1919
+ ): Promise<DesktopContentChunk> {
1920
+ const threadId = stringValue(threadIdValue);
1921
+ if (!threadId) return { kind: "missing", reason: "source-missing" };
1922
+ // Handles created by the local compatibility page store never need to
1923
+ // cross the IPC boundary. This also makes a fallback page immediately
1924
+ // usable by clients that request its content chunks.
1925
+ const localChunk = this.historyPages.readContentChunk({
1926
+ threadId,
1927
+ sourceRevision: input.sourceRevision,
1928
+ handle: input.handle,
1929
+ offset: input.offset,
1930
+ });
1931
+ if (localChunk.kind === "chunk") return localChunk;
1932
+ if (this.boundedHistoryUnsupportedForCurrentConnection()) return localChunk;
1933
+ try {
1934
+ const result = await this.requestFollowerAction(THREAD_FOLLOWER_READ_CONTENT_CHUNK, {
1935
+ hostId: HOST_ID,
1936
+ conversationId: threadId,
1937
+ handle: input.handle,
1938
+ sourceRevision: input.sourceRevision,
1939
+ offset: input.offset,
1940
+ });
1941
+ const chunk = record(result);
1942
+ if (chunk?.kind === "chunk" || chunk?.kind === "missing") return chunk as unknown as DesktopContentChunk;
1943
+ return { kind: "missing", reason: "source-missing" };
1944
+ } catch (error) {
1945
+ if (!desktopFollowerMethodUnsupported(error)) throw error;
1946
+ this.markBoundedHistoryUnsupported();
1947
+ return { kind: "missing", reason: "not-retained" };
1948
+ }
1949
+ }
1950
+
1951
+ async readFollowerThreadState(
1952
+ threadIdValue: string,
1953
+ options: { fresh?: boolean } = {},
1954
+ ): Promise<JsonRecord | null> {
1955
+ const threadId = stringValue(threadIdValue);
1956
+ if (!threadId) return null;
1957
+ const current = this.followerStates.get(threadId);
1958
+ if (current && !options.fresh) return clone(current);
1959
+ if (this.boundedHistoryUnsupportedForCurrentConnection()) {
1960
+ return this.readFollowerThreadStateFallback(threadId);
1961
+ }
1962
+ let stateWaiter!: {
1963
+ resolve: (state: JsonRecord | null) => void;
1964
+ reject: (error: Error) => void;
1965
+ timer: Timer;
1966
+ };
1967
+ const state = new Promise<JsonRecord | null>((resolvePromise, reject) => {
1968
+ const timer = setTimeout(() => {
1969
+ const waiters = this.followerStateWaiters.get(threadId);
1970
+ if (waiters) {
1971
+ for (const waiter of waiters) {
1972
+ if (waiter.resolve !== resolvePromise) continue;
1973
+ waiters.delete(waiter);
1974
+ break;
1975
+ }
1976
+ if (waiters.size === 0) this.followerStateWaiters.delete(threadId);
1977
+ }
1978
+ reject(new Error("Codex Desktop did not publish the requested task state"));
1979
+ }, this.followerStateTimeoutMs);
1980
+ timer.unref?.();
1981
+ const waiter = { resolve: resolvePromise, reject, timer };
1982
+ stateWaiter = waiter;
1983
+ const waiters = this.followerStateWaiters.get(threadId) ?? new Set();
1984
+ waiters.add(waiter);
1985
+ this.followerStateWaiters.set(threadId, waiters);
1986
+ });
1987
+ try {
1988
+ // A state read is a bounded recent-page request. It intentionally does
1989
+ // not register a stream follower: older Desktop builds respond to that
1990
+ // registration by broadcasting their complete materialized transcript.
1991
+ const action = this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
1992
+ hostId: HOST_ID,
1993
+ conversationId: threadId,
1994
+ direction: "recent",
1995
+ pageToken: null,
1996
+ });
1997
+ const directState = action.then(result => {
1998
+ const response = record(result);
1999
+ const value = record(
2000
+ response?.conversationState
2001
+ ?? response?.conversation_state
2002
+ ?? response?.state,
2003
+ );
2004
+ if (!value) return null;
2005
+ const revision = finiteRevision(response?.revision) ?? 0;
2006
+ const bounded = this.historyPages.recentPage(threadId, value).state;
2007
+ this.followerStates.set(threadId, clone(bounded));
2008
+ this.followerRevisions.set(threadId, revision);
2009
+ this.clearFollowerRecovery(threadId);
2010
+ this.resolveFollowerStateWaiters(threadId);
2011
+ return clone(bounded);
2012
+ });
2013
+ // The publication waiter can win the race while the direct request is
2014
+ // still in flight. Attach a sink so a later unsupported-method rejection
2015
+ // cannot become an unhandled promise, while still allowing the race to
2016
+ // observe it when it is the first result.
2017
+ void directState.catch(error => {
2018
+ if (desktopFollowerMethodUnsupported(error)) this.markBoundedHistoryUnsupported();
2019
+ });
2020
+ // Race the bounded page request against the state publication timeout.
2021
+ // A slow or lost Desktop response must not keep the gateway request
2022
+ // open for the full IPC timeout.
2023
+ const resolved = await Promise.race([
2024
+ directState,
2025
+ state,
2026
+ ]);
2027
+ return resolved ?? await state;
2028
+ } catch (error) {
2029
+ const waiters = this.followerStateWaiters.get(threadId);
2030
+ if (waiters && waiters.delete(stateWaiter)) {
2031
+ clearTimeout(stateWaiter.timer);
2032
+ if (waiters.size === 0) this.followerStateWaiters.delete(threadId);
2033
+ }
2034
+ if (desktopFollowerMethodUnsupported(error)) {
2035
+ this.markBoundedHistoryUnsupported();
2036
+ return this.readFollowerThreadStateFallback(threadId);
2037
+ }
2038
+ throw error;
2039
+ }
2040
+ }
2041
+
2042
+ private boundedHistoryGeneration(): number {
2043
+ return this.transport.generation ?? 0;
2044
+ }
2045
+
2046
+ private boundedHistoryUnsupportedForCurrentConnection(): boolean {
2047
+ return this.boundedHistoryUnsupportedGeneration === this.boundedHistoryGeneration();
2048
+ }
2049
+
2050
+ private markBoundedHistoryUnsupported(): void {
2051
+ this.boundedHistoryUnsupportedGeneration = this.boundedHistoryGeneration();
2052
+ }
2053
+
2054
+ /**
2055
+ * Compatibility reader for Desktop builds that predate the bounded-page
2056
+ * follower methods. `readThreadPage` is backed by the bounded
2057
+ * thread/turns/list request in the default gateway integration; it never
2058
+ * asks the Desktop renderer to publish its complete transcript.
2059
+ */
2060
+ private async readFollowerThreadStateFallback(threadId: string): Promise<JsonRecord | null> {
2061
+ if (!this.readThreadPage) return null;
2062
+ const result = await this.readThreadPage(threadId);
2063
+ const state = record(result?.thread) ?? record(result);
2064
+ if (!state) return null;
2065
+ const bounded = this.historyPages.recentPage(threadId, state).state;
2066
+ this.followerStates.set(threadId, clone(bounded));
2067
+ this.followerRevisions.set(threadId, this.followerRevisions.get(threadId) ?? 0);
2068
+ this.clearFollowerRecovery(threadId);
2069
+ this.resolveFollowerStateWaiters(threadId);
2070
+ return clone(bounded);
2071
+ }
2072
+
2073
+ private async readFollowerHistoryPageFallback(
2074
+ threadId: string,
2075
+ direction: Exclude<DesktopHistoryPageDirection, "recent"> | "recent",
2076
+ pageToken: string | null,
2077
+ ): Promise<JsonRecord | null> {
2078
+ let state = this.followerStates.get(threadId);
2079
+ if (!state || direction === "recent") {
2080
+ state = await this.readFollowerThreadStateFallback(threadId) ?? undefined;
2081
+ }
2082
+ if (!state) return null;
2083
+ if (direction === "recent") return clone(state);
2084
+ if (!pageToken) throw new Error("A page token is required for older or newer history");
2085
+ const page = this.historyPages.page(threadId, state, direction, pageToken);
2086
+ return clone(page.state);
2087
+ }
2088
+
2089
+ private rememberDesktopOwner(threadIdValue: string, clientIdValue: string): void {
2090
+ const threadId = stringValue(threadIdValue);
2091
+ const clientId = stringValue(clientIdValue);
2092
+ const localClientId = stringValue(this.transport.localClientId);
2093
+ if (!threadId || !clientId || (localClientId && clientId === localClientId)) return;
2094
+ // A locally mounted writer is authoritative for this bridge. Ignore its
2095
+ // own echoed snapshots/responses rather than converting the mirror into a
2096
+ // false Desktop owner.
2097
+ if (this.ownedThreadIds.has(threadId)) return;
2098
+ this.desktopOwnerClientIds.set(threadId, clientId);
2099
+ this.desktopOwnedThreadIds.add(threadId);
2100
+ this.ownershipStore.remember(threadId);
2101
+ const waiters = this.desktopOwnerWaiters.get(threadId);
2102
+ if (waiters) {
2103
+ this.desktopOwnerWaiters.delete(threadId);
2104
+ for (const waiter of waiters) {
2105
+ clearTimeout(waiter.timer);
2106
+ waiter.resolve(clientId);
2107
+ }
2108
+ }
2109
+ }
2110
+
2111
+ private rememberDesktopOwnerFromResult(threadIdValue: string, result: unknown): void {
2112
+ const response = record(result);
2113
+ const handledByClientId = stringValue(
2114
+ response?.handledByClientId
2115
+ ?? response?.handled_by_client_id
2116
+ ?? record(response?.result)?.handledByClientId,
2117
+ );
2118
+ if (handledByClientId) this.rememberDesktopOwner(threadIdValue, handledByClientId);
2119
+ }
2120
+
2121
+ private removeDesktopOwnerClient(clientIdValue: string): void {
2122
+ const clientId = stringValue(clientIdValue);
2123
+ if (!clientId) return;
2124
+ for (const [threadId, ownerClientId] of this.desktopOwnerClientIds) {
2125
+ if (ownerClientId === clientId) this.desktopOwnerClientIds.delete(threadId);
2126
+ }
2127
+ }
2128
+
2129
+ async probeFollowerRoute(
2130
+ threadIdValue: string,
2131
+ ): Promise<"ready" | "absent" | "unhealthy"> {
2132
+ const threadId = stringValue(threadIdValue);
2133
+ if (!threadId) return "absent";
2134
+ const ownership = this.threadOwnership(threadId);
2135
+ if (ownership.state === "local-owned") return "absent";
2136
+ // A disconnected bus says nothing about the Desktop app-server writer.
2137
+ // Treat it as unhealthy so callers cannot adopt a competing local writer.
2138
+ if (!this.transport.connected) return "unhealthy";
2139
+ const discovery = await this.discoverDesktopOwner(threadId);
2140
+ if (discovery.canHandle && discovery.handledByClientId) return "ready";
2141
+ if (discovery.timedOut) return "unhealthy";
2142
+ return this.desktopOwnedThreadIds.has(threadId) ? "unhealthy" : "absent";
2143
+ }
2144
+
2145
+ releaseThread(threadId: string): void {
2146
+ const id = stringValue(threadId);
2147
+ if (!id) return;
2148
+ this.ownedThreadIds.delete(id);
2149
+ this.conversations.delete(id);
2150
+ this.revisions.delete(id);
2151
+ this.lastBroadcastStates.delete(id);
2152
+ this.historyPages.clearThread(id);
2153
+ this.pendingTurnStarts.delete(id);
2154
+ this.dirtyThreadIds.delete(id);
2155
+ this.followerClientIds.delete(id);
2156
+ this.followedDesktopThreadIds.delete(id);
2157
+ this.followerStates.delete(id);
2158
+ this.followerRevisions.delete(id);
2159
+ this.queuedFollowerChanges.delete(id);
2160
+ this.clearFollowerRecovery(id);
2161
+ this.followAttempts.delete(id);
2162
+ this.runtimeOverrides.delete(id);
2163
+ this.stopAwaitingInitialHistory(id);
2164
+ const timer = this.followTimers.get(id);
2165
+ if (timer) clearTimeout(timer);
2166
+ this.followTimers.delete(id);
2167
+ const baselineTimer = this.followerBaselineTimers.get(id);
2168
+ if (baselineTimer) clearTimeout(baselineTimer);
2169
+ this.followerBaselineTimers.delete(id);
2170
+ const sidebarTimer = this.sidebarTimers.get(id);
2171
+ if (sidebarTimer) clearTimeout(sidebarTimer);
2172
+ this.sidebarTimers.delete(id);
2173
+ this.announcedSidebarThreadIds.delete(id);
2174
+ // `releaseThread` releases only this bridge's local mirror. Do not erase
2175
+ // the monotonic Desktop-ownership fact: a transient handoff/reconnect must
2176
+ // not make a later Android prompt call private thread/resume.
2177
+ }
2178
+
2179
+ claimThread(input: {
2180
+ threadId: string;
2181
+ turnStartParams: JsonRecord;
2182
+ cwd?: string;
2183
+ title?: string;
2184
+ }): void {
2185
+ const threadId = stringValue(input.threadId);
2186
+ if (!threadId || !DESKTOP_THREAD_ID.test(threadId)) return;
2187
+ if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
2188
+ throw new DesktopIpcOwnershipError(
2189
+ `Codex Desktop still owns task ${threadId}; local adoption requires an explicit owner release`,
2190
+ threadId,
2191
+ "local-claim",
2192
+ 0,
2193
+ );
2194
+ }
2195
+ const needsInitialHistory = !this.ownedThreadIds.has(threadId)
2196
+ && !this.conversations.has(threadId)
2197
+ && !this.lastBroadcastStates.has(threadId);
2198
+ // Claiming is the local-owner transition. The gateway performs the
2199
+ // explicit `releaseDesktopOwnership` step only after a definitive owner
2200
+ // release/no-owner result; this method itself never silently overrides a
2201
+ // remembered Desktop writer.
2202
+ this.ownedThreadIds.add(threadId);
2203
+ if (needsInitialHistory) this.threadsAwaitingInitialHistory.add(threadId);
2204
+ const conversation = this.ensureConversation(threadId, input.cwd);
2205
+ if (input.cwd) conversation.cwd = input.cwd;
2206
+ if (input.title) conversation.title = input.title;
2207
+ const params = sanitizeTurnStartParams({ ...input.turnStartParams, threadId });
2208
+ this.applyRuntimeMetadata(conversation, params);
2209
+ const fallbackTurnId = `opencodex-pending-${this.now()}-${randomUUID()}`;
2210
+ const queue = this.pendingTurnStarts.get(threadId) ?? [];
2211
+ queue.push({ params: clone(params), fallbackTurnId });
2212
+ this.pendingTurnStarts.set(threadId, queue);
2213
+ conversation.turns.push(createConversationTurn({
2214
+ id: fallbackTurnId,
2215
+ status: "inProgress",
2216
+ items: [],
2217
+ }, conversation, this.now, params));
2218
+ this.trimConversationHistory(conversation);
2219
+ conversation.updatedAt = this.now();
2220
+ this.markDirty(threadId);
2221
+ this.scheduleSidebarAnnouncement(threadId);
2222
+ this.transport.start();
2223
+ this.requestInitialHistoryBaseline(threadId);
2224
+ if (this.transport.connected) this.beginFollow(threadId);
2225
+ }
2226
+
2227
+ observeCodexMessage(message: CodexJsonRpcMessage): void {
2228
+ const method = stringValue(message.method);
2229
+ if (!method) return;
2230
+ const params = record(message.params) ?? {};
2231
+ const threadId = threadIdFromMessage(method, params);
2232
+ if (!threadId || !this.ownedThreadIds.has(threadId)) return;
2233
+ const conversation = this.ensureConversation(threadId);
2234
+ let changed = false;
2235
+ let requiresAuthoritativeSnapshot = false;
2236
+
2237
+ if (SERVER_REQUEST_METHODS.has(method) && message.id !== undefined) {
2238
+ upsertById(conversation.requests, {
2239
+ id: message.id,
2240
+ method,
2241
+ params: clone(params),
2242
+ });
2243
+ changed = true;
2244
+ requiresAuthoritativeSnapshot = true;
2245
+ } else if (method === "thread/started") {
2246
+ const thread = record(params.thread);
2247
+ if (thread) this.mergeThread(conversation, thread);
2248
+ changed = true;
2249
+ } else if (method === "thread/name/updated") {
2250
+ conversation.title = stringValue(params.threadName)
2251
+ || stringValue(params.name)
2252
+ || stringValue(params.title)
2253
+ || conversation.title;
2254
+ changed = true;
2255
+ } else if (method === "thread/status/changed") {
2256
+ conversation.threadRuntimeStatus = clone(params.status ?? null);
2257
+ changed = true;
2258
+ } else if (method === "thread/tokenUsage/updated") {
2259
+ conversation.latestTokenUsageInfo = clone(params.tokenUsage ?? params.usage ?? null);
2260
+ changed = true;
2261
+ } else if (method === "turn/started" || method === "turn/completed") {
2262
+ const rawTurn = record(params.turn);
2263
+ const explicitTurnId = turnIdFromParams(params) || stringValue(rawTurn?.id);
2264
+ const pending = method === "turn/started"
2265
+ ? this.shiftPendingTurn(threadId)
2266
+ : explicitTurnId && !findTurn(conversation, explicitTurnId)
2267
+ ? this.shiftPendingTurn(threadId)
2268
+ : null;
2269
+ const turnId = explicitTurnId || pending?.fallbackTurnId || this.activeTurnId(conversation);
2270
+ if (turnId) {
2271
+ const previousId = pending?.fallbackTurnId;
2272
+ const previous = previousId ? findTurn(conversation, previousId) : findTurn(conversation, turnId);
2273
+ const next = createConversationTurn({
2274
+ ...(rawTurn ?? {}),
2275
+ id: turnId,
2276
+ ...(method === "turn/completed" && rawTurn?.status == null ? { status: "completed" } : {}),
2277
+ }, conversation, this.now, pending?.params ?? previous?.params);
2278
+ if (previous) Object.assign(next, mergeTurnContinuity(previous, next));
2279
+ replaceTurn(conversation, previousId || turnId, next);
2280
+ if (pending) this.applyRuntimeMetadata(conversation, pending.params);
2281
+ }
2282
+ changed = true;
2283
+ if (method === "turn/completed") {
2284
+ requiresAuthoritativeSnapshot = true;
2285
+ this.announceSidebarThread(threadId, true);
2286
+ }
2287
+ } else if (method === "turn/diff/updated") {
2288
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
2289
+ if (turn) turn.diff = typeof params.diff === "string" ? params.diff : "";
2290
+ changed = Boolean(turn);
2291
+ requiresAuthoritativeSnapshot = changed;
2292
+ } else if (method === "turn/plan/updated") {
2293
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
2294
+ if (turn) upsertTurnItem(turn, {
2295
+ id: `todo-list-${turn.turnId}`,
2296
+ type: "todo-list",
2297
+ explanation: stringValue(params.explanation) || null,
2298
+ plan: Array.isArray(params.plan) ? clone(params.plan) : [],
2299
+ });
2300
+ changed = Boolean(turn);
2301
+ } else if (method === "item/started" || method === "item/completed") {
2302
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
2303
+ const item = sanitizeDesktopItem(record(params.item));
2304
+ if (turn && item) {
2305
+ const duplicatesInitialInput = turn.items.every(isInitialTurnPrefixItem)
2306
+ && userMessageDuplicatesTurnInput(turn, item);
2307
+ if (!duplicatesInitialInput) upsertTurnItem(turn, item);
2308
+ if (item.type !== "userMessage") turn.firstTurnWorkItemStartedAtMs ||= this.now();
2309
+ if (item.type === "agentMessage") turn.finalAssistantStartedAtMs ||= this.now();
2310
+ if (item.type === "commandExecution") {
2311
+ (turn.commandExecutionStartedAtMsById as JsonRecord)[stringValue(item.id)] ||= this.now();
2312
+ }
2313
+ changed = true;
2314
+ if (method === "item/completed") requiresAuthoritativeSnapshot = true;
2315
+ if (item.type === "userMessage" && method === "item/completed") {
2316
+ this.announceSidebarThread(threadId, true);
2317
+ }
2318
+ }
2319
+ } else if (method === "item/agentMessage/delta") {
2320
+ changed = this.appendItemDelta(conversation, params, "agentMessage", "text");
2321
+ } else if (method === "item/plan/delta") {
2322
+ changed = this.appendItemDelta(conversation, params, "plan", "text");
2323
+ } else if (method === "item/reasoning/summaryTextDelta") {
2324
+ changed = this.appendReasoningSummary(conversation, params);
2325
+ } else if (method === "item/fileChange/patchUpdated") {
2326
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params), false);
2327
+ const itemId = stringValue(params.itemId);
2328
+ if (turn && itemId) {
2329
+ const item = ensureItem(turn, itemId, () => ({ type: "fileChange", id: itemId, changes: [], status: "inProgress" }));
2330
+ item.changes = Array.isArray(params.changes) ? clone(params.changes) : [];
2331
+ changed = true;
2332
+ }
2333
+ } else if (method === "item/commandExecution/outputDelta") {
2334
+ changed = this.appendItemDelta(conversation, params, "commandExecution", "aggregatedOutput");
2335
+ } else if (method === "item/mcpToolCall/progress") {
2336
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
2337
+ const itemId = stringValue(params.itemId);
2338
+ if (turn && itemId) {
2339
+ const item = ensureItem(turn, itemId, () => ({ type: "mcpToolCall", id: itemId, status: "inProgress" }));
2340
+ item.progress = stringValue(params.message).slice(0, 8192);
2341
+ changed = true;
2342
+ }
2343
+ } else if (method === "serverRequest/resolved") {
2344
+ const key = requestIdKey(params.requestId ?? params.request_id);
2345
+ conversation.requests = conversation.requests.filter(request => requestIdKey(request.id) !== key);
2346
+ changed = true;
2347
+ requiresAuthoritativeSnapshot = true;
2348
+ } else if (method === "error") {
2349
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
2350
+ if (turn) {
2351
+ turn.error = clone(params.error ?? null);
2352
+ upsertTurnItem(turn, {
2353
+ id: `error-${this.now()}`,
2354
+ type: "error",
2355
+ message: stringValue(record(params.error)?.message) || "Codex error",
2356
+ willRetry: Boolean(params.willRetry),
2357
+ });
2358
+ changed = true;
2359
+ requiresAuthoritativeSnapshot = true;
2360
+ }
2361
+ }
2362
+
2363
+ if (changed) {
2364
+ this.trimConversationHistory(conversation);
2365
+ conversation.updatedAt = this.now();
2366
+ this.markDirty(threadId);
2367
+ if (requiresAuthoritativeSnapshot) this.forceSnapshot(threadId);
2368
+ }
2369
+ }
2370
+
2371
+ private onConnected(): void {
2372
+ // A remembered Desktop-owned task remains protected across a socket or
2373
+ // renderer restart. Reconnect is deliberately metadata-only: registering
2374
+ // a follower here can make older Desktop builds materialize and broadcast
2375
+ // their complete transcript.
2376
+ for (const threadId of [...this.followedDesktopThreadIds]) {
2377
+ // Re-registering a follower can make Desktop eagerly broadcast its
2378
+ // complete materialized snapshot. That is precisely the unsafe path
2379
+ // this recovery code is designed to avoid for a remembered
2380
+ // Desktop-owned task. State consumers explicitly request bounded
2381
+ // pages instead (see readFollowerThreadState), so reconnect itself is
2382
+ // metadata-only.
2383
+ if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
2384
+ this.followedDesktopThreadIds.delete(threadId);
2385
+ continue;
2386
+ }
2387
+ this.transport.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
2388
+ conversationId: threadId,
2389
+ hostId: HOST_ID,
2390
+ following: true,
2391
+ });
2392
+ }
2393
+ // A waiter may have started while the bus was disconnected. Resolve it
2394
+ // with the metadata-only owner probe; never ask Desktop to load history.
2395
+ for (const threadId of this.desktopOwnerWaiters.keys()) {
2396
+ void this.discoverDesktopOwner(threadId).catch(() => undefined);
2397
+ }
2398
+ for (const threadId of this.ownedThreadIds) {
2399
+ this.transport.sendBroadcast("thread-stream-following-status-requested", {
2400
+ hostId: HOST_ID,
2401
+ conversationId: threadId,
2402
+ });
2403
+ if (!this.shouldDelayInitialSnapshot(threadId)) this.forceSnapshot(threadId);
2404
+ this.beginFollow(threadId);
2405
+ }
2406
+ }
2407
+
2408
+ private onDisconnected(): void {
2409
+ // Current renderer ids are connection-scoped. Keep the monotonic
2410
+ // `desktopOwnedThreadIds` fact, but force the next mutation through the
2411
+ // router/discovery path (or a newly observed snapshot).
2412
+ this.desktopOwnerClientIds.clear();
2413
+ this.boundedHistoryUnsupportedGeneration = null;
2414
+ for (const waiters of this.desktopOwnerWaiters.values()) {
2415
+ for (const waiter of waiters) {
2416
+ clearTimeout(waiter.timer);
2417
+ waiter.resolve(null);
2418
+ }
2419
+ }
2420
+ this.desktopOwnerWaiters.clear();
2421
+ this.followerClientIds.clear();
2422
+ this.followAttempts.clear();
2423
+ for (const timer of this.followTimers.values()) clearTimeout(timer);
2424
+ this.followTimers.clear();
2425
+ for (const timer of this.followerBaselineTimers.values()) clearTimeout(timer);
2426
+ this.followerBaselineTimers.clear();
2427
+ const followerThreadsBeforeDisconnect = [...this.followerStates.keys()];
2428
+ this.followerStates.clear();
2429
+ this.followerRevisions.clear();
2430
+ for (const threadId of followerThreadsBeforeDisconnect) this.historyPages.clearThread(threadId);
2431
+ this.queuedFollowerChanges.clear();
2432
+ for (const recovery of this.followerRecovery.values()) {
2433
+ if (recovery.timer) clearTimeout(recovery.timer);
2434
+ }
2435
+ this.followerRecovery.clear();
2436
+ for (const waiters of this.followerStateWaiters.values()) {
2437
+ for (const waiter of waiters) {
2438
+ clearTimeout(waiter.timer);
2439
+ waiter.reject(new Error("Codex Desktop IPC connection closed"));
2440
+ }
2441
+ }
2442
+ this.followerStateWaiters.clear();
2443
+ }
2444
+
2445
+ private onBroadcast(envelope: DesktopIpcEnvelope): void {
2446
+ if (envelope.method === THREAD_ARCHIVED) {
2447
+ const params = record(envelope.params) ?? {};
2448
+ const threadId = threadIdFromParams(params);
2449
+ const ownerClientId = this.desktopOwnerClientIds.get(threadId);
2450
+ const sourceClientId = stringValue(envelope.sourceClientId);
2451
+ if (
2452
+ threadId
2453
+ && (params.ownerReleased === true || (ownerClientId && ownerClientId === sourceClientId))
2454
+ ) {
2455
+ this.releaseDesktopOwnership(threadId);
2456
+ }
2457
+ return;
2458
+ }
2459
+ if (envelope.method === THREAD_STREAM_STATE_CHANGED) {
2460
+ const params = record(envelope.params) ?? {};
2461
+ if (stringValue(params.opencodexOwnerSource) === OWNER_SOURCE) return;
2462
+ const threadId = threadIdFromParams(params);
2463
+ if (
2464
+ threadId
2465
+ && (params.desktopOwnerReleased === true || params.ownerReleased === true)
2466
+ ) {
2467
+ this.releaseDesktopOwnership(threadId);
2468
+ return;
2469
+ }
2470
+ const change = followerStateChange(params.change);
2471
+ if (!threadId || !change) return;
2472
+ const wasOwned = this.ownedThreadIds.has(threadId);
2473
+ if (wasOwned && change.type === "snapshot") {
2474
+ if (this.hasActiveLocalTurn(threadId) && !snapshotShowsActiveTurn(change)) {
2475
+ this.log(`Ignoring idle peer snapshot while local turn is active for ${threadId}`);
2476
+ return;
2477
+ }
2478
+ this.log(`Yielding IPC ownership of ${threadId} to peer snapshot revision ${change.revision ?? "unknown"}`);
2479
+ this.releaseThread(threadId);
2480
+ }
2481
+ // This broadcast is authoritative even when the snapshot is idle and
2482
+ // Android is not currently following the task. Record the source before
2483
+ // any reconnect/handler race can make a later mutation look ownerless.
2484
+ this.rememberDesktopOwner(threadId, stringValue(envelope.sourceClientId));
2485
+ const interested = wasOwned
2486
+ || this.desktopOwnedThreadIds.has(threadId)
2487
+ || this.followedDesktopThreadIds.has(threadId)
2488
+ || this.followerStateWaiters.has(threadId);
2489
+ if (interested) this.applyFollowerStateChange(threadId, change);
2490
+ return;
2491
+ }
2492
+ if (envelope.method === THREAD_STREAM_FOLLOWING_CHANGED) {
2493
+ const params = record(envelope.params) ?? {};
2494
+ const threadId = threadIdFromParams(params);
2495
+ if (!this.ownedThreadIds.has(threadId)) return;
2496
+ const clientId = stringValue(envelope.sourceClientId) || stringValue(params.clientId);
2497
+ const followers = this.followerClientIds.get(threadId) ?? new Set<string>();
2498
+ if (params.following === false) followers.delete(clientId);
2499
+ else if (params.following === true && clientId) followers.add(clientId);
2500
+ this.followerClientIds.set(threadId, followers);
2501
+ if (params.following === true) {
2502
+ const timer = this.followTimers.get(threadId);
2503
+ if (timer) clearTimeout(timer);
2504
+ this.followTimers.delete(threadId);
2505
+ this.log(`Codex Desktop is following Android task ${threadId}`);
2506
+ this.forceSnapshot(threadId);
2507
+ this.scheduleFollowerMountBaseline(threadId);
2508
+ }
2509
+ return;
2510
+ }
2511
+ if (envelope.method === CLIENT_STATUS_CHANGED) {
2512
+ const params = record(envelope.params) ?? {};
2513
+ if (normalizeToken(params.status) === "disconnected") {
2514
+ const clientId = stringValue(params.clientId) || stringValue(envelope.sourceClientId);
2515
+ for (const followers of this.followerClientIds.values()) followers.delete(clientId);
2516
+ this.removeDesktopOwnerClient(clientId);
2517
+ } else {
2518
+ for (const threadId of this.ownedThreadIds) {
2519
+ this.transport.sendBroadcast("thread-stream-following-status-requested", {
2520
+ hostId: HOST_ID,
2521
+ conversationId: threadId,
2522
+ });
2523
+ if (!this.shouldDelayInitialSnapshot(threadId)) this.forceSnapshot(threadId);
2524
+ }
2525
+ }
2526
+ }
2527
+ }
2528
+
2529
+ private applyFollowerStateChange(threadId: string, change: FollowerStateChange): void {
2530
+ if (change.type === "snapshot") {
2531
+ const state = record(change.conversationState);
2532
+ if (!state) return;
2533
+ // Desktop is allowed to have a much larger native transcript than the
2534
+ // bridge can safely retain. Bound an inbound snapshot before storing it
2535
+ // or resolving a pending-request read; otherwise one legacy renderer
2536
+ // broadcast could recreate the original 171 MB failure on this side.
2537
+ const bounded = this.historyPages.recentPage(threadId, state).state;
2538
+ this.followerStates.set(threadId, clone(bounded));
2539
+ this.followerRevisions.set(threadId, change.revision ?? 0);
2540
+ this.clearFollowerRecovery(threadId);
2541
+ this.applyQueuedFollowerChanges(threadId);
2542
+ this.resolveFollowerStateWaiters(threadId);
2543
+ return;
2544
+ }
2545
+
2546
+ const state = this.followerStates.get(threadId);
2547
+ const revision = this.followerRevisions.get(threadId);
2548
+ if (
2549
+ state
2550
+ && revision !== undefined
2551
+ && change.baseRevision === revision
2552
+ && change.revision !== undefined
2553
+ && Array.isArray(change.patches)
2554
+ ) {
2555
+ const next = applyDesktopStatePatches(state, change.patches);
2556
+ if (next) {
2557
+ this.followerStates.set(threadId, next);
2558
+ this.followerRevisions.set(threadId, change.revision);
2559
+ this.resolveFollowerStateWaiters(threadId);
2560
+ return;
2561
+ }
2562
+ }
2563
+
2564
+ this.queueFollowerChange(threadId, change);
2565
+ this.followerStates.delete(threadId);
2566
+ this.followerRevisions.delete(threadId);
2567
+ this.recoverFollowerBaseline(threadId);
2568
+ }
2569
+
2570
+ private applyQueuedFollowerChanges(threadId: string): void {
2571
+ const queued = this.queuedFollowerChanges.get(threadId);
2572
+ if (!queued?.length) return;
2573
+ let state = this.followerStates.get(threadId);
2574
+ let revision = this.followerRevisions.get(threadId);
2575
+ if (!state || revision === undefined) return;
2576
+ const remaining: FollowerStateChange[] = [];
2577
+ for (const change of queued) {
2578
+ if (change.type === "snapshot") {
2579
+ const snapshot = record(change.conversationState);
2580
+ if (snapshot) {
2581
+ state = clone(snapshot);
2582
+ revision = change.revision ?? revision;
2583
+ }
2584
+ continue;
2585
+ }
2586
+ if (change.baseRevision !== undefined && change.baseRevision < revision) continue;
2587
+ if (
2588
+ change.baseRevision !== revision
2589
+ || change.revision === undefined
2590
+ || !Array.isArray(change.patches)
2591
+ ) {
2592
+ remaining.push(change);
2593
+ continue;
2594
+ }
2595
+ const next = applyDesktopStatePatches(state, change.patches);
2596
+ if (!next) {
2597
+ remaining.push(change);
2598
+ continue;
2599
+ }
2600
+ state = next;
2601
+ revision = change.revision;
2602
+ }
2603
+ this.followerStates.set(threadId, state);
2604
+ this.followerRevisions.set(threadId, revision);
2605
+ if (remaining.length > 0) {
2606
+ this.queuedFollowerChanges.set(threadId, remaining);
2607
+ this.recoverFollowerBaseline(threadId);
2608
+ } else {
2609
+ this.queuedFollowerChanges.delete(threadId);
2610
+ }
2611
+ }
2612
+
2613
+ private queueFollowerChange(threadId: string, change: FollowerStateChange): void {
2614
+ const queued = this.queuedFollowerChanges.get(threadId) ?? [];
2615
+ queued.push(clone(change));
2616
+ if (queued.length > MAX_QUEUED_FOLLOWER_CHANGES) {
2617
+ queued.splice(0, queued.length - MAX_QUEUED_FOLLOWER_CHANGES);
2618
+ }
2619
+ this.queuedFollowerChanges.set(threadId, queued);
2620
+ }
2621
+
2622
+ private recoverFollowerBaseline(threadId: string): void {
2623
+ if (!this.transport.connected) return;
2624
+ const recovery = this.followerRecovery.get(threadId) ?? { attempts: 0, timer: null, inFlight: false };
2625
+ if (recovery.inFlight || recovery.timer || recovery.attempts >= Math.max(1, this.followerBaselineMaxAttempts)) return;
2626
+ recovery.attempts += 1;
2627
+ recovery.inFlight = true;
2628
+ this.followerRecovery.set(threadId, recovery);
2629
+ void this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
2630
+ hostId: HOST_ID,
2631
+ conversationId: threadId,
2632
+ direction: "recent",
2633
+ pageToken: null,
2634
+ }).then(result => {
2635
+ const response = record(result);
2636
+ const state = record(response?.conversationState ?? response?.conversation_state ?? response?.state);
2637
+ if (!state) return;
2638
+ const bounded = this.historyPages.recentPage(threadId, state).state;
2639
+ this.followerStates.set(threadId, clone(bounded));
2640
+ this.followerRevisions.set(threadId, finiteRevision(response?.revision) ?? 0);
2641
+ this.clearFollowerRecovery(threadId);
2642
+ this.applyQueuedFollowerChanges(threadId);
2643
+ this.resolveFollowerStateWaiters(threadId);
2644
+ }).catch(error => {
2645
+ if (recovery.attempts === 1 || recovery.attempts === this.followerBaselineMaxAttempts) {
2646
+ this.warn(`Could not recover the Desktop stream baseline for ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
2647
+ }
2648
+ }).finally(() => {
2649
+ const current = this.followerRecovery.get(threadId);
2650
+ if (!current || current !== recovery) return;
2651
+ current.inFlight = false;
2652
+ if (this.followerStates.has(threadId) || current.attempts >= Math.max(1, this.followerBaselineMaxAttempts)) return;
2653
+ const delay = Math.min(
2654
+ Math.max(0, this.followerBaselineMaxRetryMs),
2655
+ Math.max(0, this.followerBaselineRetryMs) * (2 ** Math.min(current.attempts - 1, 5)),
2656
+ );
2657
+ current.timer = setTimeout(() => {
2658
+ const latest = this.followerRecovery.get(threadId);
2659
+ if (latest) latest.timer = null;
2660
+ this.recoverFollowerBaseline(threadId);
2661
+ }, delay);
2662
+ current.timer.unref?.();
2663
+ });
2664
+ }
2665
+
2666
+ private clearFollowerRecovery(threadId: string): void {
2667
+ const recovery = this.followerRecovery.get(threadId);
2668
+ if (recovery?.timer) clearTimeout(recovery.timer);
2669
+ this.followerRecovery.delete(threadId);
2670
+ }
2671
+
2672
+ private resolveFollowerStateWaiters(threadId: string): void {
2673
+ const state = this.followerStates.get(threadId);
2674
+ const waiters = this.followerStateWaiters.get(threadId);
2675
+ if (!state || !waiters) return;
2676
+ this.followerStateWaiters.delete(threadId);
2677
+ for (const waiter of waiters) {
2678
+ clearTimeout(waiter.timer);
2679
+ waiter.resolve(clone(state));
2680
+ }
2681
+ }
2682
+
2683
+ private hasActiveLocalTurn(threadId: string): boolean {
2684
+ if ((this.pendingTurnStarts.get(threadId)?.length ?? 0) > 0) return true;
2685
+ return (this.conversations.get(threadId)?.turns ?? []).some(turn => {
2686
+ const status = normalizeToken(turn.status);
2687
+ return !status || status === "inprogress" || status === "running" || status === "active";
2688
+ });
2689
+ }
2690
+
2691
+ private canHandleRequest(envelope: DesktopIpcEnvelope): boolean {
2692
+ const method = stringValue(envelope.method);
2693
+ const params = record(envelope.params) ?? {};
2694
+ const threadId = threadIdFromParams(params);
2695
+ const sourceClientId = stringValue(envelope.sourceClientId);
2696
+ const localClientId = stringValue(this.transport.localClientId);
2697
+ if (sourceClientId && localClientId && sourceClientId === localClientId) {
2698
+ return false;
2699
+ }
2700
+ // Owner discovery is deliberately metadata-only. It is part of the
2701
+ // router's route-probe protocol, not a request to hydrate or broadcast a
2702
+ // conversation. Restrict it to the bridge's local writer so a stale
2703
+ // renderer cannot claim an arbitrary thread.
2704
+ if (method === THREAD_OWNER_DISCOVERY) {
2705
+ return stringValue(params.hostId) === HOST_ID
2706
+ && Boolean(threadId)
2707
+ && this.ownedThreadIds.has(threadId);
2708
+ }
2709
+ return FOLLOWER_METHODS.has(method) && this.ownedThreadIds.has(threadId);
2710
+ }
2711
+
2712
+ private async handleRequest(envelope: DesktopIpcEnvelope): Promise<unknown> {
2713
+ const method = stringValue(envelope.method);
2714
+ const params = record(envelope.params) ?? {};
2715
+ const threadId = threadIdFromParams(params);
2716
+ if (!threadId || !this.ownedThreadIds.has(threadId)) throw new Error("conversation-not-owned");
2717
+ if (method === THREAD_OWNER_DISCOVERY) {
2718
+ // The response envelope carries handledByClientId. Returning an empty
2719
+ // object is intentional: no turns, items, requests, or content cross
2720
+ // the IPC boundary during route discovery.
2721
+ return {};
2722
+ }
2723
+ if (method === THREAD_FOLLOWER_LOAD_HISTORY_PAGE) {
2724
+ const directionToken = normalizeToken(params.direction);
2725
+ const direction: DesktopHistoryPageDirection = directionToken === "older"
2726
+ ? "older"
2727
+ : directionToken === "newer"
2728
+ ? "newer"
2729
+ : "recent";
2730
+ const pageToken = stringValue(params.pageToken ?? params.page_token);
2731
+ let page: DesktopBoundedHistoryPage;
2732
+ const conversation = this.ensureConversation(threadId);
2733
+ if (direction === "recent") {
2734
+ page = this.historyPages.recentPage(threadId, conversation);
2735
+ } else {
2736
+ if (!pageToken) throw new Error("A page token is required for older or newer history");
2737
+ page = this.historyPages.page(threadId, conversation, direction, pageToken);
2738
+ }
2739
+ // Return the bounded page directly as well as publishing it. A direct
2740
+ // response lets a caller recover when it is not currently subscribed to
2741
+ // stream broadcasts, while the broadcast keeps existing followers in
2742
+ // sync. Both paths carry the same byte-bounded state.
2743
+ this.stopAwaitingInitialHistory(threadId);
2744
+ this.broadcastState(threadId, true);
2745
+ const revision = this.revisions.get(threadId) ?? 0;
2746
+ return {
2747
+ revision,
2748
+ conversationState: page.state,
2749
+ historyPage: page.pageInfo,
2750
+ };
2751
+ }
2752
+ if (method === THREAD_FOLLOWER_READ_CONTENT_CHUNK) {
2753
+ const handle = stringValue(params.handle);
2754
+ const sourceRevision = stringValue(params.sourceRevision ?? params.source_revision);
2755
+ const offset = numberValue(params.offset) ?? 0;
2756
+ if (!handle || !sourceRevision || !Number.isSafeInteger(offset) || offset < 0) {
2757
+ throw new Error("A valid content handle, source revision, and offset are required");
2758
+ }
2759
+ return this.historyPages.readContentChunk({
2760
+ threadId,
2761
+ sourceRevision,
2762
+ handle,
2763
+ offset,
2764
+ });
2765
+ }
2766
+ if (method === "thread-follower-load-complete-history") {
2767
+ // Legacy Desktop clients may still send this method. Keep the method
2768
+ // available for compatibility, but make its response bounded; it must
2769
+ // never materialize a 64 MiB+ IPC frame.
2770
+ await this.hydrateThread(threadId);
2771
+ this.stopAwaitingInitialHistory(threadId);
2772
+ if (!this.broadcastState(threadId, true)) throw new Error("Codex Desktop is not connected");
2773
+ const bounded = this.historyPages.recentPage(threadId, this.ensureConversation(threadId));
2774
+ return {
2775
+ revision: this.revisions.get(threadId) ?? 0,
2776
+ conversationState: bounded.state,
2777
+ historyPage: bounded.pageInfo,
2778
+ };
2779
+ }
2780
+ if (method === "thread-follower-start-turn") {
2781
+ const turnStart = record(params.turnStart);
2782
+ const raw = record(turnStart?.request)
2783
+ ?? record(params.turnStartParams)
2784
+ ?? record(params.turn_start_params)
2785
+ ?? params;
2786
+ const startParams = this.withRuntimeOverrides(threadId, sanitizeTurnStartParams({ ...raw, threadId }));
2787
+ this.rememberFollowerTurnStart(threadId, startParams);
2788
+ try {
2789
+ const result = await this.sendCodexRequest("turn/start", startParams);
2790
+ return { result: result ?? null };
2791
+ } catch (error) {
2792
+ this.removeLatestPendingTurn(threadId);
2793
+ throw error;
2794
+ }
2795
+ }
2796
+ if (method === "thread-follower-steer-turn") {
2797
+ const raw = record(params.turnSteerParams) ?? record(params.turn_steer_params) ?? params;
2798
+ const expectedTurnId = stringValue(raw.expectedTurnId)
2799
+ || stringValue(raw.expected_turn_id)
2800
+ || this.activeTurnId(this.ensureConversation(threadId));
2801
+ if (!expectedTurnId) throw new Error("The active Codex turn could not be found");
2802
+ return await this.sendCodexRequest("turn/steer", {
2803
+ threadId,
2804
+ ...(stringValue(raw.clientUserMessageId ?? raw.client_user_message_id)
2805
+ ? { clientUserMessageId: stringValue(raw.clientUserMessageId ?? raw.client_user_message_id) }
2806
+ : {}),
2807
+ input: Array.isArray(raw.input) ? raw.input : [],
2808
+ expectedTurnId,
2809
+ });
2810
+ }
2811
+ if (method === "thread-follower-interrupt-turn") {
2812
+ const turnId = stringValue(params.expectedTurnId)
2813
+ || stringValue(params.expected_turn_id)
2814
+ || stringValue(params.turnId)
2815
+ || stringValue(params.turn_id)
2816
+ || this.activeTurnId(this.ensureConversation(threadId));
2817
+ if (!turnId) throw new Error("The active Codex turn could not be found");
2818
+ return await this.sendCodexRequest("turn/interrupt", {
2819
+ threadId,
2820
+ turnId,
2821
+ });
2822
+ }
2823
+ if (method === "thread-follower-rollback-thread") {
2824
+ const numTurns = numberValue(params.numTurns ?? params.num_turns);
2825
+ if (numTurns === null || !Number.isInteger(numTurns) || numTurns < 1) {
2826
+ throw new Error("numTurns must be an integer greater than zero");
2827
+ }
2828
+ const response = record(await this.sendCodexRequest("thread/rollback", {
2829
+ threadId,
2830
+ numTurns,
2831
+ }));
2832
+ const rolledBackThread = record(response?.thread);
2833
+ if (rolledBackThread) {
2834
+ this.mergeThread(this.ensureConversation(threadId), rolledBackThread, true);
2835
+ } else {
2836
+ await this.hydrateThread(threadId);
2837
+ }
2838
+ this.pendingTurnStarts.delete(threadId);
2839
+ // A rollback removes an arbitrary suffix of turns. Publish the complete
2840
+ // replacement immediately so an already-open Codex Desktop follower
2841
+ // cannot keep rendering the removed prompt while Android starts the
2842
+ // edited turn. Ordinary patches are intentionally not used for this
2843
+ // non-append history rewrite.
2844
+ this.stopAwaitingInitialHistory(threadId);
2845
+ this.forceSnapshot(threadId);
2846
+ return { result: response ?? null };
2847
+ }
2848
+ if (method === "thread-follower-compact-thread") {
2849
+ return await this.sendCodexRequest("thread/compact/start", { threadId });
2850
+ }
2851
+ if (method === "thread-follower-set-model-and-reasoning") {
2852
+ await this.applyFollowerRuntimeSettings(threadId, params);
2853
+ return { ok: true };
2854
+ }
2855
+ if (method === "thread-follower-set-collaboration-mode") {
2856
+ await this.applyFollowerRuntimeSettings(threadId, { collaborationMode: params.collaborationMode });
2857
+ return { ok: true };
2858
+ }
2859
+ if (method === "thread-follower-update-thread-settings") {
2860
+ await this.applyFollowerRuntimeSettings(threadId, record(params.threadSettings) ?? {});
2861
+ return { ok: true };
2862
+ }
2863
+ const requestId = params.requestId ?? params.request_id;
2864
+ if (method === "thread-follower-command-approval-decision") {
2865
+ return this.respondToPendingRequest(threadId, requestId, { decision: params.decision });
2866
+ }
2867
+ if (method === "thread-follower-file-approval-decision") {
2868
+ return this.respondToPendingRequest(threadId, requestId, this.fileApprovalResult(threadId, requestId, params));
2869
+ }
2870
+ if (
2871
+ method === "thread-follower-permissions-request-approval-response"
2872
+ || method === "thread-follower-submit-user-input"
2873
+ || method === "thread-follower-submit-mcp-server-elicitation-response"
2874
+ ) {
2875
+ return this.respondToPendingRequest(threadId, requestId, record(params.response) ?? {});
2876
+ }
2877
+ throw new Error(`Unsupported Codex Desktop follower action: ${method}`);
2878
+ }
2879
+
2880
+ private ensureConversation(threadId: string, cwd = ""): ConversationState {
2881
+ const existing = this.conversations.get(threadId);
2882
+ if (existing) return existing;
2883
+ const timestamp = this.now();
2884
+ const conversation: ConversationState = {
2885
+ id: threadId,
2886
+ hostId: HOST_ID,
2887
+ turns: [],
2888
+ requests: [],
2889
+ createdAt: timestamp,
2890
+ updatedAt: timestamp,
2891
+ title: null,
2892
+ latestModel: "",
2893
+ latestReasoningEffort: null,
2894
+ latestServiceTier: null,
2895
+ previousTurnModel: null,
2896
+ latestCollaborationMode: {
2897
+ mode: "default",
2898
+ settings: { reasoning_effort: null, model: "", developer_instructions: null },
2899
+ },
2900
+ hasUnreadTurn: false,
2901
+ unreadMessageCount: 0,
2902
+ threadGoal: null,
2903
+ completedThreadGoal: null,
2904
+ threadRuntimeStatus: null,
2905
+ rolloutPath: "",
2906
+ cwd,
2907
+ gitInfo: null,
2908
+ resumeState: "resumed",
2909
+ latestTokenUsageInfo: null,
2910
+ workspaceKind: "project",
2911
+ workspaceBrowserRoot: null,
2912
+ projectlessOutputDirectory: null,
2913
+ currentPermissions: null,
2914
+ };
2915
+ this.conversations.set(threadId, conversation);
2916
+ return conversation;
2917
+ }
2918
+
2919
+ private async hydrateThread(threadId: string): Promise<boolean> {
2920
+ const active = this.hydrationPromises.get(threadId);
2921
+ if (active) return active;
2922
+ const read = this.readThreadPage
2923
+ ? this.readThreadPage(threadId)
2924
+ : this.readThread(threadId);
2925
+ const hydration = read
2926
+ .then(result => {
2927
+ const thread = record(result?.thread) ?? record(result);
2928
+ if (!thread || stringValue(thread.id) !== threadId || !this.ownedThreadIds.has(threadId)) return false;
2929
+ this.mergeThread(this.ensureConversation(threadId), thread);
2930
+ this.markDirty(threadId);
2931
+ return true;
2932
+ })
2933
+ .catch(error => {
2934
+ this.warn(`Could not load Codex Desktop history for ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
2935
+ return false;
2936
+ })
2937
+ .finally(() => this.hydrationPromises.delete(threadId));
2938
+ this.hydrationPromises.set(threadId, hydration);
2939
+ return hydration;
2940
+ }
2941
+
2942
+ private requestInitialHistoryBaseline(threadId: string): void {
2943
+ if (!this.ownedThreadIds.has(threadId) || !this.threadsAwaitingInitialHistory.has(threadId)) return;
2944
+ if (this.hydrationPromises.has(threadId) || this.initialHistoryTimers.has(threadId)) return;
2945
+ const attempts = this.initialHistoryAttempts.get(threadId) ?? 0;
2946
+ if (attempts >= Math.max(1, this.initialHistoryMaxAttempts)) {
2947
+ this.warn(`Could not establish a complete Desktop history baseline for ${threadId}; releasing IPC ownership`);
2948
+ this.releaseThread(threadId);
2949
+ return;
2950
+ }
2951
+ this.initialHistoryAttempts.set(threadId, attempts + 1);
2952
+ void this.hydrateThread(threadId).then(success => {
2953
+ if (!this.ownedThreadIds.has(threadId) || !this.threadsAwaitingInitialHistory.has(threadId)) return;
2954
+ if (success) {
2955
+ this.stopAwaitingInitialHistory(threadId);
2956
+ this.markDirty(threadId);
2957
+ return;
2958
+ }
2959
+ const completedAttempts = this.initialHistoryAttempts.get(threadId) ?? attempts + 1;
2960
+ if (completedAttempts >= Math.max(1, this.initialHistoryMaxAttempts)) {
2961
+ this.warn(`Could not establish a complete Desktop history baseline for ${threadId}; releasing IPC ownership`);
2962
+ this.releaseThread(threadId);
2963
+ return;
2964
+ }
2965
+ const delay = Math.max(0, this.initialHistoryRetryMs) * (2 ** Math.min(completedAttempts - 1, 5));
2966
+ const timer = setTimeout(() => {
2967
+ this.initialHistoryTimers.delete(threadId);
2968
+ this.requestInitialHistoryBaseline(threadId);
2969
+ }, delay);
2970
+ timer.unref?.();
2971
+ this.initialHistoryTimers.set(threadId, timer);
2972
+ });
2973
+ }
2974
+
2975
+ private stopAwaitingInitialHistory(threadId: string): void {
2976
+ this.threadsAwaitingInitialHistory.delete(threadId);
2977
+ this.initialHistoryAttempts.delete(threadId);
2978
+ const timer = this.initialHistoryTimers.get(threadId);
2979
+ if (timer) clearTimeout(timer);
2980
+ this.initialHistoryTimers.delete(threadId);
2981
+ }
2982
+
2983
+ private shouldDelayInitialSnapshot(threadId: string): boolean {
2984
+ if (!this.threadsAwaitingInitialHistory.has(threadId)) return false;
2985
+ if (this.lastBroadcastStates.has(threadId)) {
2986
+ this.stopAwaitingInitialHistory(threadId);
2987
+ return false;
2988
+ }
2989
+ this.requestInitialHistoryBaseline(threadId);
2990
+ return this.ownedThreadIds.has(threadId) && this.threadsAwaitingInitialHistory.has(threadId);
2991
+ }
2992
+
2993
+ private mergeThread(
2994
+ conversation: ConversationState,
2995
+ thread: JsonRecord,
2996
+ replaceMissingTurns = false,
2997
+ ): void {
2998
+ conversation.createdAt = timestampMs(thread.createdAt, Number(conversation.createdAt) || this.now());
2999
+ conversation.updatedAt = timestampMs(thread.updatedAt, this.now());
3000
+ conversation.title = stringValue(thread.name) || conversation.title;
3001
+ conversation.cwd = stringValue(thread.cwd) || conversation.cwd;
3002
+ conversation.rolloutPath = stringValue(thread.path) || conversation.rolloutPath;
3003
+ conversation.gitInfo = boundInboundPatchValue(thread.gitInfo ?? conversation.gitInfo ?? null) as JsonRecord | null;
3004
+ conversation.threadRuntimeStatus = boundInboundPatchValue(
3005
+ thread.status ?? conversation.threadRuntimeStatus ?? null,
3006
+ );
3007
+ const model = stringValue(thread.model) || stringValue(thread.modelProvider);
3008
+ if (model) conversation.latestModel = model;
3009
+ if (Array.isArray(thread.turns)) {
3010
+ const currentById = new Map(conversation.turns.map(turn => [turn.turnId, turn]));
3011
+ const hydrated: ConversationTurn[] = [];
3012
+ const turns = thread.turns.slice(-INBOUND_HISTORY_MAX_TURNS);
3013
+ for (const candidate of turns) {
3014
+ const rawTurn = record(candidate);
3015
+ const turnId = stringValue(rawTurn?.id) || stringValue(rawTurn?.turnId);
3016
+ if (!rawTurn || !turnId) continue;
3017
+ const built = createConversationTurn(rawTurn, conversation, this.now, currentById.get(turnId)?.params);
3018
+ const previous = currentById.get(turnId);
3019
+ hydrated.push(previous ? mergeTurnContinuity(previous, built) : built);
3020
+ currentById.delete(turnId);
3021
+ }
3022
+ conversation.turns = replaceMissingTurns
3023
+ ? hydrated
3024
+ : [...hydrated, ...currentById.values()];
3025
+ }
3026
+ this.trimConversationHistory(conversation);
3027
+ }
3028
+
3029
+ /** Keep the local owner mirror bounded even while a long turn stream grows. */
3030
+ private trimConversationHistory(conversation: ConversationState): void {
3031
+ if (conversation.turns.length > INBOUND_HISTORY_MAX_TURNS) {
3032
+ conversation.turns = conversation.turns.slice(-INBOUND_HISTORY_MAX_TURNS);
3033
+ }
3034
+ let remainingItems = DESKTOP_HISTORY_PAGE_MAX_ITEMS;
3035
+ for (let index = conversation.turns.length - 1; index >= 0; index -= 1) {
3036
+ const turn = conversation.turns[index]!;
3037
+ const items = Array.isArray(turn.items) ? turn.items : [];
3038
+ const keep = Math.min(remainingItems, items.length);
3039
+ turn.items = keep > 0 ? items.slice(-keep) : [];
3040
+ remainingItems = Math.max(0, remainingItems - keep);
3041
+ }
3042
+ if (conversation.requests.length > 64) conversation.requests = conversation.requests.slice(-64);
3043
+ }
3044
+
3045
+ private shiftPendingTurn(threadId: string): PendingTurnStart | null {
3046
+ const queue = this.pendingTurnStarts.get(threadId);
3047
+ const pending = queue?.shift() ?? null;
3048
+ if (!queue || queue.length === 0) this.pendingTurnStarts.delete(threadId);
3049
+ return pending;
3050
+ }
3051
+
3052
+ private rememberFollowerTurnStart(threadId: string, params: JsonRecord): void {
3053
+ const conversation = this.ensureConversation(threadId);
3054
+ const fallbackTurnId = `opencodex-pending-${this.now()}-${randomUUID()}`;
3055
+ const queue = this.pendingTurnStarts.get(threadId) ?? [];
3056
+ queue.push({ params: clone(params), fallbackTurnId });
3057
+ this.pendingTurnStarts.set(threadId, queue);
3058
+ conversation.turns.push(createConversationTurn({ id: fallbackTurnId, status: "inProgress", items: [] }, conversation, this.now, params));
3059
+ this.applyRuntimeMetadata(conversation, params);
3060
+ this.markDirty(threadId);
3061
+ }
3062
+
3063
+ private removeLatestPendingTurn(threadId: string): void {
3064
+ const queue = this.pendingTurnStarts.get(threadId);
3065
+ const pending = queue?.pop();
3066
+ if (!queue || queue.length === 0) this.pendingTurnStarts.delete(threadId);
3067
+ if (pending) {
3068
+ const conversation = this.conversations.get(threadId);
3069
+ if (conversation) conversation.turns = conversation.turns.filter(turn => turn.turnId !== pending.fallbackTurnId);
3070
+ this.markDirty(threadId);
3071
+ }
3072
+ }
3073
+
3074
+ private ensureTurn(conversation: ConversationState, turnId: string, allowLast = true): ConversationTurn | null {
3075
+ const id = stringValue(turnId) || (allowLast ? this.activeTurnId(conversation) : "");
3076
+ if (!id) return null;
3077
+ let turn = findTurn(conversation, id);
3078
+ if (!turn) {
3079
+ turn = createConversationTurn({ id, status: "inProgress", items: [] }, conversation, this.now);
3080
+ conversation.turns.push(turn);
3081
+ }
3082
+ return turn;
3083
+ }
3084
+
3085
+ private activeTurnId(conversation: ConversationState): string {
3086
+ for (let index = conversation.turns.length - 1; index >= 0; index -= 1) {
3087
+ const turn = conversation.turns[index];
3088
+ const status = normalizeToken(turn.status);
3089
+ if (!status || status === "inprogress" || status === "running" || status === "active") return turn.turnId;
3090
+ }
3091
+ return conversation.turns.at(-1)?.turnId ?? "";
3092
+ }
3093
+
3094
+ private appendItemDelta(
3095
+ conversation: ConversationState,
3096
+ params: JsonRecord,
3097
+ type: string,
3098
+ field: string,
3099
+ ): boolean {
3100
+ const itemId = stringValue(params.itemId) || stringValue(params.item_id);
3101
+ const delta = typeof params.delta === "string" ? params.delta : "";
3102
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
3103
+ if (!turn || !itemId || !delta) return false;
3104
+ const item = ensureItem(turn, itemId, () => defaultDeltaItem(type, itemId, conversation.cwd as string));
3105
+ item[field] = `${typeof item[field] === "string" ? item[field] : ""}${delta}`.slice(-MAX_STREAM_TEXT_BYTES);
3106
+ turn.firstTurnWorkItemStartedAtMs ||= this.now();
3107
+ if (type === "agentMessage") turn.finalAssistantStartedAtMs ||= this.now();
3108
+ return true;
3109
+ }
3110
+
3111
+ private appendReasoningSummary(conversation: ConversationState, params: JsonRecord): boolean {
3112
+ const itemId = stringValue(params.itemId) || stringValue(params.item_id);
3113
+ const delta = typeof params.delta === "string" ? params.delta : "";
3114
+ const turn = this.ensureTurn(conversation, turnIdFromParams(params));
3115
+ if (!turn || !itemId || !delta) return false;
3116
+ const item = ensureItem(turn, itemId, () => ({ type: "reasoning", id: itemId, summary: [], content: [] }));
3117
+ const index = typeof params.summaryIndex === "number" ? Math.max(0, Math.floor(params.summaryIndex)) : 0;
3118
+ const summary = Array.isArray(item.summary) ? item.summary as unknown[] : [];
3119
+ while (summary.length <= index) summary.push("");
3120
+ summary[index] = `${typeof summary[index] === "string" ? summary[index] : ""}${delta}`.slice(-MAX_STREAM_TEXT_BYTES);
3121
+ item.summary = summary;
3122
+ item.content = [];
3123
+ turn.firstTurnWorkItemStartedAtMs ||= this.now();
3124
+ return true;
3125
+ }
3126
+
3127
+ private applyRuntimeMetadata(conversation: ConversationState, params: JsonRecord): void {
3128
+ const model = stringValue(params.model);
3129
+ if (model) {
3130
+ conversation.previousTurnModel = conversation.latestModel || null;
3131
+ conversation.latestModel = model;
3132
+ }
3133
+ if (typeof params.effort === "string") conversation.latestReasoningEffort = params.effort;
3134
+ if ("serviceTier" in params) conversation.latestServiceTier = params.serviceTier ?? null;
3135
+ if (record(params.collaborationMode)) conversation.latestCollaborationMode = clone(params.collaborationMode);
3136
+ }
3137
+
3138
+ private async applyFollowerRuntimeSettings(threadId: string, params: JsonRecord): Promise<void> {
3139
+ const current = this.runtimeOverrides.get(threadId) ?? {};
3140
+ const model = stringValue(params.model) || stringValue(record(record(params.collaborationMode)?.settings)?.model);
3141
+ if (model) current.model = model;
3142
+ if ("reasoningEffort" in params) current.effort = params.reasoningEffort;
3143
+ if ("effort" in params) current.effort = params.effort;
3144
+ if ("serviceTier" in params) current.serviceTier = params.serviceTier;
3145
+ if (record(params.collaborationMode)) current.collaborationMode = clone(params.collaborationMode);
3146
+ this.runtimeOverrides.set(threadId, current);
3147
+ this.applyRuntimeMetadata(this.ensureConversation(threadId), current);
3148
+ this.markDirty(threadId);
3149
+ const update: JsonRecord = { threadId };
3150
+ if (model) update.model = model;
3151
+ if ("reasoningEffort" in params) update.effort = params.reasoningEffort;
3152
+ else if ("effort" in params) update.effort = params.effort;
3153
+ if ("serviceTier" in params) update.serviceTier = params.serviceTier;
3154
+ if (record(params.collaborationMode)) update.collaborationMode = clone(params.collaborationMode);
3155
+ if (Object.keys(update).length === 1) return;
3156
+ try {
3157
+ await this.sendCodexRequest("thread/settings/update", update);
3158
+ } catch (error) {
3159
+ // Codex builds before thread/settings/update still use the in-memory
3160
+ // override on the next turn. Newer builds persist and notify all clients.
3161
+ if (!threadSettingsUpdateUnavailable(error)) throw error;
3162
+ }
3163
+ }
3164
+
3165
+ private withRuntimeOverrides(threadId: string, params: JsonRecord): JsonRecord {
3166
+ const overrides = this.runtimeOverrides.get(threadId);
3167
+ if (!overrides) return params;
3168
+ const merged = { ...params };
3169
+ for (const key of ["model", "effort", "serviceTier", "collaborationMode"]) {
3170
+ if (merged[key] == null && overrides[key] != null) merged[key] = clone(overrides[key]);
3171
+ }
3172
+ return merged;
3173
+ }
3174
+
3175
+ private respondToPendingRequest(threadId: string, requestId: unknown, result: JsonRecord): JsonRecord {
3176
+ const key = requestIdKey(requestId);
3177
+ if (!key) throw new Error("The pending Codex request could not be identified");
3178
+ const conversation = this.ensureConversation(threadId);
3179
+ const pending = conversation.requests.find(request => requestIdKey(request.id) === key);
3180
+ if (!pending) throw new Error("This Codex request is no longer waiting for a response");
3181
+ this.respondToCodexRequest(pending.id as RequestId, result);
3182
+ // Keep the request visible until app-server emits serverRequest/resolved.
3183
+ // A successful socket write is not authoritative completion, and removing
3184
+ // it here makes transient reconnects strand Desktop with no prompt to retry.
3185
+ return { ok: true };
3186
+ }
3187
+
3188
+ private fileApprovalResult(threadId: string, requestId: unknown, params: JsonRecord): JsonRecord {
3189
+ const conversation = this.ensureConversation(threadId);
3190
+ const pending = conversation.requests.find(request => requestIdKey(request.id) === requestIdKey(requestId));
3191
+ if (stringValue(pending?.method) !== "item/permissions/requestApproval") {
3192
+ return { decision: params.decision };
3193
+ }
3194
+ const decision = stringValue(params.decision);
3195
+ const requested = record(record(pending!.params)?.permissions);
3196
+ return {
3197
+ permissions: decision === "accept" || decision === "acceptForSession" ? clone(requested ?? {}) : {},
3198
+ scope: decision === "acceptForSession" ? "session" : "turn",
3199
+ };
3200
+ }
3201
+
3202
+ private markDirty(threadId: string): void {
3203
+ if (!this.ownedThreadIds.has(threadId)) return;
3204
+ this.dirtyThreadIds.add(threadId);
3205
+ if (this.snapshotTimer) return;
3206
+ this.snapshotTimer = setTimeout(() => {
3207
+ this.snapshotTimer = null;
3208
+ for (const pending of [...this.dirtyThreadIds]) {
3209
+ if (this.shouldDelayInitialSnapshot(pending)) continue;
3210
+ if (this.broadcastState(pending)) this.dirtyThreadIds.delete(pending);
3211
+ }
3212
+ }, Math.max(0, this.snapshotDebounceMs));
3213
+ this.snapshotTimer.unref?.();
3214
+ }
3215
+
3216
+ private forceSnapshot(threadId: string): void {
3217
+ if (this.shouldDelayInitialSnapshot(threadId)) {
3218
+ this.dirtyThreadIds.add(threadId);
3219
+ return;
3220
+ }
3221
+ if (this.broadcastState(threadId, true)) this.dirtyThreadIds.delete(threadId);
3222
+ else this.dirtyThreadIds.add(threadId);
3223
+ }
3224
+
3225
+ private broadcastState(threadId: string, forceSnapshot = false): boolean {
3226
+ const conversation = this.conversations.get(threadId);
3227
+ if (!conversation || !this.ownedThreadIds.has(threadId)) return true;
3228
+ if (this.shouldDelayInitialSnapshot(threadId)) return false;
3229
+ // Never put the retained full projection on the wire. The page builder
3230
+ // selects a recent tail, replaces oversized fields with opaque handles,
3231
+ // and enforces a serialized-byte budget before patches or snapshots are
3232
+ // encoded into a 64 MiB IPC frame.
3233
+ const bounded = this.historyPages.recentPage(threadId, conversation).state;
3234
+ const revision = this.revisions.get(threadId) ?? 0;
3235
+ const previous = this.lastBroadcastStates.get(threadId);
3236
+ if (!forceSnapshot && previous) {
3237
+ const patches = buildDesktopStatePatches(previous, bounded);
3238
+ if (patches?.length === 0) return true;
3239
+ if (patches && this.transport.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
3240
+ conversationId: threadId,
3241
+ hostId: HOST_ID,
3242
+ version: DESKTOP_IPC_METHOD_VERSIONS.get(THREAD_STREAM_STATE_CHANGED) ?? 1,
3243
+ opencodexOwnerSource: OWNER_SOURCE,
3244
+ change: {
3245
+ type: "patches",
3246
+ baseRevision: revision,
3247
+ revision: revision + 1,
3248
+ patches,
3249
+ },
3250
+ })) {
3251
+ this.revisions.set(threadId, revision + 1);
3252
+ this.lastBroadcastStates.set(threadId, clone(bounded));
3253
+ this.log(`Published Desktop patches for ${threadId} at revision ${revision + 1}`);
3254
+ return true;
3255
+ }
3256
+ }
3257
+ if (!this.transport.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
3258
+ conversationId: threadId,
3259
+ hostId: HOST_ID,
3260
+ version: DESKTOP_IPC_METHOD_VERSIONS.get(THREAD_STREAM_STATE_CHANGED) ?? 1,
3261
+ opencodexOwnerSource: OWNER_SOURCE,
3262
+ change: {
3263
+ type: "snapshot",
3264
+ revision: revision + 1,
3265
+ conversationState: bounded,
3266
+ },
3267
+ })) return false;
3268
+ this.revisions.set(threadId, revision + 1);
3269
+ this.lastBroadcastStates.set(threadId, clone(bounded));
3270
+ this.log(`Published Desktop snapshot for ${threadId} at revision ${revision + 1}`);
3271
+ return true;
3272
+ }
3273
+
3274
+ private beginFollow(threadId: string): void {
3275
+ if (!this.transport.connected || !this.ownedThreadIds.has(threadId)) return;
3276
+ if ((this.followerClientIds.get(threadId)?.size ?? 0) > 0) return;
3277
+ const attempt = (this.followAttempts.get(threadId) ?? 0) + 1;
3278
+ if (attempt > this.followMaxAttempts) return;
3279
+ this.followAttempts.set(threadId, attempt);
3280
+ this.transport.sendBroadcast("thread-stream-following-status-requested", {
3281
+ hostId: HOST_ID,
3282
+ conversationId: threadId,
3283
+ });
3284
+ const token = `${this.now()}-${attempt}-${randomUUID()}`;
3285
+ const url = `codex://threads/${encodeURIComponent(threadId)}?opencodex-follow=${encodeURIComponent(token)}`;
3286
+ void this.openUrl(url).catch(error => {
3287
+ this.warn(`Could not ask Codex Desktop to follow ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
3288
+ });
3289
+ const previous = this.followTimers.get(threadId);
3290
+ if (previous) clearTimeout(previous);
3291
+ const timer = setTimeout(() => {
3292
+ this.followTimers.delete(threadId);
3293
+ this.beginFollow(threadId);
3294
+ }, this.followConfirmMs);
3295
+ timer.unref?.();
3296
+ this.followTimers.set(threadId, timer);
3297
+ }
3298
+
3299
+ private scheduleFollowerMountBaseline(threadId: string): void {
3300
+ const previous = this.followerBaselineTimers.get(threadId);
3301
+ if (previous) clearTimeout(previous);
3302
+ const timer = setTimeout(() => {
3303
+ this.followerBaselineTimers.delete(threadId);
3304
+ if (this.ownedThreadIds.has(threadId) && (this.followerClientIds.get(threadId)?.size ?? 0) > 0) {
3305
+ this.forceSnapshot(threadId);
3306
+ }
3307
+ }, FOLLOWER_MOUNT_BASELINE_DELAY_MS);
3308
+ timer.unref?.();
3309
+ this.followerBaselineTimers.set(threadId, timer);
3310
+ }
3311
+
3312
+ private scheduleSidebarAnnouncement(threadId: string): void {
3313
+ if (this.announcedSidebarThreadIds.has(threadId) || this.sidebarTimers.has(threadId)) return;
3314
+ const timer = setTimeout(() => {
3315
+ this.sidebarTimers.delete(threadId);
3316
+ this.announceSidebarThread(threadId);
3317
+ }, SIDEBAR_REFRESH_DELAY_MS);
3318
+ timer.unref?.();
3319
+ this.sidebarTimers.set(threadId, timer);
3320
+ }
3321
+
3322
+ private announceSidebarThread(threadId: string, replay = false): void {
3323
+ if (!this.ownedThreadIds.has(threadId)) return;
3324
+ if (!replay && this.announcedSidebarThreadIds.has(threadId)) return;
3325
+ const timer = this.sidebarTimers.get(threadId);
3326
+ if (timer) clearTimeout(timer);
3327
+ this.sidebarTimers.delete(threadId);
3328
+ if (this.transport.sendBroadcast("thread-unarchived", {
3329
+ hostId: HOST_ID,
3330
+ conversationId: threadId,
3331
+ })) {
3332
+ this.announcedSidebarThreadIds.add(threadId);
3333
+ } else {
3334
+ this.scheduleSidebarAnnouncement(threadId);
3335
+ }
3336
+ }
3337
+ }
3338
+
3339
+ function openUrlWithPlatform(url: string, platform: NodeJS.Platform): Promise<void> {
3340
+ const commands = codexDesktopOpenCommands(url, platform);
3341
+ if (commands.length === 0) return Promise.reject(new Error("Unsupported operating system"));
3342
+ return runOpenCommands(commands);
3343
+ }
3344
+
3345
+ async function runOpenCommands(commands: CodexDesktopOpenCommand[]): Promise<void> {
3346
+ let lastError: unknown = null;
3347
+ for (const command of commands) {
3348
+ try {
3349
+ await runDesktopOpenCommand(command.command, command.args);
3350
+ return;
3351
+ } catch (error) {
3352
+ lastError = error;
3353
+ }
3354
+ }
3355
+ throw lastError instanceof Error ? lastError : new Error("Could not open Codex Desktop");
3356
+ }
3357
+
3358
+ function sanitizeTurnStartParams(params: JsonRecord): JsonRecord {
3359
+ const sanitized: JsonRecord = {};
3360
+ for (const [key, value] of Object.entries(params)) {
3361
+ if (ALLOWED_TURN_START_KEYS.has(key) && value !== undefined) {
3362
+ sanitized[key] = boundInboundPatchValue(value);
3363
+ }
3364
+ }
3365
+ sanitized.input = normalizeInputEntries(sanitized.input);
3366
+ return sanitized;
3367
+ }
3368
+
3369
+ function normalizeInputEntries(value: unknown): unknown[] {
3370
+ if (!Array.isArray(value)) return [];
3371
+ return value.map(candidate => {
3372
+ const entry = record(candidate);
3373
+ if (!entry || normalizeToken(entry.type) !== "imageurl") return clone(candidate);
3374
+ const nested = record(entry.image_url) ?? record(entry.imageUrl);
3375
+ const url = stringValue(entry.url)
3376
+ || stringValue(nested?.url)
3377
+ || stringValue(entry.image_url)
3378
+ || stringValue(entry.imageUrl);
3379
+ return url ? { type: "image", url } : clone(candidate);
3380
+ });
3381
+ }
3382
+
3383
+ function threadIdFromMessage(method: string, params: JsonRecord): string {
3384
+ if (method === "thread/started") return stringValue(record(params.thread)?.id);
3385
+ return threadIdFromParams(params);
3386
+ }
3387
+
3388
+ function createConversationTurn(
3389
+ rawTurn: JsonRecord,
3390
+ conversation: ConversationState,
3391
+ now: () => number,
3392
+ suppliedParams?: JsonRecord,
3393
+ ): ConversationTurn {
3394
+ const turnId = stringValue(rawTurn.id) || stringValue(rawTurn.turnId) || stringValue(rawTurn.turn_id);
3395
+ const params = suppliedParams ? sanitizeTurnStartParams(suppliedParams) : defaultTurnParams(conversation);
3396
+ const rawItems = Array.isArray(rawTurn.items)
3397
+ ? rawTurn.items.slice(-INBOUND_HISTORY_MAX_ITEMS_PER_TURN)
3398
+ : [];
3399
+ const items: JsonRecord[] = [];
3400
+ let adoptedPrompt = Array.isArray(params.input) && params.input.length > 0;
3401
+ for (const candidate of rawItems) {
3402
+ const item = sanitizeDesktopItem(record(candidate));
3403
+ if (!item) continue;
3404
+ if (item.type === "userMessage" && !adoptedPrompt) {
3405
+ const prompt = userMessageInput(item);
3406
+ if (prompt.length > 0) {
3407
+ params.input = prompt;
3408
+ adoptedPrompt = true;
3409
+ continue;
3410
+ }
3411
+ }
3412
+ if (item.type === "userMessage" && sameVisibleInput(params.input, userMessageInput(item)) && !items.some(row => row.type === "userMessage")) {
3413
+ continue;
3414
+ }
3415
+ items.push(item);
3416
+ }
3417
+ return {
3418
+ id: turnId,
3419
+ turnId,
3420
+ params,
3421
+ turnStartedAtMs: timestampMs(rawTurn.startedAt, now()),
3422
+ durationMs: rawTurn.durationMs ?? null,
3423
+ firstTurnWorkItemStartedAtMs: null,
3424
+ finalAssistantStartedAtMs: null,
3425
+ status: rawTurn.status ?? "inProgress",
3426
+ error: boundInboundPatchValue(rawTurn.error ?? null),
3427
+ diff: typeof rawTurn.diff === "string"
3428
+ ? (() => {
3429
+ const bounded = boundInboundPatchValue(rawTurn.diff, new WeakSet(), 0, true);
3430
+ return typeof bounded === "string" ? bounded : null;
3431
+ })()
3432
+ : null,
3433
+ hookRuns: [],
3434
+ commandExecutionStartedAtMsById: {},
3435
+ items,
3436
+ };
3437
+ }
3438
+
3439
+ function defaultTurnParams(conversation: ConversationState): JsonRecord {
3440
+ return {
3441
+ threadId: conversation.id,
3442
+ input: [],
3443
+ cwd: conversation.cwd || null,
3444
+ approvalPolicy: null,
3445
+ approvalsReviewer: null,
3446
+ sandboxPolicy: null,
3447
+ model: conversation.latestModel || null,
3448
+ serviceTier: conversation.latestServiceTier ?? null,
3449
+ effort: conversation.latestReasoningEffort ?? null,
3450
+ summary: "none",
3451
+ personality: null,
3452
+ outputSchema: null,
3453
+ collaborationMode: clone(conversation.latestCollaborationMode ?? null),
3454
+ };
3455
+ }
3456
+
3457
+ function sanitizeDesktopItem(item: JsonRecord | null): JsonRecord | null {
3458
+ if (!item) return null;
3459
+ const type = stringValue(item.type);
3460
+ if (!type || !stringValue(item.id)) return null;
3461
+ if (type === "userMessage" && isInjectedContextUserItem(item)) return null;
3462
+ const safe: JsonRecord = {};
3463
+ let entries = 0;
3464
+ for (const key in item) {
3465
+ if (!Object.prototype.hasOwnProperty.call(item, key)) continue;
3466
+ entries += 1;
3467
+ if (entries > INBOUND_PATCH_MAX_ENTRIES || Buffer.byteLength(key, "utf8") > 512) break;
3468
+ safe[key] = boundInboundPatchValue(item[key], new WeakSet(), 0, true);
3469
+ }
3470
+ // Keep the identity fields authoritative even if an untrusted input object
3471
+ // put them after a truncated collection of metadata keys.
3472
+ safe.type = type;
3473
+ safe.id = stringValue(item.id);
3474
+ if (type === "reasoning") safe.content = [];
3475
+ return safe;
3476
+ }
3477
+
3478
+ function isInjectedContextUserItem(item: JsonRecord): boolean {
3479
+ const text = visibleUserText(item).trimStart();
3480
+ return text.startsWith("<environment_context>")
3481
+ || text.startsWith("<permissions instructions>")
3482
+ || text.startsWith("<skills_instructions>")
3483
+ || text.startsWith("<app-context>")
3484
+ || text.startsWith("<INSTRUCTIONS>");
3485
+ }
3486
+
3487
+ function userMessageInput(item: JsonRecord): unknown[] {
3488
+ const content = Array.isArray(item.content) ? item.content : [];
3489
+ if (content.length > 0) return normalizeInputEntries(content);
3490
+ const text = visibleUserText(item);
3491
+ return text ? [{ type: "text", text }] : [];
3492
+ }
3493
+
3494
+ function visibleUserText(item: JsonRecord): string {
3495
+ if (typeof item.text === "string") return item.text;
3496
+ if (typeof item.message === "string") return item.message;
3497
+ if (!Array.isArray(item.content)) return "";
3498
+ return item.content.flatMap(candidate => {
3499
+ const row = record(candidate);
3500
+ const text = row && typeof row.text === "string" ? row.text : "";
3501
+ return text ? [text] : [];
3502
+ }).join("\n");
3503
+ }
3504
+
3505
+ function visibleInputText(value: unknown): string {
3506
+ if (!Array.isArray(value)) return "";
3507
+ return value.flatMap(candidate => {
3508
+ const row = record(candidate);
3509
+ return row && typeof row.text === "string" ? [row.text.trim()] : [];
3510
+ }).filter(Boolean).join("\n");
3511
+ }
3512
+
3513
+ function sameVisibleInput(left: unknown, right: unknown): boolean {
3514
+ const a = canonicalUserMessageText(visibleInputText(left));
3515
+ const b = canonicalUserMessageText(visibleInputText(right));
3516
+ return Boolean(a && b && a === b);
3517
+ }
3518
+
3519
+ function mergeTurnContinuity(previous: ConversationTurn, current: ConversationTurn): ConversationTurn {
3520
+ const merged = {
3521
+ ...current,
3522
+ params: Array.isArray(previous.params.input) && previous.params.input.length > 0 ? clone(previous.params) : current.params,
3523
+ turnStartedAtMs: previous.turnStartedAtMs ?? current.turnStartedAtMs,
3524
+ firstTurnWorkItemStartedAtMs: previous.firstTurnWorkItemStartedAtMs ?? current.firstTurnWorkItemStartedAtMs,
3525
+ finalAssistantStartedAtMs: previous.finalAssistantStartedAtMs ?? current.finalAssistantStartedAtMs,
3526
+ diff: current.diff ?? previous.diff,
3527
+ hookRuns: Array.isArray(current.hookRuns) && current.hookRuns.length > 0 ? current.hookRuns : clone(previous.hookRuns),
3528
+ commandExecutionStartedAtMsById: {
3529
+ ...(record(previous.commandExecutionStartedAtMsById) ?? {}),
3530
+ ...(record(current.commandExecutionStartedAtMsById) ?? {}),
3531
+ },
3532
+ items: mergeItems(previous.items, current.items),
3533
+ };
3534
+ return {
3535
+ ...merged,
3536
+ items: removeDuplicateInitialUserMessage(merged, merged.items),
3537
+ };
3538
+ }
3539
+
3540
+ function userMessageDuplicatesTurnInput(turn: ConversationTurn, item: JsonRecord): boolean {
3541
+ if (item.type !== "userMessage" || !Array.isArray(turn.params.input) || turn.params.input.length === 0) {
3542
+ return false;
3543
+ }
3544
+ const clientUserMessageId = stringValue(turn.params.clientUserMessageId);
3545
+ const itemClientId = stringValue(item.clientId) || stringValue(item.client_id);
3546
+ if (clientUserMessageId && itemClientId && clientUserMessageId === itemClientId) return true;
3547
+ return sameVisibleInput(turn.params.input, userMessageInput(item));
3548
+ }
3549
+
3550
+ function isInitialTurnPrefixItem(item: JsonRecord): boolean {
3551
+ return item.type === "automaticApprovalReview"
3552
+ || item.type === "forkedFromConversation"
3553
+ || item.type === "modelChanged"
3554
+ || item.type === "modelRerouted"
3555
+ || item.type === "personalityChanged"
3556
+ || item.type === "remoteTaskCreated"
3557
+ || item.type === "worktreeInit";
3558
+ }
3559
+
3560
+ function removeDuplicateInitialUserMessage(turn: ConversationTurn, items: JsonRecord[]): JsonRecord[] {
3561
+ let canMatchInitialInput = true;
3562
+ let removedInitialInput = false;
3563
+ return items.filter(item => {
3564
+ if (
3565
+ canMatchInitialInput
3566
+ && !removedInitialInput
3567
+ && userMessageDuplicatesTurnInput(turn, item)
3568
+ ) {
3569
+ removedInitialInput = true;
3570
+ canMatchInitialInput = false;
3571
+ return false;
3572
+ }
3573
+ if (!isInitialTurnPrefixItem(item)) canMatchInitialInput = false;
3574
+ return true;
3575
+ });
3576
+ }
3577
+
3578
+ function mergeItems(previous: JsonRecord[], current: JsonRecord[]): JsonRecord[] {
3579
+ const next = previous.map(clone);
3580
+ for (const item of current) upsertById(next, item);
3581
+ return next;
3582
+ }
3583
+
3584
+ function findTurn(conversation: ConversationState, turnId: string): ConversationTurn | null {
3585
+ return conversation.turns.find(turn => turn.turnId === turnId || turn.id === turnId) ?? null;
3586
+ }
3587
+
3588
+ function replaceTurn(conversation: ConversationState, oldId: string, turn: ConversationTurn): void {
3589
+ const matchingIndexes: number[] = [];
3590
+ let merged = turn;
3591
+ conversation.turns.forEach((candidate, index) => {
3592
+ if (candidate.turnId !== oldId && candidate.turnId !== turn.turnId) return;
3593
+ matchingIndexes.push(index);
3594
+ merged = mergeTurnContinuity(candidate, merged);
3595
+ });
3596
+ if (matchingIndexes.length === 0) {
3597
+ conversation.turns.push(merged);
3598
+ return;
3599
+ }
3600
+ const insertionIndex = matchingIndexes[0]!;
3601
+ conversation.turns = conversation.turns.filter((_, index) => !matchingIndexes.includes(index));
3602
+ conversation.turns.splice(insertionIndex, 0, merged);
3603
+ }
3604
+
3605
+ function upsertTurnItem(turn: ConversationTurn, item: JsonRecord): void {
3606
+ upsertById(turn.items, item);
3607
+ }
3608
+
3609
+ function upsertById(rows: JsonRecord[], value: JsonRecord): void {
3610
+ const key = requestIdKey(value.id);
3611
+ if (!key) return;
3612
+ const index = rows.findIndex(candidate => requestIdKey(candidate.id) === key);
3613
+ if (index >= 0) rows[index] = { ...rows[index], ...clone(value) };
3614
+ else rows.push(clone(value));
3615
+ }
3616
+
3617
+ function ensureItem(turn: ConversationTurn, itemId: string, create: () => JsonRecord): JsonRecord {
3618
+ let item = turn.items.find(candidate => stringValue(candidate.id) === itemId);
3619
+ if (!item) {
3620
+ item = create();
3621
+ turn.items.push(item);
3622
+ }
3623
+ return item;
3624
+ }
3625
+
3626
+ function defaultDeltaItem(type: string, id: string, cwd: string): JsonRecord {
3627
+ if (type === "agentMessage") return { type, id, text: "", phase: null, memoryCitation: null };
3628
+ if (type === "plan") return { type, id, text: "" };
3629
+ if (type === "commandExecution") return {
3630
+ type,
3631
+ id,
3632
+ command: "",
3633
+ cwd: cwd || "/",
3634
+ processId: null,
3635
+ source: "exec",
3636
+ status: "inProgress",
3637
+ commandActions: [],
3638
+ aggregatedOutput: "",
3639
+ exitCode: null,
3640
+ durationMs: null,
3641
+ };
3642
+ return { type, id };
3643
+ }