agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
@@ -0,0 +1,796 @@
1
+ // The transport to `codex app-server`: one child process, many work items, and
2
+ // an approval that always gets an answer or takes the connection down trying.
3
+ //
4
+ // THIS IS THE PART THAT WAS NEVER SOLVED.The removed build drove `codex exec`,
5
+ // a one-shot run over a single JSON stream, and `codex exec` has no channel
6
+ // back to the caller. A Claude Code worker that wants something outside its
7
+ // grants raises a card through main/approval-prompt-server.mjs and waits in
8
+ // place; a Codex worker could not raise anything, so it either ran ungated or
9
+ // it stopped. That was the blocker, and `git show 258d71d^:main/codex.mjs` is
10
+ // worth reading for what else was tried, but ITS EVENT PARSING IS OBSOLETE and
11
+ // none of it is carried over here.
12
+ //
13
+ // `codex app-server` is the fix, verified by running it rather than by reading
14
+ // about it. It speaks newline-delimited JSON-RPC 2.0 over the child's stdio,
15
+ // and when a turn wants to run a gated command the SERVER sends US a request,
16
+ // with an id, that we must answer. That is the missing channel, and it is the
17
+ // same shape the approvals spool already has: a question, a wait, an answer.
18
+ //
19
+ // AND IT MOVES THE FAILURE SOMEWHERE NEW, WHICH IS WHAT THIS FILE IS ABOUT. A
20
+ // request with an id that nobody ever answers is not an error. It is a worker
21
+ // parked forever, with no exit code, no message and nothing on the row. CLAUDE.md
22
+ // already has that incident under another name -- "A WORKER THAT DID NOT FINISH
23
+ // DELIVERED NOTHING, HOWEVER LONG IT TOOK" -- three items stranded for two, two
24
+ // and six hours. So the posture here is copied from
25
+ // main/approval-prompt-server.mjs and not re-argued:
26
+ //
27
+ // DENY IS THE DEFAULT IN EVERY FAILURE MODE, AND SILENCE IS NOT A REFUSAL.
28
+ //
29
+ // That sentence has three consequences, and each one cost a draft of this file.
30
+ //
31
+ // A CARD SHE NEVER ANSWERS DENIES ITSELF, AFTER FIFTEEN MINUTES. The first
32
+ // draft waited forever, on the theory that the caller owns the timeout policy.
33
+ // It does not: main/approval-prompt-server.mjs is the same channel for the same
34
+ // founder and it has `TIMEOUT_MS = 15 * 60_000` and denies on expiry. Waiting
35
+ // forever is not neutrality, it is a worker parked for the rest of the session
36
+ // because she went to lunch. The deadline is injectable so that tests do not
37
+ // sleep, and a late answer arriving after it has already denied is DROPPED --
38
+ // the turn was told no and has moved on, and a second answer to one request id
39
+ // is a protocol violation on top of an approval nobody is waiting for.
40
+ //
41
+ // A REFUSAL THAT CANNOT BE WRITTEN IS SILENCE. So a write that throws, a write
42
+ // that fails afterwards in its callback, and an error raised on the pipe itself
43
+ // are all treated as a broken connection: settle everything, tell every thread,
44
+ // take the child down. There is no way to answer a request over a dead pipe,
45
+ // and a supervisor can see a dead worker. It cannot see a hung one.
46
+ //
47
+ // A FRAME WE CANNOT READ MIGHT HAVE BEEN A CARD. stdout is the protocol here --
48
+ // the child's tracing goes to stderr, measured, and the live run read zero
49
+ // unreadable lines. A line on stdout that will not parse is therefore not noise
50
+ // to step over: it is a frame whose id has just been lost, and if it was an
51
+ // approval request then the turn that raised it waits forever. Nothing can tell
52
+ // whether it was, which is exactly why tolerating it is unsafe. Unreadable or
53
+ // unbounded input is fatal, loudly. Blank lines are framing rather than content
54
+ // and are the one thing here that may be skipped.
55
+ //
56
+ // ------------------------------------------------------------------------
57
+ // WHAT WAS MEASURED, ON THIS MAC, 2026-09-04, codex-cli 0.148.0. Some of this
58
+ // contradicts what was written down before; where it does, the machine wins.
59
+ //
60
+ // THE THREAD ID IS AT `result.thread.id`. Off a live `thread/start` response:
61
+ // `result.thread.id` was "01a068f1-be4d-7701-98e3-16137d16dcdf" and
62
+ // `result.threadId` was `undefined`. The obvious guess fails silently -- every
63
+ // thread ends up filed under the id `undefined`, which is one shared bucket --
64
+ // so it is asserted rather than trusted.
65
+ //
66
+ // SERVER REQUEST IDS START AT 0 AND ARE A SEPARATE NUMBER SPACE FROM OURS. In
67
+ // the captured session the first approval carried `id: 0` while our own
68
+ // `initialize` carried `id: 1`. A classifier written `if (msg.id)` drops the
69
+ // first approval of every session, because 0 is falsy. Everything below tests
70
+ // `!== undefined`, and the two spaces never share a map.
71
+ //
72
+ // A REFUSAL IS ACCEPTED EVEN WHEN THE SERVER DID NOT OFFER IT. The live
73
+ // approval arrived with `availableDecisions: ["accept",
74
+ // {acceptWithExecpolicyAmendment: ...}, "cancel"]` -- no "decline" in the list.
75
+ // Answering `{decision: 'decline'}` anyway worked: the item completed with
76
+ // `status: "declined"`, `serverRequest/resolved` fired, and the turn COMPLETED
77
+ // 4.0 seconds later. `availableDecisions` is a hint about which buttons a UI
78
+ // should draw, not a whitelist the answer is validated against. That is the
79
+ // measurement that makes an unconditional refusal safe, and without it the
80
+ // deny-by-default rule above would have been a guess.
81
+ //
82
+ // A JSON-RPC ERROR FRAME REFUSES TOO, MEASURED ON ONE METHOD. The same
83
+ // `item/commandExecution/requestApproval` answered with `{error: {code: -32001,
84
+ // message}}` left the item at `status: "failed"` and the turn still COMPLETED,
85
+ // 5.5 seconds later. BE PRECISE ABOUT THE SCOPE OF THAT: it was measured for
86
+ // command approval and for nothing else, so using it as the refusal for other
87
+ // server requests is a REASONED EXTENSION rather than a measured fact. It is
88
+ // the best one available -- a well-formed error is the only answer that is
89
+ // valid for a result shape nobody has measured -- but the next slice to lean on
90
+ // it for a specific method should measure that method.
91
+ //
92
+ // NO HEAD-OF-LINE BLOCKING. Proven separately: an approval on thread A that is
93
+ // deliberately never answered does not stop thread B from raising its own
94
+ // approval, having it answered, and completing its turn. That is what makes one
95
+ // process for the whole fleet safe, and it is a promise this file has to keep --
96
+ // hence the read loop below never awaits anything.
97
+ //
98
+ // `ephemeral: false` IS THE DEFAULT AND HAS TO BE. `thread/resume` fails with
99
+ // "no rollout found for thread id" on an ephemeral thread, so a thread started
100
+ // ephemerally cannot carry a continuation. the app's whole reply path is
101
+ // continuations, so the default is the one that can come back.
102
+ //
103
+ // TWO THINGS THE WRITTEN BRIEF HAD WRONG, both from the CLI's own
104
+ // `codex app-server generate-json-schema` dump:
105
+ //
106
+ // `abort` IS NOT A v2 APPROVAL DECISION. CommandExecutionApprovalDecision and
107
+ // FileChangeApprovalDecision are accept / acceptForSession / decline / cancel,
108
+ // plus two amendment OBJECTS (`acceptWithExecpolicyAmendment` and
109
+ // `applyNetworkPolicyAmendment`, command approvals only). `abort` belongs to
110
+ // the LEGACY `ReviewDecision` used by `execCommandApproval` and
111
+ // `applyPatchApproval`. Sending it to a v2 approval is a malformed answer, so
112
+ // it is treated as junk here.
113
+ //
114
+ // `item/permissions/requestApproval` TAKES NO `decision` AT ALL. Its response
115
+ // is PermissionsRequestApprovalResponse: `{ permissions, scope?,
116
+ // strictAutoReview? }`, a granted profile rather than a word.
117
+ //
118
+ // AND THERE ARE TEN SERVER-TO-CLIENT REQUEST METHODS IN THE STABLE SCHEMA, NOT
119
+ // THREE. Beyond the three approvals: item/tool/requestUserInput, item/tool/call,
120
+ // mcpServer/elicitation/request, account/chatgptAuthTokens/refresh,
121
+ // attestation/generate, and the legacy applyPatchApproval and
122
+ // execCommandApproval, both of which are still in the stable dump. The
123
+ // experimental schema adds more (currentTime/read), so "ten" is a fact about
124
+ // the stable dump on 0.148.0 and not a ceiling. Every one of them hangs
125
+ // whatever raised it if it is left open, so ANY server request this file does
126
+ // not know how to answer gets the error frame. That is the honest answer and
127
+ // not a placeholder: refusing `account/chatgptAuthTokens/refresh` may well cost
128
+ // a long-lived process its token refresh, and the slice that wants that
129
+ // capability is the slice that gets to measure the shape and answer it
130
+ // properly. A refusal is a bug somebody can see; silence is not.
131
+ //
132
+ // ------------------------------------------------------------------------
133
+ // SCOPE. Transport and routing, nothing else. Nothing here turns an event into
134
+ // Agentbox's work lines or its trace, nothing here touches the approvals spool,
135
+ // and nothing here decides which engine a row runs on.
136
+ //
137
+ // IT IS NO LONGER INERT, AND THIS HEADER SAID IT WAS FOR A WHOLE SLICE AFTER IT
138
+ // HAD STOPPED BEING ONE. Three files depend on it now: main/supervisor.mjs spawns the
139
+ // process and holds the client, main/codex-session.mjs is the per-work-item
140
+ // facade over one thread of it, and main/codex-approvals.mjs is the handler on
141
+ // the other end of `onApproval` that this file was written around. The three
142
+ // sentences above still hold; the fourth was a fact about a Tuesday.
143
+ //
144
+ // The transport is injected so this whole file can be tested without `codex`
145
+ // being installed. That is not tidiness. Four tests in this suite already fail
146
+ // on any machine whose clock or installs differ from hers, and a transport test
147
+ // that shelled out to the real binary would be the fifth.
148
+
149
+ import { StringDecoder } from 'node:string_decoder';
150
+ import { NAME, Name, nameSlug } from '../shared/product-name.mjs';
151
+
152
+ /**
153
+ * How this client names itself to the server. It comes back on `initialize` as
154
+ * part of `userAgent` -- measured live as "astral/0.148.0 (Mac OS 15.6.1;
155
+ * arm64) (astral; 1.0.0)", which is the app's name of the day rather than a
156
+ * constant -- and it is what appears in Codex's own session records, so it says
157
+ * this app rather than the name of whatever spike wrote it. READ, NEVER TYPED:
158
+ * that measured line is why, it is a record written months ago under a name the
159
+ * app no longer has.
160
+ */
161
+ export const CLIENT_INFO = Object.freeze({ name: nameSlug, title: NAME, version: '1.0.0' });
162
+
163
+ /**
164
+ * The four SCALAR answers this callback supports, read off the CLI's schema
165
+ * dump rather than remembered.
166
+ *
167
+ * Not the whole v2 vocabulary: command approvals also accept two structured
168
+ * amendment objects, which widen the execpolicy or pin a network rule for
169
+ * future commands. Those are standing policy changes rather than an answer to
170
+ * the question that was asked, they cannot be expressed as a word, and nothing
171
+ * in Agentbox can produce one yet -- so a handler returning one is refused. That
172
+ * is conservative in the safe direction, and it is a deliberate omission rather
173
+ * than an oversight.
174
+ *
175
+ * `abort` is NOT here and reads like it should be. It is a legacy word.
176
+ */
177
+ export const APPROVAL_DECISIONS = Object.freeze(['accept', 'acceptForSession', 'decline', 'cancel']);
178
+
179
+ /**
180
+ * The three words an MCP elicitation takes, which is a SHORTER list than the
181
+ * one above and not a subset by accident: `McpServerElicitationRequestResponse`
182
+ * has no `acceptForSession`, so an approval that lasts a session cannot be
183
+ * expressed here at all. Read off the CLI's schema dump 2026-09-22.
184
+ */
185
+ export const ELICITATION_ACTIONS = Object.freeze(['accept', 'decline', 'cancel']);
186
+
187
+ /**
188
+ * The refusal. `decline` and not `cancel`, deliberately: measured, `decline`
189
+ * leaves the item `declined` and lets the agent carry on and try something
190
+ * else, while `cancel` also interrupts the whole turn. A failure to reach the
191
+ * founder should cost the command, not the work item.
192
+ */
193
+ export const REFUSAL_DECISION = 'decline';
194
+
195
+ /** Her deadline, matching main/approval-prompt-server.mjs exactly. */
196
+ export const APPROVAL_TIMEOUT_MS = 15 * 60_000;
197
+
198
+ /**
199
+ * How long ONE FRAME may be before the connection is called corrupt. Generous,
200
+ * because `turn/diff/updated` carries a git-format diff of the whole turn and a
201
+ * big refactor is a big diff; bounded, because an unterminated line is
202
+ * otherwise a memory leak that ends in a dead app.
203
+ *
204
+ * IT APPLIES TO A TERMINATED LINE AS WELL AS TO A RUNAWAY ONE, and it did not.
205
+ * The check ran on the residual buffer AFTER every newline-delimited frame had
206
+ * been taken out of it, so it could only ever see a line with no end. A single
207
+ * complete ten-megabyte line -- which is precisely what an enormous
208
+ * `turn/diff/updated` is -- went straight past the bound to `JSON.parse` and on
209
+ * to the handlers. Both halves are checked now: `read` refuses an oversized
210
+ * line before it parses it, and the residual check still catches the one that
211
+ * never terminates.
212
+ */
213
+ export const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
214
+
215
+ /**
216
+ * HOW LONG AN ORDINARY REQUEST MAY GO UNANSWERED.
217
+ *
218
+ * The approval had a deadline from the first draft of this file, for the reason
219
+ * written at the top: a request with an id that nobody answers is a worker
220
+ * parked forever with no exit code and nothing on the row. EVERY REQUEST HAS
221
+ * THAT SHAPE, and the ones that get a worker as far as raising an approval --
222
+ * `initialize`, `config/read`, `thread/start`, `thread/resume`, `turn/start` --
223
+ * had nothing to settle them at all. A live app-server that accepts the bytes
224
+ * and never answers holds a work item and a fleet slot for the rest of the
225
+ * session; the pipe is not broken, so nothing else here fires either.
226
+ *
227
+ * Two minutes, which is far above anything measured (a handshake and a
228
+ * `config/read` answer in milliseconds, and `turn/start` resolves when the turn
229
+ * is ACCEPTED rather than when it finishes) and far below a founder noticing a
230
+ * row is stuck. The turn itself is not on this clock; only the request that
231
+ * starts it is.
232
+ *
233
+ * ONE REQUEST GOES, NOT THE CONNECTION. A rejection frees the row and the slot,
234
+ * which is the whole failure being closed, and the worker's own chain turns it
235
+ * into a run that says why on stderr. Taking the shared process down over one
236
+ * slow answer would charge every other thread for it.
237
+ */
238
+ export const REQUEST_TIMEOUT_MS = 2 * 60_000;
239
+
240
+ /**
241
+ * The server requests we know how to answer, and in which of the three shapes.
242
+ *
243
+ * `mcpServer/elicitation/request` IS AN APPROVAL AND NOT ONLY A FORM, which is
244
+ * the correction this table carries. It is how Codex asks whether an MCP TOOL
245
+ * CALL may run. Measured on this Mac 2026-09-22 against codex-cli 0.153.4, a
246
+ * thread started exactly as `workerThreadParams` starts one, with one stdio MCP
247
+ * server on it and the model asked to call its single tool:
248
+ *
249
+ * item/started mcpToolCall server=probe tool=ping status=inProgress
250
+ * REQUEST mcpServer/elicitation/request
251
+ * { serverName: "probe", mode: "form",
252
+ * message: 'Allow the probe MCP server to run tool "ping"?',
253
+ * _meta: { codex_approval_kind: "mcp_tool_call",
254
+ * persist: ["session","always"],
255
+ * tool_description: "...", tool_params: {} } }
256
+ *
257
+ * The method was not on this table, so it took the error frame below, and the
258
+ * measured consequence was the whole integration rather than one lost call:
259
+ *
260
+ * answered with the error frame -> item/completed mcpToolCall status=failed,
261
+ * error "user rejected MCP tool call"
262
+ * answered `{action:"accept"}` -> the tool ran and returned its result
263
+ *
264
+ * EVERY STORE TOOL A CODEX WORKER HAS IS AN MCP TOOL CALL, so the first row is
265
+ * a worker that cannot claim, checkpoint or finish anything, and the only words
266
+ * anybody sees are that sentence: a rejection nobody made, over a card that was
267
+ * never drawn. `McpServerElicitationRequestResponse` is `{ action: "accept" |
268
+ * "decline" | "cancel", content?, _meta? }`, which is a third answer shape, and
269
+ * its vocabulary happens to be three of the words the decision shape speaks.
270
+ */
271
+ const APPROVAL_SHAPE = new Map([
272
+ ['item/commandExecution/requestApproval', 'decision'],
273
+ ['item/fileChange/requestApproval', 'decision'],
274
+ ['item/permissions/requestApproval', 'grant'],
275
+ ['mcpServer/elicitation/request', 'elicitation'],
276
+ ]);
277
+
278
+ const REFUSAL_ERROR = Object.freeze({
279
+ code: -32001,
280
+ message: `${Name} did not approve this request. Continue with what you are already allowed to do, and leave the rest for the founder to look at.`,
281
+ });
282
+
283
+ const plain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
284
+ const only = (keys, allowed) => keys.every((k) => allowed.includes(k));
285
+
286
+ /**
287
+ * One long-lived `codex app-server`.
288
+ *
289
+ * Give it either a `transport` (anything child-process-shaped: `stdin.write`,
290
+ * `stdin.end`, `stdout.on('data')`, an optional `stderr`, `on('exit')`,
291
+ * `on('error')`, `kill`) or a `spawn` function that returns one. Production
292
+ * passes a real child; the tests pass a notepad.
293
+ *
294
+ * `onNotification` catches every line that belongs to no watched thread: a
295
+ * thread we have not been told about yet, and the notifications that carry no
296
+ * thread at all. There are eighteen of those in the 0.148.0 stable schema --
297
+ * account, config, process, filesystem and remote-control news, among them
298
+ * `account/rateLimits/updated` and `remoteControl/status/changed` -- so this is
299
+ * an ordinary channel rather than an error path.
300
+ */
301
+ export function createCodexAppServer({
302
+ transport = null,
303
+ spawn = null,
304
+ clientInfo = CLIENT_INFO,
305
+ onNotification = null,
306
+ approvalTimeoutMs = APPROVAL_TIMEOUT_MS,
307
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
308
+ maxFrameLength = MAX_FRAME_LENGTH,
309
+ stderrLimit = 64 * 1024,
310
+ } = {}) {
311
+ const child = transport ?? spawn?.();
312
+ if (!child) throw new Error('createCodexAppServer needs a transport or a spawn that makes one');
313
+
314
+ const pending = new Map(); // OUR request id -> { resolve, reject, method, onResult }
315
+ const threads = new Map(); // thread id -> handlers
316
+ const openCards = new Map(); // THEIR request id -> the deadline timer
317
+ const decoder = new StringDecoder('utf8');
318
+ let nextId = 1;
319
+ let buffer = '';
320
+ let stderrText = '';
321
+ let gone = null; // null while live; the closing reason once not
322
+ let drained = false; // stdout has ended, so the diagnostics are complete
323
+ let finished = false;
324
+
325
+ /* ------------------------------- writing -------------------------------- */
326
+
327
+ /**
328
+ * Put one message on the wire, or take the connection down.
329
+ *
330
+ * Three ways a real pipe fails and all three end here: it throws where it
331
+ * stands, it accepts the bytes and reports the failure in the write callback,
332
+ * or it raises `error` later. The callback is the one that is easy to miss
333
+ * and the one that matters most, because a refusal that was "written" and
334
+ * never arrived is indistinguishable from an approval.
335
+ *
336
+ * A `false` return is NOT a failure: that is ordinary backpressure and the
337
+ * bytes are still queued. Only the callback and the throw are failures.
338
+ */
339
+ function write(msg) {
340
+ if (gone) return false;
341
+ let text;
342
+ try {
343
+ text = JSON.stringify(msg) + '\n';
344
+ } catch (err) {
345
+ fail('write', { error: err });
346
+ return false;
347
+ }
348
+ try {
349
+ child.stdin.write(text, (err) => { if (err) fail('write', { error: err }); });
350
+ } catch (err) {
351
+ fail('write', { error: err });
352
+ return false;
353
+ }
354
+ return !gone;
355
+ }
356
+
357
+ function send(method, params, onResult) {
358
+ if (gone) return Promise.reject(new Error(`${method}: codex app-server is no longer running (${gone.reason})`));
359
+ const id = nextId++;
360
+ return new Promise((resolve, reject) => {
361
+ // The deadline is armed BEFORE the write, so a request that is accepted
362
+ // and then never answered cannot outlive it. See REQUEST_TIMEOUT_MS.
363
+ const timer = setTimeout(() => {
364
+ if (!pending.delete(id)) return;
365
+ reject(new Error(`${method}: codex app-server accepted this request and never answered it (${Math.round(requestTimeoutMs / 1000)}s)`));
366
+ }, requestTimeoutMs);
367
+ timer?.unref?.();
368
+ pending.set(id, { resolve, reject, method, onResult, timer });
369
+ if (!write({ jsonrpc: '2.0', id, method, params })) {
370
+ clearTimeout(timer);
371
+ pending.delete(id);
372
+ reject(new Error(`${method}: ${cause()}`));
373
+ }
374
+ });
375
+ }
376
+
377
+ const notify = (method) => write({ jsonrpc: '2.0', method });
378
+
379
+ /* ------------------------------- reading -------------------------------- */
380
+
381
+ // The decoder rather than `chunk.toString`: a chunk boundary can fall in the
382
+ // middle of a UTF-8 sequence, and agent messages stream through here one
383
+ // delta at a time in whatever language the repo is written in.
384
+ child.stdout.on('data', (chunk) => {
385
+ if (gone) return;
386
+ buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
387
+ let at;
388
+ while (!gone && (at = buffer.indexOf('\n')) >= 0) {
389
+ const line = buffer.slice(0, at);
390
+ buffer = buffer.slice(at + 1);
391
+ read(line);
392
+ }
393
+ if (!gone && buffer.length > maxFrameLength) {
394
+ fail('protocol', { note: `a frame passed ${maxFrameLength} characters with no end to it` });
395
+ }
396
+ });
397
+
398
+ // The child writes tracing here, and the last line of it is usually the
399
+ // reason something failed -- that is where "Rejected" showed up in the
400
+ // measurements above. Worth keeping for a cause report, and NEVER worth
401
+ // parsing: nothing on this pipe is protocol, however badly formed it looks,
402
+ // so garbage here must not cost the founder a fleet. Only the tail is kept,
403
+ // because a long run's tracing is unbounded and the useful part is always at
404
+ // the end.
405
+ child.stderr?.on('data', (chunk) => {
406
+ stderrText += typeof chunk === 'string' ? chunk : chunk.toString();
407
+ if (stderrText.length > stderrLimit) stderrText = stderrText.slice(-stderrLimit);
408
+ });
409
+
410
+ child.on('error', (err) => fail('error', { error: err }));
411
+ child.on('exit', (code, signal) => fail('exit', { code: code ?? null, signal: signal ?? null }));
412
+ child.stdin?.on?.('error', (err) => fail('error', { error: err }));
413
+ child.stdout.on?.('error', (err) => fail('error', { error: err }));
414
+ child.stderr?.on?.('error', () => { /* tracing, not protocol */ });
415
+ for (const event of ['end', 'close']) child.stdout.on?.(event, () => { drained = true; if (gone) finish(); });
416
+
417
+ function read(line) {
418
+ // THE BOUND APPLIES TO A LINE THAT ENDED, TOO. The residual check below the
419
+ // split can only ever see a frame with no newline in it, so a single
420
+ // complete frame of any size walked past it into `JSON.parse`. See
421
+ // MAX_FRAME_LENGTH.
422
+ if (line.length > maxFrameLength) {
423
+ return fail('protocol', { note: `a frame of ${line.length} characters passed the ${maxFrameLength} this connection allows` });
424
+ }
425
+ const text = line.trim();
426
+ if (!text) return; // framing, not content
427
+
428
+ let msg;
429
+ try {
430
+ msg = JSON.parse(text);
431
+ } catch {
432
+ return fail('protocol', { note: 'a line on stdout could not be read as JSON, and it may have been an approval request' });
433
+ }
434
+ if (!plain(msg)) {
435
+ return fail('protocol', { note: 'a line on stdout could not be read as a message, and it may have been an approval request' });
436
+ }
437
+
438
+ // Three kinds, and the test is `!== undefined` in both places because the
439
+ // server's ids start at zero.
440
+ if (msg.id !== undefined && msg.method !== undefined) return serverRequest(msg);
441
+ if (msg.id !== undefined) return response(msg);
442
+ if (typeof msg.method === 'string') return notification(msg);
443
+ fail('protocol', { note: 'a message on stdout was neither a request, a response nor a notification' });
444
+ }
445
+
446
+ function response(msg) {
447
+ const waiting = pending.get(msg.id);
448
+ if (!waiting) return;
449
+ pending.delete(msg.id);
450
+ clearTimeout(waiting.timer);
451
+ if (msg.error) {
452
+ waiting.reject(new Error(`codex app-server refused ${waiting.method}: ${msg.error.message ?? JSON.stringify(msg.error)}`));
453
+ return;
454
+ }
455
+ // Synchronously, INSIDE the read loop, before the next line of this chunk
456
+ // is looked at. A promise resolves on a microtask and the rest of the chunk
457
+ // is read on this one, so registering the thread's handlers off the
458
+ // resolved promise would miss any notification the server packed in behind
459
+ // the response -- which for `thread/start` is the thread's own first event.
460
+ try { waiting.onResult?.(msg.result); } catch { /* the caller's bookkeeping, not ours */ }
461
+ waiting.resolve(msg.result);
462
+ }
463
+
464
+ function notification(msg) {
465
+ const id = threadOf(msg.params);
466
+ const handlers = id === null ? null : threads.get(id);
467
+ if (handlers?.onNotification) call(() => handlers.onNotification(msg.method, msg.params));
468
+ else call(() => onNotification?.(msg.method, msg.params));
469
+ }
470
+
471
+ /* ------------------------------- approvals ------------------------------ */
472
+
473
+ /**
474
+ * Answer a server request. NEVER awaits: the handler's promise is left to
475
+ * settle on its own, because the founder may take fifteen minutes and every
476
+ * other thread on this process reads through the same loop.
477
+ */
478
+ function serverRequest(msg) {
479
+ // A SECOND REQUEST ON AN ID THAT IS STILL OPEN IS NOT A SECOND CARD.
480
+ //
481
+ // `openCards` is keyed by the server's request id, so a duplicate used to
482
+ // overwrite the entry without clearing the timer it replaced. The first
483
+ // card's deadline then went on running and denied the SECOND card at its
484
+ // own hour, and the first card's real answer -- her click -- landed on a map
485
+ // entry that had already been swapped and was dropped.
486
+ //
487
+ // There is no way to answer two open requests that share one id: whichever
488
+ // we write, the server matches it to one of them and the other hangs. That
489
+ // is the same unresolvable input as an unreadable frame, so it gets the same
490
+ // answer this file gives every one of those. Loud, and with `finish` denying
491
+ // every card that was open, so nothing is left waiting on a decision it can
492
+ // never receive.
493
+ if (openCards.has(msg.id)) {
494
+ return fail('protocol', { note: `codex app-server raised a second request on id ${msg.id} while the first was still open, and one answer cannot settle two` });
495
+ }
496
+ const shape = APPROVAL_SHAPE.get(msg.method);
497
+ if (!shape) return write({ jsonrpc: '2.0', id: msg.id, error: { ...REFUSAL_ERROR } });
498
+
499
+ const id = threadOf(msg.params);
500
+ const handler = id === null ? null : threads.get(id)?.onApproval;
501
+ // A refusal that is already certain does not wait out the clock: there is
502
+ // nobody to ask, so the turn hears no now rather than in fifteen minutes.
503
+ if (typeof handler !== 'function') return write({ jsonrpc: '2.0', id: msg.id, ...refusal(shape) });
504
+
505
+ openCard(msg.id, shape);
506
+ Promise.resolve()
507
+ .then(() => handler(msg.method, msg.params))
508
+ .then((answered) => answerCard(msg.id, translate(shape, answered) ?? refusal(shape)))
509
+ .catch(() => answerCard(msg.id, refusal(shape)));
510
+ }
511
+
512
+ /** Start her deadline. Expiry denies, once, and closes the card. */
513
+ function openCard(id, shape) {
514
+ const timer = setTimeout(() => {
515
+ if (openCards.delete(id)) write({ jsonrpc: '2.0', id, ...refusal(shape) });
516
+ }, approvalTimeoutMs);
517
+ timer?.unref?.();
518
+ openCards.set(id, timer);
519
+ }
520
+
521
+ /**
522
+ * The one answer this card will ever get.
523
+ *
524
+ * A card the deadline has already denied is CLOSED, so her late click lands
525
+ * here and is dropped rather than approving something the turn stopped
526
+ * waiting for twenty minutes ago. Same for a card whose connection died.
527
+ */
528
+ function answerCard(id, body) {
529
+ if (!openCards.has(id)) return;
530
+ clearTimeout(openCards.get(id));
531
+ openCards.delete(id);
532
+ write({ jsonrpc: '2.0', id, ...body });
533
+ }
534
+
535
+ // A REFUSAL IS SAID IN THE VOCABULARY THE METHOD TAKES, which for an
536
+ // elicitation is `decline` rather than an error frame. Both reach the model
537
+ // as a no; the difference is that a declined elicitation leaves the tool call
538
+ // declined and the turn running, while the error frame is a protocol failure
539
+ // on a request the server expected an answer to.
540
+ const refusal = (shape) => {
541
+ if (shape === 'decision') return { result: { decision: REFUSAL_DECISION } };
542
+ if (shape === 'elicitation') return { result: { action: REFUSAL_DECISION } };
543
+ return { error: { ...REFUSAL_ERROR } };
544
+ };
545
+
546
+ /**
547
+ * The handler's answer, in the shape the method actually takes, or null if it
548
+ * is not one this protocol version can express. Null is the whole
549
+ * deny-by-default rule: everything unrecognised lands there.
550
+ *
551
+ * The elicitation's three actions are `accept`, `decline` and `cancel`, read
552
+ * off `McpServerElicitationRequestResponse` in the CLI's own schema dump on
553
+ * 2026-09-22. `acceptForSession` is NOT among them, so a handler that answers
554
+ * a tool call with the word a command approval uses is refused rather than
555
+ * quietly turned into a yes. `content` is omitted: the accept this app sends
556
+ * answers a permission question whose `requestedSchema` has no properties,
557
+ * and a form that really does ask for typed input is not something Agentbox can
558
+ * fill in for her.
559
+ */
560
+ function translate(shape, answered) {
561
+ if (shape === 'decision') {
562
+ return APPROVAL_DECISIONS.includes(answered) ? { result: { decision: answered } } : null;
563
+ }
564
+ if (shape === 'elicitation') {
565
+ return ELICITATION_ACTIONS.includes(answered) ? { result: { action: answered } } : null;
566
+ }
567
+ const grant = grantFrom(answered);
568
+ return grant ? { result: grant } : null;
569
+ }
570
+
571
+ /**
572
+ * A permissions grant, CHECKED FIELD BY FIELD AND THEN BUILT FRESH.
573
+ *
574
+ * This is the one server request whose answer is a document rather than a
575
+ * word, and the document widens a sandbox. Forwarding the caller's object
576
+ * would mean checking one value and sending another: a `toJSON`, or a getter,
577
+ * runs again inside JSON.stringify, and what reaches Codex is whatever it
578
+ * returns the second time. So nothing the handler returned reaches the wire
579
+ * by reference, every value is read exactly once, and an unknown key anywhere
580
+ * -- on the object, in the profile, or in either area -- is a refusal rather
581
+ * than something to pass along. Inherited properties do not count, so a grant
582
+ * on a prototype is not a grant.
583
+ *
584
+ * The `entries` form of the filesystem profile is deliberately NOT accepted.
585
+ * Its shape has three nested variants, none of them measured, and a grant
586
+ * this file cannot check is a grant it has no business sending. The slice
587
+ * that needs it measures it.
588
+ */
589
+ function grantFrom(answered) {
590
+ if (!plain(answered)) return null;
591
+ const top = Object.keys(answered);
592
+ if (!top.includes('permissions') || !only(top, ['permissions', 'scope', 'strictAutoReview'])) return null;
593
+
594
+ const profile = answered.permissions;
595
+ if (!plain(profile)) return null;
596
+ const areas = Object.keys(profile);
597
+ if (!areas.length || !only(areas, ['fileSystem', 'network'])) return null;
598
+
599
+ const permissions = {};
600
+ if (areas.includes('fileSystem')) {
601
+ const asked = profile.fileSystem;
602
+ if (!plain(asked)) return null;
603
+ const keys = Object.keys(asked);
604
+ if (!keys.length || !only(keys, ['read', 'write', 'globScanMaxDepth'])) return null;
605
+ const fileSystem = {};
606
+ for (const list of ['read', 'write']) {
607
+ if (!keys.includes(list)) continue;
608
+ const paths = asked[list];
609
+ if (!Array.isArray(paths) || !paths.length) return null;
610
+ if (!paths.every((p) => typeof p === 'string' && p.length > 0)) return null;
611
+ fileSystem[list] = [...paths];
612
+ }
613
+ if (keys.includes('globScanMaxDepth')) {
614
+ const depth = asked.globScanMaxDepth;
615
+ if (!Number.isInteger(depth) || depth < 1) return null;
616
+ fileSystem.globScanMaxDepth = depth;
617
+ }
618
+ permissions.fileSystem = fileSystem;
619
+ }
620
+ if (areas.includes('network')) {
621
+ const asked = profile.network;
622
+ if (!plain(asked)) return null;
623
+ const keys = Object.keys(asked);
624
+ if (keys.length !== 1 || keys[0] !== 'enabled' || typeof asked.enabled !== 'boolean') return null;
625
+ permissions.network = { enabled: asked.enabled };
626
+ }
627
+
628
+ const grant = { permissions };
629
+ // `turn` when nobody said, because it is the narrower of the two and a
630
+ // grant that outlives the question it answered is not that answer.
631
+ if (top.includes('scope')) {
632
+ if (answered.scope !== 'turn' && answered.scope !== 'session') return null;
633
+ grant.scope = answered.scope;
634
+ } else {
635
+ grant.scope = 'turn';
636
+ }
637
+ if (top.includes('strictAutoReview')) {
638
+ if (typeof answered.strictAutoReview !== 'boolean') return null;
639
+ grant.strictAutoReview = answered.strictAutoReview;
640
+ }
641
+ return grant;
642
+ }
643
+
644
+ // Almost every line carries `params.threadId`. `thread/started` is the
645
+ // exception and keeps it at `params.thread.id`, which is the thread's own
646
+ // first event and therefore the worst one to lose.
647
+ function threadOf(params) {
648
+ if (!plain(params)) return null;
649
+ if (typeof params.threadId === 'string') return params.threadId;
650
+ if (typeof params.thread?.id === 'string') return params.thread.id;
651
+ return null;
652
+ }
653
+
654
+ // A listener that throws must not take the read loop with it: one thread's
655
+ // bad handler would otherwise stop every other thread on this process.
656
+ function call(fn) {
657
+ try { fn(); } catch { /* the caller's problem, and not worth the fleet */ }
658
+ }
659
+
660
+ /* ------------------------------ the ending ------------------------------ */
661
+
662
+ /** Why this connection ended, in a sentence that names something actionable. */
663
+ function cause() {
664
+ const tail = stderrText.trim().split('\n').slice(-3).join(' ').trim();
665
+ const said = tail ? `: ${tail}` : '';
666
+ if (!gone) return `the connection to codex app-server failed on write${said}`;
667
+ switch (gone.reason) {
668
+ case 'write': return `the connection to codex app-server failed on write (${gone.error?.message ?? 'no detail'})${said}`;
669
+ case 'protocol': return `the connection to codex app-server is corrupt, ${gone.note}${said}`;
670
+ case 'error': return `codex app-server failed to run (${gone.error?.message ?? 'no detail'})${said}`;
671
+ case 'closed': return `codex app-server was closed by ${NAME} (${gone.note})${said}`;
672
+ default: return `codex app-server exited (code ${gone.code}, signal ${gone.signal})${said}`;
673
+ }
674
+ }
675
+
676
+ /**
677
+ * The connection is over, whatever ended it.
678
+ *
679
+ * Marked SYNCHRONOUSLY, so that from this instant no further request is
680
+ * accepted and no late approval can be written. Finished once stdout has
681
+ * drained, so the tracing tail that explains it is complete rather than
682
+ * whatever happened to have arrived by the moment `exit` fired -- exit
683
+ * routinely beats the last of the pipe, and "exited with code 1" on its own
684
+ * is not a cause anybody can act on.
685
+ */
686
+ function fail(reason, detail = {}) {
687
+ if (gone) return;
688
+ gone = { reason, code: null, signal: null, error: null, note: null, ...detail, stderr: stderrText };
689
+ if (reason !== 'exit') stop();
690
+ if (drained) finish();
691
+ else if (typeof setImmediate === 'function') setImmediate(finish);
692
+ else finish();
693
+ }
694
+
695
+ function stop() {
696
+ try { child.stdin.end(); } catch { /* already gone */ }
697
+ try { child.kill(); } catch { /* already gone */ }
698
+ }
699
+
700
+ /**
701
+ * Everything that was waiting finds out, once, and nothing stays registered.
702
+ *
703
+ * A promise nobody settles is exactly how a work item strands, so an
704
+ * in-flight request rejects rather than staying pending, and every watched
705
+ * thread is told rather than left listening to a dead pipe. Deadlines are
706
+ * cancelled and registrations dropped: a card that can never be answered and
707
+ * a thread that can never be spoken to are only holding this client alive.
708
+ */
709
+ function finish() {
710
+ if (finished) return;
711
+ finished = true;
712
+ gone.stderr = stderrText;
713
+ const why = cause();
714
+
715
+ for (const timer of openCards.values()) clearTimeout(timer);
716
+ openCards.clear();
717
+
718
+ for (const [id, waiting] of pending) {
719
+ pending.delete(id);
720
+ clearTimeout(waiting.timer);
721
+ waiting.reject(Object.assign(new Error(`${waiting.method}: ${why}`), { cause: gone }));
722
+ }
723
+ const listening = [...threads.values()];
724
+ threads.clear();
725
+ for (const handlers of listening) call(() => handlers.onClosed?.(gone));
726
+ }
727
+
728
+ /* -------------------------------- the API ------------------------------- */
729
+
730
+ const watch = (threadId, handlers = {}) => { threads.set(threadId, handlers); return threadId; };
731
+
732
+ return {
733
+ /** Any client request, for the ones with no wrapper (`config/read`). */
734
+ request: (method, params) => send(method, params),
735
+
736
+ /**
737
+ * The whole handshake, not half of it: the request, and then `initialized`
738
+ * once the server has answered.
739
+ *
740
+ * `initialized` is the only client notification in the 0.148.0 schema and
741
+ * the documented lifecycle puts it before any other operation. 0.148
742
+ * tolerates its absence, which is why the first draft never noticed it was
743
+ * missing; a later one need not. It is sent from the response hook, so it
744
+ * can never overtake the response, and it is never sent at all if the
745
+ * server refused the handshake.
746
+ */
747
+ initialize: () => send('initialize', { clientInfo }, () => notify('initialized')),
748
+
749
+ /**
750
+ * Start a thread and watch it from the instant the server names it.
751
+ *
752
+ * `ephemeral` defaults to false so the thread leaves a rollout on disk and
753
+ * a later `thread/resume` can find it; pass true only for work that will
754
+ * never be continued.
755
+ */
756
+ startThread: (params = {}, handlers = {}) => send(
757
+ 'thread/start',
758
+ { ephemeral: false, ...params },
759
+ (result) => { const id = result?.thread?.id; if (typeof id === 'string') watch(id, handlers); },
760
+ ).then((result) => ({ threadId: result?.thread?.id ?? null, thread: result?.thread ?? null, result })),
761
+
762
+ /**
763
+ * Continue a thread we already know the id of, so the watch goes on first
764
+ * and no event of the resumed thread can arrive unowned.
765
+ *
766
+ * A resume the server REFUSES never became a thread, so the watch is rolled
767
+ * back. Left installed, it would hand every later line carrying that id to
768
+ * a listener for a session that does not exist.
769
+ */
770
+ resumeThread: (params = {}, handlers = {}) => {
771
+ const id = typeof params.threadId === 'string' ? params.threadId : null;
772
+ if (id) watch(id, handlers);
773
+ return send('thread/resume', params).catch((err) => {
774
+ if (id && threads.get(id) === handlers) threads.delete(id);
775
+ throw err;
776
+ });
777
+ },
778
+
779
+ startTurn: (threadId, params = {}) => send('turn/start', { threadId, ...params }),
780
+
781
+ /** Both ids: a turn is not identified by either one alone. */
782
+ interruptTurn: (threadId, turnId) => send('turn/interrupt', { threadId, turnId }),
783
+ steerTurn: (threadId, expectedTurnId, text) => send('turn/steer', {threadId, expectedTurnId, input:[{type:'text',text}]}),
784
+
785
+ watch,
786
+ unwatch: (threadId) => threads.delete(threadId),
787
+
788
+ /** Stop on purpose. Same settling as a crash, so callers have one ending. */
789
+ close: (note = 'no reason given') => fail('closed', { note }),
790
+
791
+ isClosed: () => gone !== null,
792
+ /** The tracing tail, for a cause report. Never protocol. */
793
+ stderr: () => stderrText,
794
+ stats: () => ({ pending: pending.size, threads: threads.size, approvals: openCards.size }),
795
+ };
796
+ }