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,797 @@
1
+ // ONE CODEX WORK ITEM, SHAPED LIKE A CHILD PROCESS.
2
+ //
3
+ // Agentbox spawns one child per work item and hangs everything off it: the
4
+ // supervisor's `session.child` is a ChildProcess and every other part of
5
+ // `spawnWorker` is written against that. A Codex worker has no per-item
6
+ // process. It is ONE shared `codex app-server` plus a thread inside it, proven
7
+ // on 2026-09-04 by running several concurrently: independent thread ids,
8
+ // interleaved progress, and no head-of-line blocking when one of them parked on
9
+ // an unanswered approval.
10
+ //
11
+ // So the question this file answers is whether the supervisor has to learn
12
+ // about threads, and the measured answer is no. `session.child` is referenced
13
+ // in exactly ONE place in the whole 4,273-line supervisor
14
+ // (`session.child.kill`), and the local `child` handle inside `spawnWorker` is
15
+ // used in exactly five ways:
16
+ //
17
+ // child.once('spawn') child.stderr.on('data') child.kill
18
+ // child.stdout.on('data') child.on('exit')
19
+ //
20
+ // That five-method contract is the entire seam, and this module is a facade
21
+ // over one thread that satisfies it. The supervisor keeps being a file about
22
+ // running sessions rather than a file about two vendors' flag names, which is
23
+ // the sentence the deleted 08-25 design used to justify the same choice.
24
+ //
25
+ // FOUR OF THE FIVE ARE REAL AND ONE IS DELIBERATELY MISSING.
26
+ //
27
+ // `stdout` IS NOT FAKED. It would be easy to re-serialise every notification
28
+ // into a JSON line so the existing readers worked untouched, and it would be
29
+ // objects -> JSONL -> objects for no gain: it would also hide the real
30
+ // camelCase/snake_case divergence between `codex app-server` and the
31
+ // `codex exec` stream the removed August build parsed, which is precisely the
32
+ // thing that must stay visible. So this facade emits `event(method, params)`
33
+ // and the supervisor swaps its three readers once per session instead of once
34
+ // per line. See `readersFor` in main/supervisor.mjs.
35
+ //
36
+ // `stderr` IS REAL, and it is why this file writes to it at all. The supervisor
37
+ // classifies a dead run by reading the `stderr:` lines off the session tail
38
+ // (`sayTheRunDied` -> `troubleCause`), and Codex's own refusal wording -- "Not
39
+ // inside a trusted directory and --skip-git-repo-check was not specified" -- is
40
+ // what makes the difference between her reading "the folder is not one Codex
41
+ // will work in" and reading the generic sentence. A failure that never reaches
42
+ // this pipe is a failure filed as 'unknown'.
43
+ //
44
+ // AND THE ONE THING THIS FILE OWES `settleDelivery`: AN ENDING THAT WAS NOT THE
45
+ // WORK ITEM'S FAULT MUST BE TELLABLE FROM ONE THAT WAS. CLAUDE.md's rule is that a session WE killed does not
46
+ // spend one of an item's three delivery attempts, "because every restart kills
47
+ // the fleet and charging that to the item is how restarts strand work". The
48
+ // supervisor's own `_kill` writes `stoppedByUs` on the session, and this facade
49
+ // ALSO sets `signal` on the exit -- both halves, deliberately, because
50
+ // `settleDelivery` reads `signal || session.stoppedByUs` and a facade that
51
+ // reported a clean `null` signal on an interrupt would be lying about the one
52
+ // fact that protects her work.
53
+ //
54
+ // THE EXIT THEREFORE CARRIES A THIRD ARGUMENT, which a real ChildProcess never
55
+ // sends and the supervisor reads as undefined for every Claude worker. It says
56
+ // one thing today: the shared app-server died under this thread. ONE process
57
+ // carries the whole Codex fleet, so a single unreadable frame reaches every live
58
+ // thread at once, and each of those threads is a work item -- ending them with a
59
+ // bare `end(1, null)` spent one of every item's three delivery attempts on a
60
+ // fault none of them caused. See `onClosed` below.
61
+
62
+ import { EventEmitter } from 'node:events';
63
+ import fs from 'node:fs';
64
+ import os from 'node:os';
65
+ import path from 'node:path';
66
+ import { Name } from '../shared/product-name.mjs';
67
+ import { codexPosture } from '../shared/codex-modes.mjs';
68
+
69
+ /**
70
+ * WHAT A WORKER THREAD IS ALLOWED TO DO. The sandbox is the wall; the approval
71
+ * policy only decides who gets interrupted.
72
+ *
73
+ * THIS WAS `untrusted` UNTIL 2026-09-23 AND IT MADE CODEX UNUSABLE. The founder
74
+ * reported that Codex asked about nearly everything it wanted to run, to the
75
+ * point that the only viable way to use it was to leave this app and drive the
76
+ * Codex app directly (w-07f98d5a1c).
77
+ *
78
+ * She was right, and the note that stood here was wrong in a way worth writing
79
+ * down because it is easy to make twice. It said the other policies "did not
80
+ * card a command" and concluded they gated nothing. NOT CARDING AND NOT
81
+ * STOPPING ARE DIFFERENT THINGS. Under `workspace-write` the sandbox refuses on
82
+ * its own, silently, and a card was never what held the wall up.
83
+ *
84
+ * MEASURED 2026-09-23 on codex-cli 0.153.4. Both scripts are kept:
85
+ *
86
+ * scripts/count-what-codex-asks.mjs -- one ordinary task: read a file, edit
87
+ * it, run node to check the result.
88
+ * untrusted 3 interruptions, task completed
89
+ * on-request 0 interruptions, task completed, same edit
90
+ *
91
+ * scripts/can-codex-escape-on-request.mjs -- the counter-test: write to $HOME
92
+ * and curl the open internet.
93
+ * untrusted 2 cards, both denied, nothing escaped
94
+ * on-request 0 cards, AND NOTHING ESCAPED EITHER. The sandbox answered
95
+ * "operation not permitted" for the write and "could not
96
+ * resolve host" for the curl.
97
+ *
98
+ * So `untrusted` bought three interruptions on ordinary work and no containment
99
+ * `workspace-write` was not already giving us.
100
+ *
101
+ * WHAT THE SCHEMA SAYS, read off `codex app-server generate-json-schema` on that
102
+ * same version rather than remembered: `AskForApproval` is
103
+ * `untrusted | on-request | never | {granular:{...}}`, and
104
+ * `SandboxWorkspaceWrite.network_access` DEFAULTS TO FALSE. Note that the
105
+ * interactive CLI has dropped `untrusted` from `--ask-for-approval` altogether
106
+ * and offers only `on-request` and `never`; the app-server protocol still
107
+ * accepts it, which is how this went on working while being the wrong choice.
108
+ *
109
+ * `on-request` IS WHAT THE CODEX APP ITSELF RUNS, which is the whole of what she
110
+ * asked for: work inside the sandbox unasked, ask only to get out. It keeps the
111
+ * escape hatch rather than removing it, so a run that genuinely needs the
112
+ * network can still request it and she still gets a card. One caveat stated
113
+ * rather than implied: the escape probe TOLD the model not to work around a
114
+ * refusal, so its zero cards prove the sandbox refuses by default, not that
115
+ * `on-request` never asks for escalation.
116
+ *
117
+ * THE PATCH IS STILL CARDED WHERE IT MATTERS and that machinery is untouched:
118
+ * `FileChangeRequestApprovalParams` carries no path and no diff, so
119
+ * main/codex-approvals.mjs draws the card off the `item/started` for the same
120
+ * itemId, which arrives first and carries the paths and the change.
121
+ *
122
+ * `workspace-write` is the sandbox and it does not move: a worker may write in
123
+ * the folder it was given and nowhere else, and has no network. That is what
124
+ * `--permission-mode acceptEdits` buys on the other engine.
125
+ */
126
+ export const WORKER_APPROVAL_POLICY = 'on-request';
127
+ export const WORKER_SANDBOX = 'workspace-write';
128
+
129
+ /**
130
+ * How long a worker waits for its store server before giving up on the run.
131
+ *
132
+ * Measured 2026-09-04: a working server went from `starting` to `ready` in well
133
+ * under a second, and a broken one failed, was retried by Codex, and failed
134
+ * again inside twenty-five seconds. Thirty covers the retry and still costs a
135
+ * genuinely dead machine half a minute rather than her fifteen.
136
+ */
137
+ export const MCP_READY_MS = 30_000;
138
+
139
+ /* * * TURN THE FOUNDER'S OWN MCP SERVERS OFF FOR A WORKER, BY NAME. * * A bare `thread/start`
140
+ inherits every MCP server in her `~/.codex/config.toml`. * Measured 2026-09-04 on codex-cli
141
+ 0.148.0, thread start only and no turn: a * plain start reported
142
+ `mcpServer/startupStatus/updated` for `playwright`, * `context7` and `codex_apps`, so a
143
+ the app worker would silently launch two npx * processes of hers on every spawn. Nobody asked
144
+ for that and nothing on her * screen would say it happened. * * THE OBVIOUS FIX DOES NOT
145
+ WORK, WHICH IS WHY THIS IS A LIST OF NAMES AND NOT * AN EMPTY TABLE. All measured the same
146
+ day, same probe: * * `config: { mcp_servers: {} }` -> all three still started. It used to
147
+ also be what an * unreadable `config/read` degraded to, and that was the whole of the bug:
148
+ an * empty list is `{}`, `{}` is the no-op measured above, and the no-op starts * every
149
+ server she has inside a headless worker. * * `codex_apps` IS NOT ON THIS TABLE AND CANNOT
150
+ BE, measured 2026-09-04 on * codex-cli 0.148.0, thread start only: * * with `codex_apps:
151
+ {enabled:false}` beside her two -> `thread/start` REFUSED, * -32600 "failed to load
152
+ configuration: invalid transport in * `mcp_servers.codex_apps`" * with her two named off
153
+ and `codex_apps` left alone -> the only startup * notifications on the thread were
154
+ `codex_apps=starting`, `codex_apps=ready` * * An `enabled: false` entry is a MERGE onto a
155
+ server that already exists in her * config; Codex's own built-in app connector is not in
156
+ her config and has no * transport to merge onto, so naming it refuses the thread rather
157
+ than * disabling it. It therefore stays enabled on every worker, deliberately. It is *
158
+ Codex's own connector rather than a process of hers, so nothing of hers runs * because of
159
+ it -- but it is not nothing, and the slice that needs it off is * the slice that finds the
160
+ switch Codex actually offers for it. * * That measurement is also why the names must be
161
+ READ AT EVERY START rather * than cached for the app-server's life: a name that is stale
162
+ because she * deleted the server does not merely fail to disable anything, it refuses the *
163
+ thread outright. * * AND THE STORE SERVER GOES BACK IN THROUGH THE SAME TABLE. Switching
164
+ every * server off and adding nothing is what the first slice shipped, and it left a *
165
+ Codex worker unable to claim, checkpoint or finish a row: CLAUDE.md's * 2026-08-05
166
+ incident, "five sessions of analysis written to nowhere", arriving * through a second
167
+ engine. Measured 2026-09-04 on codex-cli 0.148.0, one * `thread/start` and no turn: an
168
+ entry added BESIDE the `enabled: false` ones * started and reported ready while
169
+ `playwright` and `context7` still produced * nothing at all, so one table carries both
170
+ halves and the isolation is intact. * * Ours is written LAST, deliberately. If she ever
171
+ runs a server of her own * under this name the store is the one that has to win, because a
172
+ worker with * no store is worse than a worker without her tool.
173
+ */
174
+ export function mcpIsolation(names = [], storeServer = null) {
175
+ // NULL-PROTOTYPE, BECAUSE ONE OF HER SERVERS MAY BE CALLED `__proto__`.
176
+ // `{}[('__proto__')] = x` reaches Object.prototype's setter: the assignment
177
+ // appears to work, `Object.keys` does not list it, and JSON.stringify does
178
+ // not emit it -- so the one server named after the thing that hides it would
179
+ // be the one server left running inside a worker. Nothing else here changes:
180
+ // JSON.stringify treats a null-prototype object exactly as a plain one.
181
+ const mcp_servers = Object.create(null);
182
+ for (const name of names) {
183
+ if (typeof name === 'string' && name) mcp_servers[name] = { enabled: false };
184
+ }
185
+ if (storeServer?.name && storeServer.command) {
186
+ const entry = { command: storeServer.command, env: storeServer.env ?? {} };
187
+ // `args` IS CARRIED WHEN THERE IS ONE, and Agentbox's own launcher has none:
188
+ // `storeMcpCommand` is a single executable, exactly as the Claude path
189
+ // passes it. It is here because dropping a caller's argv in silence is a
190
+ // server that starts and never handshakes, which was measured against a
191
+ // real app-server while writing this -- the process came up, waited on
192
+ // stdin forever, and the only symptom was a run that stopped saying the
193
+ // store never reported ready.
194
+ if (Array.isArray(storeServer.args) && storeServer.args.length) entry.args = [...storeServer.args];
195
+ mcp_servers[storeServer.name] = entry;
196
+ }
197
+ return mcp_servers;
198
+ }
199
+
200
+ /**
201
+ * THE ONE FOLDER THIS THREAD RUNS IN, DECLARED UNTRUSTED TO CODEX.
202
+ *
203
+ * `mcpIsolation` above switches off the servers named in HER config, and that
204
+ * is all it can do, because those are the only names `config/read` returns. A
205
+ * `.codex/config.toml` sitting in the product folder is a config LAYER of
206
+ * Codex's own, and the per-thread override is a deep merge, so a server that
207
+ * layer declares was never named and never switched off. Measured on this Mac
208
+ * 2026-09-05, codex-cli 0.148.0, real `codex app-server`, scratch CODEX_HOME:
209
+ *
210
+ * proj/.codex/config.toml declares `planted` = sh -c "echo pwned > MARK"
211
+ * thread/start cwd=proj, mcp_servers = { herone: { enabled: false } }
212
+ * -> mcpServer/startupStatus/updated planted=starting, and MARK was on
213
+ * disk. The plant RAN.
214
+ *
215
+ * So a repository she cloned -- or an earlier worker writing inside one --
216
+ * plants a command the next Codex thread LAUNCHES, outside the command sandbox
217
+ * and outside the approval path, with no card and nothing on her screen.
218
+ *
219
+ * NAMING IT THE WAY `mcpIsolation` NAMES HERS DOES NOT WORK. Measured the same
220
+ * hour: `{ planted: { enabled: false } }` answered -32600 "failed to load
221
+ * configuration: invalid transport in `mcp_servers.planted`", because an
222
+ * `enabled: false` entry is a merge onto a server in the TRUSTED config and an
223
+ * untrusted project layer is not in it. Asking `config/read` WITH the thread's
224
+ * cwd is no help either: it returned the same home names, and only
225
+ * `includeLayers: true` showed the layer at all -- carrying a `disabledReason`
226
+ * while `thread/start` went ahead and started the server regardless.
227
+ *
228
+ * CODEX'S OWN PROJECT-TRUST GATE IS THE SWITCH, AND IT IS PER THREAD. Measured
229
+ * on the same probe:
230
+ *
231
+ * config.projects[<cwd>].trust_level = "untrusted"
232
+ * -> no startup notification at all, MARK never written, and the store
233
+ * server Agentbox puts there itself still started beside it.
234
+ * with [projects."<proj>"] trust_level = "trusted" already in the home
235
+ * config.toml, the per-thread untrusted STILL won.
236
+ * cwd two levels below the `.codex` folder: unguarded the plant ran, and
237
+ * naming only the cwd stopped it.
238
+ *
239
+ * AND IT STOPS AGENTBOX WRITING IN HER CODEX CONFIG, which nothing had noticed.
240
+ * Without this, `thread/start` appended [projects."<cwd>"] trust_level =
241
+ * "trusted" to CODEX_HOME's own config.toml -- permanently, and for her
242
+ * terminal too, so the first Agentbox run in a folder quietly made that folder's
243
+ * `.codex` live for every `codex` she types afterwards. With it, nothing was
244
+ * written.
245
+ *
246
+ * A cwd that is not a real string answers `{}` rather than a guess: a table
247
+ * with an empty key is a table Codex refuses, and that would cost the run over
248
+ * a fact nobody stated.
249
+ */
250
+ export function projectIsolation(cwd) {
251
+ // NULL-PROTOTYPE for the reason `mcpIsolation` gives: a path is any string,
252
+ // `__proto__` included, and a plain `{}` drops that one key in silence --
253
+ // leaving the folder named after the thing that hides it the one folder that
254
+ // is still trusted.
255
+ const projects = Object.create(null);
256
+ if (typeof cwd === 'string' && cwd) projects[cwd] = { trust_level: 'untrusted' };
257
+ return projects;
258
+ }
259
+
260
+ /**
261
+ * THE NAMES IN A `config/read` RESULT, OR A REFUSAL TO GUESS.
262
+ *
263
+ * This used to answer `[]` for every shape it did not recognise, on the reading
264
+ * that isolation which cannot read her config should degrade to "turn nothing
265
+ * off". IT DEGRADED TO THE OPPOSITE. `[]` builds `{}`, and `{}` is the deep
266
+ * merge measured above that disables nothing at all -- so the failure path
267
+ * started every MCP server she has inside a headless worker, on this Mac two
268
+ * `npx` processes per spawn, with nothing on her screen saying it happened.
269
+ *
270
+ * So there are two answers now and they are not the same answer:
271
+ *
272
+ * SHE HAS NONE -> `[]`. An empty table, no `mcp_servers` key at all, or an
273
+ * explicit null. The empty override really is correct here, because there
274
+ * is nothing of hers for it to fail to disable.
275
+ * WE COULD NOT TELL -> THROW. A read that errored, a result with no `config`,
276
+ * an `mcp_servers` that is a list or a string. The caller stops the worker
277
+ * before `thread/start` rather than starting one that inherits everything.
278
+ *
279
+ * A throw is safe on the spawn path by construction: `createCodexWorker` awaits
280
+ * this inside its own promise chain, and the `catch` at the end of that chain
281
+ * ends the worker with the message on the stderr `troubleCause` reads.
282
+ */
283
+ export function mcpServerNames(configRead) {
284
+ const cannot = () => {
285
+ throw new Error('could not read which MCP servers to switch off for this worker, so it is not starting rather than running every server the founder has');
286
+ };
287
+ if (!plainObject(configRead) || !plainObject(configRead.config)) cannot();
288
+ const servers = configRead.config.mcp_servers;
289
+ if (servers === undefined || servers === null) return [];
290
+ if (!plainObject(servers)) cannot();
291
+ return Object.keys(servers);
292
+ }
293
+
294
+ const plainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
295
+
296
+ /**
297
+ * The `thread/start` params for one worker.
298
+ *
299
+ * `ephemeral: false` IS NOT A DEFAULT WORTH RELYING ON HERE, even though
300
+ * main/codex-app-server.mjs supplies the same value. It is the difference
301
+ * between a row that can be replied to and one that cannot: measured
302
+ * 2026-09-04, `thread/resume` on an ephemeral thread fails `no rollout found`,
303
+ * and continuation-on-reply is most of what this app does. Written where a
304
+ * reader of this file can see it rather than inherited from another one.
305
+ *
306
+ * `projects` IS THE SECOND HALF OF THE ISOLATION AND NOT AN EXTRA. `mcp_servers`
307
+ * can only name the servers in HER config; `projects` is what keeps the config
308
+ * layer inside the folder the thread runs in from contributing any of its own.
309
+ * See `projectIsolation` for the measurement.
310
+ *
311
+ * `features.apps` IS THE THIRD, AND IT IS THE SWITCH `mcpIsolation` SPENT A
312
+ * SLICE FAILING TO FIND. `codex_apps` cannot be named in `mcp_servers` -- that
313
+ * refuses the thread, measured 2026-09-04 -- because it is not one of her
314
+ * servers at all: it is Codex's own connector to the apps she has linked to her
315
+ * ChatGPT account, and it is gated by a FEATURE. Measured 2026-09-05,
316
+ * codex-cli 0.148.0, real app-server:
317
+ *
318
+ * experimentalFeature/list -> { name: "apps", stage: "stable",
319
+ * enabled: true, defaultEnabled: true }
320
+ * thread/start config.features.apps = false -> accepted, no refusal
321
+ * experimentalFeature/list { threadId } for that thread -> apps enabled FALSE
322
+ * the same call for a thread started without it -> apps enabled true
323
+ *
324
+ * (`config.apps` is not it and was tried first: `{ apps: { enabled: false } }`
325
+ * answered "invalid type: boolean `false`, expected struct AppConfig in
326
+ * `apps`".)
327
+ *
328
+ * WHAT IS NOT MEASURED, said plainly rather than implied away: the connector
329
+ * needs a signed-in ChatGPT account and every probe ran against a scratch
330
+ * CODEX_HOME that is signed out, so `codex_apps` never started in any of them.
331
+ * What was watched is Codex's own registry reporting the flag off for the
332
+ * thread, not the connector process failing to appear. Her real `~/.codex` was
333
+ * not opened to close that gap. `createCodexWorker` therefore also WATCHES the
334
+ * result -- see `sayUnasked` below -- so a flag that stops working is a line on
335
+ * the run rather than a silence.
336
+ */
337
+ export function workerThreadParams({ cwd, model = null, instructions = null, mcpServers = [], storeServer = null, mode = null } = {}) {
338
+ // THE MODE SHE PICKED, OR THE PAIR OF CONSTANTS. `mode` is one of the three in
339
+ // shared/codex-modes.mjs, and it decides BOTH the sandbox and the approval
340
+ // policy, because on this engine those two are one choice rather than two
341
+ // (that file says why). Passing nothing means the default posture, which is
342
+ // what every caller did before there was a picker, so a caller that has not
343
+ // been updated keeps the behaviour it had.
344
+ const posture = mode
345
+ ? codexPosture(mode)
346
+ : { approvalPolicy: WORKER_APPROVAL_POLICY, sandbox: WORKER_SANDBOX };
347
+ const params = {
348
+ cwd,
349
+ ephemeral: false,
350
+ approvalPolicy: posture.approvalPolicy,
351
+ sandbox: posture.sandbox,
352
+ ...(posture.approvalsReviewer ? { approvalsReviewer: posture.approvalsReviewer } : {}),
353
+ config: {
354
+ mcp_servers: mcpIsolation(mcpServers, storeServer),
355
+ projects: projectIsolation(cwd),
356
+ // Plugins can contribute MCP servers outside config.mcp_servers.
357
+ // Keep this worker on the same explicit integration boundary.
358
+ features: { apps: false, plugins: false, remote_plugin: false },
359
+ },
360
+ };
361
+ if (model) params.model = model;
362
+ // Her standing instructions and the project's, in the slot that is this
363
+ // engine's `--append-system-prompt`: a rule pasted above a 200-line brief is
364
+ // a rule that gets buried, which is the reason the Claude path uses a system
365
+ // prompt too.
366
+ if (instructions) params.developerInstructions = instructions;
367
+ return params;
368
+ }
369
+
370
+ /**
371
+ * THE SAME THREAD, CONTINUED, AND IT HAS TO CARRY THE WHOLE TABLE AGAIN.
372
+ *
373
+ * The harness resumed with `{threadId}` and nothing else, on the reasonable
374
+ * assumption that a thread remembers what it was started with. IT DOES NOT.
375
+ * Measured 2026-09-04 on codex-cli 0.148.0, one thread and three connections to
376
+ * the same rollout:
377
+ *
378
+ * resume WITH this override -> the store server started and reported ready, and
379
+ * `playwright` and `context7` stayed off.
380
+ * resume with `{threadId}` -> `playwright` and `context7` BOTH started and
381
+ * reported ready, and the thread had no store
382
+ * server on it at all. Two npx processes of
383
+ * hers were left running on the Mac by that
384
+ * probe and had to be killed by hand.
385
+ *
386
+ * A reply she writes is a continuation, and continuations are most of what this
387
+ * app does, so without this the isolation held for a row's first turn only and
388
+ * every reply afterwards ran her servers and no store.
389
+ *
390
+ * `ephemeral` IS DROPPED RATHER THAN PASSED THROUGH. ThreadResumeParams takes
391
+ * approvalPolicy, approvalsReviewer, baseInstructions, config, cwd,
392
+ * developerInstructions, model, modelProvider, personality, sandbox,
393
+ * serviceTier and threadId, and `ephemeral` is not among them: it is a fact
394
+ * about creating a thread, and this one already exists.
395
+ */
396
+ export function resumeThreadParams(threadId, params = {}) {
397
+ const { ephemeral, ...rest } = params ?? {};
398
+ return { threadId, ...rest };
399
+ }
400
+
401
+ /**
402
+ * WHERE CODEX PUT THE TRANSCRIPT OF ONE SESSION.
403
+ *
404
+ * Claude Code keeps `<home>/projects/<slug>/<id>.jsonl`, so the supervisor can
405
+ * test one path and be done. Codex writes
406
+ * `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl` and the id is
407
+ * INSIDE the filename, so finding one means looking. There were 1,700 of these
408
+ * on the machine this was written on, counted with `find ~/.codex/sessions -name
409
+ * 'rollout-*.jsonl' | wc -l`, so "look at all of them" is not an answer.
410
+ *
411
+ * NEWEST FIRST AND BOUNDED. The directory tree is already sorted by date, so
412
+ * walking it year-desc, month-desc, day-desc finds a recent session in one
413
+ * readdir and gives up after `days` day-folders rather than scanning the lot.
414
+ * Sixty is the bound because the only caller is a "does this still exist"
415
+ * check before a resume, and a session two months old is one nothing is going
416
+ * back to.
417
+ *
418
+ * THE MATCH IS THE WHOLE FILENAME, NOT A SUBSTRING. `includes(id)` would match
419
+ * a neighbouring rollout whose own uuid merely ends with the same characters,
420
+ * and handing `thread/resume` the wrong thread is worse than handing it
421
+ * nothing: it would resume a stranger's conversation onto her row.
422
+ */
423
+ export function codexTranscriptFile(sessionId, { home = path.join(os.homedir(), '.codex'), days = 60 } = {}) {
424
+ if (!sessionId || typeof sessionId !== 'string') return null;
425
+ const wanted = new RegExp(`^rollout-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-${escapeForRegExp(sessionId)}\\.jsonl$`);
426
+ const root = path.join(home, 'sessions');
427
+ let looked = 0;
428
+ for (const year of numericDesc(root)) {
429
+ for (const month of numericDesc(path.join(root, year))) {
430
+ for (const day of numericDesc(path.join(root, year, month))) {
431
+ if (looked >= days) return null;
432
+ looked += 1;
433
+ const dir = path.join(root, year, month, day);
434
+ let names = [];
435
+ try { names = fs.readdirSync(dir); } catch { names = []; }
436
+ const hit = names.find((name) => wanted.test(name));
437
+ if (hit) return path.join(dir, hit);
438
+ }
439
+ }
440
+ }
441
+ return null;
442
+ }
443
+
444
+ function escapeForRegExp(text) {
445
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
446
+ }
447
+
448
+ // Newest first, and only the entries that are dated: a stray file or a folder
449
+ // Codex renames under us must not stop the walk or reorder it.
450
+ function numericDesc(dir) {
451
+ let names = [];
452
+ try { names = fs.readdirSync(dir); } catch { return []; }
453
+ return names.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
454
+ }
455
+
456
+ /* * * ONE WORK ITEM'S THREAD, WEARING A CHILD PROCESS'S FACE. * * `server` is a
457
+ main/codex-app-server.mjs client, shared with every other * worker on this Mac. `ready` is
458
+ the handshake: `initialize` must land before * `thread/start`, and awaiting it here rather
459
+ than at every call site is what * lets the supervisor treat starting a Codex worker as one
460
+ synchronous call * that returns a handle, exactly like `spawn`. * * WHAT IT EMITS, AND
461
+ WHEN: * * 'spawn' the work is really up: the thread exists AND the * store server it needs
462
+ has reported ready. Without it the transport refuses every * server request on this thread
463
+ immediately -- which is still the correct * behaviour and is what a worker with nobody to
464
+ ask should hear, rather than * parking for fifteen minutes. main/codex-approvals.mjs is the
465
+ handler the * supervisor passes. * * `requireMcpServer` IS THE STORE, AND THE TURN WAITS
466
+ FOR IT. Measured * 2026-09-04: a `mcp_servers` entry whose command does not exist leaves *
467
+ `thread/start` resolving normally and the turn free to run, with the failure * arriving
468
+ only as a notification. That is a worker doing a whole job it cannot * file anywhere, which
469
+ CLAUDE.md prices at five sessions. So the turn does not * start until
470
+ `mcpServer/startupStatus/updated` says `ready` for this server, * and if that never comes
471
+ the run dies with the reason on stderr. Null when no * store server was configured, which
472
+ is every downloaded copy of Agentbox: there * is nothing to wait for and a worker that
473
+ waited would never run. * * ONE `failed` IS NOT THE VERDICT, THE DEADLINE IS. Codex retries
474
+ a server that * failed to start -- measured, twice inside twenty-five seconds -- so a run *
475
+ killed on the first failure would be a run killed on a hiccup. The last * failure's own
476
+ words are kept, and they are what the run dies saying.
477
+ */
478
+ export function createCodexWorker({
479
+ server,
480
+ ready = Promise.resolve(),
481
+ threadParams = {},
482
+ turnParams = {},
483
+ resumeThreadId = null,
484
+ onApproval = null,
485
+ requireMcpServer = null,
486
+ mcpReadyMs = MCP_READY_MS,
487
+ } = {}) {
488
+ if (!server) throw new Error('createCodexWorker needs a codex app-server client');
489
+
490
+ const worker = new EventEmitter();
491
+ const stderr = new EventEmitter();
492
+ worker.stderr = stderr;
493
+ worker.threadId = resumeThreadId ?? null;
494
+ worker.turnId = null;
495
+
496
+ let ended = false;
497
+ worker.steer = async text => {
498
+ if (ended || !worker.threadId || !worker.turnId) throw Error('The turn is not running. Send again when the conversation is ready.');
499
+ const expected=worker.turnId;
500
+ const result=await server.steerTurn(worker.threadId,expected,text);
501
+ if(result?.turnId!==expected)throw Error('The provider did not confirm the active turn.');
502
+ return result;
503
+ };
504
+
505
+ const say = (text) => {
506
+ const line = String(text ?? '').trim();
507
+ if (line) stderr.emit('data', line);
508
+ };
509
+
510
+ /* --------------------------- the store's gate --------------------------- */
511
+
512
+ // `ready` more than once is ordinary: measured, the store server reported ready at
513
+ // the thread's start and again while the turn ran. So the wait settles once
514
+ // and every later notification is a no-op.
515
+ //
516
+ // THIS DEPENDS ON THE STARTUP NOTIFICATION ARRIVING AFTER THE RESPONSE THAT
517
+ // NAMES THE THREAD, which is what was observed on every measured run: the
518
+ // watch is installed inside `startThread`'s result hook, synchronously in the
519
+ // read loop, so anything the server packs in BEHIND the response is caught,
520
+ // and a `ready` that somehow came in AHEAD of it would belong to no watched
521
+ // thread and be dropped -- costing a healthy worker its run at the deadline.
522
+ // The resume road has no such window, because `resumeThread` watches before
523
+ // it sends. If a Codex release ever reorders that, this is the sentence that
524
+ // says where to look.
525
+ let storeIsUp = !requireMcpServer;
526
+ let storeTrouble = '';
527
+ const waiting = [];
528
+
529
+ /**
530
+ * A SERVER CAME UP ON THIS THREAD THAT AGENTBOX DID NOT PUT THERE.
531
+ *
532
+ * The isolation is three tables now -- her servers off by name, the folder's
533
+ * own config layer gated, and Codex's apps connector switched off by feature
534
+ * -- and every one of them is a claim about a version of `codex app-server`.
535
+ * A claim nobody checks is how the last two holes lasted: an empty
536
+ * `mcp_servers` table "disabled" everything for a slice, and `codex_apps` was
537
+ * left running for a slice after that, both without a symptom on any screen.
538
+ *
539
+ * So the result is watched rather than assumed. `starting` is the status that
540
+ * means a process was really launched (`failed` launched nothing, and a
541
+ * `ready` follows a `starting` we have already spoken about), and this is the
542
+ * pipe `troubleCause` reads and the run trace records, so the line reaches her
543
+ * row rather than a log nobody opens.
544
+ *
545
+ * ONCE PER SERVER. A line repeated on every beat is a line she stops reading,
546
+ * which is the 2026-08-05 card flood in a different shape.
547
+ *
548
+ * AND THE WORDING IS LOAD-BEARING. `troubleCause`'s WORKSPACE list matches the
549
+ * bare word "untrusted" and its INTERRUPTED list matches "connection", so a
550
+ * sentence carrying either would relabel every Codex run that died of
551
+ * something else. tests/a-codex-worker-does-not-reach-her-connected-apps
552
+ * pins that against the real classifier rather than against this note.
553
+ */
554
+ const spoken = new Set();
555
+ const sayUnasked = (params) => {
556
+ const name = typeof params?.name === 'string' ? params.name : '';
557
+ if (!name || name === requireMcpServer) return;
558
+ if (params.status !== 'starting' && params.status !== 'ready') return;
559
+ if (spoken.has(name)) return;
560
+ spoken.add(name);
561
+ say(`${Name} did not put the MCP server "${name}" on this run and it started anyway; whatever it offers is outside the command sandbox and outside the approval path.`);
562
+ };
563
+
564
+ const readStoreStatus = (params) => {
565
+ sayUnasked(params);
566
+ if (!requireMcpServer || params?.name !== requireMcpServer) return;
567
+ if (params.status === 'ready') {
568
+ storeIsUp = true;
569
+ for (const wake of waiting.splice(0)) wake(true);
570
+ return;
571
+ }
572
+ if (params.status !== 'failed' && params.status !== 'cancelled') return;
573
+ storeTrouble = String(params.error ?? '').trim() || `it ${params.status}`;
574
+ };
575
+
576
+ const storeReady = () => {
577
+ if (storeIsUp) return Promise.resolve(true);
578
+ return new Promise((resolve) => {
579
+ let settled = false;
580
+ const done = (up) => { if (settled) return; settled = true; clearTimeout(timer); resolve(up); };
581
+ const timer = setTimeout(() => done(false), mcpReadyMs);
582
+ timer?.unref?.();
583
+ waiting.push(done);
584
+ });
585
+ };
586
+
587
+ /**
588
+ * The one ending this worker will ever have.
589
+ *
590
+ * The thread is RELEASED here and not only on a kill: a watch left installed
591
+ * on a finished thread hands every later line carrying that id to a listener
592
+ * for a session that no longer exists, and the transport keeps the whole
593
+ * client alive holding it.
594
+ */
595
+ const end = (code, signal = null, how = null) => {
596
+ if (ended) return;
597
+ ended = true;
598
+ if (worker.threadId) { try { server.unwatch(worker.threadId); } catch { /* already gone */ } }
599
+ worker.emit('exit', code, signal, how);
600
+ };
601
+
602
+ const handlers = {
603
+ onNotification: (method, params) => {
604
+ // The turn id arrives on the notification as well as on the `turn/start`
605
+ // response, and this is the earlier of the two: `turn/start` resolves
606
+ // when the turn is ACCEPTED, and the server can pack `turn/started` in
607
+ // behind that response. `turn/interrupt` needs both ids, so the earliest
608
+ // honest moment to know the second one is here.
609
+ const id = params?.turn?.id ?? params?.turnId;
610
+ if (typeof id === 'string' && id) worker.turnId = id;
611
+
612
+ if (method === 'mcpServer/startupStatus/updated') readStoreStatus(params);
613
+
614
+ worker.emit('event', method, params);
615
+
616
+ if (method !== 'turn/completed' && method !== 'turn/failed') return;
617
+ // `turn/failed` is handled even though the 0.148.0 schema does not list
618
+ // it: failure arrives as `turn/completed` with `turn.status: "failed"`.
619
+ // Dropping a notification means a run that reads as unfinished forever,
620
+ // and being wrong in this direction is free.
621
+ const status = typeof params?.turn?.status === 'string'
622
+ ? params.turn.status
623
+ : (method === 'turn/failed' ? 'failed' : 'completed');
624
+ if (status === 'inProgress') return;
625
+ if (status !== 'completed') say(troubleText(params?.turn));
626
+ end(status === 'completed' ? 0 : 1, null);
627
+ },
628
+ // THE CONNECTION DIED UNDER THIS THREAD, AND THAT IS NOT THIS ITEM'S DOING.
629
+ //
630
+ // The process crashed, the pipe broke, one line nobody could read took the
631
+ // whole app-server down, or Agentbox closed it. `gone.stderr` is the
632
+ // app-server's own tracing tail, which is where Codex's refusal wording
633
+ // actually appears.
634
+ //
635
+ // ONE app-server carries the WHOLE fleet, so every one of these events
636
+ // reaches every live thread at once. Ending them `end(1, null)` -- no
637
+ // signal, nothing saying it was not the item -- had `settleDelivery` read
638
+ // each of them as a run that tried and failed and spend one of that item's
639
+ // three delivery attempts. Three such events and rows she had answered read
640
+ // "stopped", which CLAUDE.md calls the loudest word this app has and says
641
+ // must never mean "and nothing will retry". A whole-turn `turn/diff/updated`
642
+ // is the biggest frame on this wire, so the fault arrives exactly when the
643
+ // most work has been done.
644
+ //
645
+ // `transportFault` is the fact, told rather than faked. A signal would have
646
+ // been the cheap way to reach the same branch and it would be a lie: nothing
647
+ // signalled this worker, and `noteExitForBackoff` must still record the
648
+ // failure against the Codex account, because a broken app-server IS evidence
649
+ // about Codex on this Mac. Only the DELIVERY attempt is spared.
650
+ onClosed: (gone) => {
651
+ say(gone?.stderr);
652
+ end(1, null, { transportFault: true });
653
+ },
654
+ };
655
+ // Only when there is somebody to ask. The transport tests for a FUNCTION and
656
+ // refuses the request outright when it finds none, which is the right answer
657
+ // for a worker whose card would reach nobody.
658
+ if (typeof onApproval === 'function') handlers.onApproval = onApproval;
659
+
660
+ ready
661
+ // A FUNCTION, BECAUSE ONE OF THE PARAMS IS NOT KNOWN YET WHEN THE
662
+ // SUPERVISOR ASKS FOR A WORKER. The MCP servers a thread must switch off
663
+ // are read from the app-server itself, and her servers launch at
664
+ // `thread/start` -- so the list has to be in hand before the start rather
665
+ // than filled in on the second spawn. Whatever `ready` resolves to is
666
+ // handed here, and a plain object is still accepted unchanged.
667
+ .then((settled) => {
668
+ // CANCELLATION IS CHECKED IN FRONT OF EVERY RPC, NOT ONLY AFTER THE FIRST
669
+ // ONE. The first `ended` test used to sit AFTER the start had already been
670
+ // sent, so a worker killed during the handshake still launched a thread,
671
+ // still started whatever MCP processes that thread carries, and still
672
+ // installed a watcher -- all for a run the supervisor had already given
673
+ // up on. A quit kills the whole fleet at once, so this is the ordinary
674
+ // case rather than a corner one.
675
+ if (ended) return null;
676
+ const params = typeof threadParams === 'function' ? threadParams(settled) : threadParams;
677
+ // THE SAME PARAMS ON BOTH ROADS. A resume that carries only the thread id
678
+ // gets her own MCP servers back and no store at all (measured; see
679
+ // `resumeThreadParams`), and a reply is the commonest thing this app does.
680
+ return resumeThreadId
681
+ ? server.resumeThread(resumeThreadParams(resumeThreadId, params), handlers).then(() => ({ threadId: resumeThreadId }))
682
+ : server.startThread(params, handlers);
683
+ })
684
+ .then((started) => {
685
+ if (!started) return null;
686
+ const threadId = started.threadId ?? resumeThreadId ?? null;
687
+ // A THREAD THAT ARRIVES AFTER THE KILL IS STILL A THREAD, AND IT IS OURS
688
+ // TO LET GO OF. `end` releases `worker.threadId`, and at a kill during
689
+ // startup there was not one yet, so the watch this response just installed
690
+ // would have outlived the worker: every later line carrying that id
691
+ // handed to a listener for a session that no longer exists, with the
692
+ // transport kept alive holding it.
693
+ if (ended) {
694
+ if (threadId) { try { server.unwatch(threadId); } catch { /* already gone */ } }
695
+ return null;
696
+ }
697
+ worker.threadId = threadId;
698
+ if (!worker.threadId) throw new Error('codex app-server started a thread with no id');
699
+ return storeReady();
700
+ })
701
+ .then((up) => {
702
+ if (ended) return null;
703
+ if (!up) {
704
+ // The loudest thing this file can do, on the pipe `troubleCause` reads.
705
+ // A run that stops here has written nothing and spent nothing, which is
706
+ // the whole point: the alternative is a worker doing the entire job and
707
+ // filing it nowhere.
708
+ say(`the ${requireMcpServer} store server did not start (${storeTrouble || 'it never reported ready'}), so this run is stopping rather than working with no way to write to her store`);
709
+ end(1, null);
710
+ return null;
711
+ }
712
+ worker.emit('spawn');
713
+ return turnParams.reviewTarget
714
+ ? server.request('review/start', { threadId: worker.threadId, target: turnParams.reviewTarget, delivery: 'inline' })
715
+ : server.startTurn(worker.threadId, turnParams);
716
+ })
717
+ .then((accepted) => {
718
+ const id = accepted?.turn?.id;
719
+ if (typeof id === 'string' && id) worker.turnId = id;
720
+ })
721
+ .catch((err) => {
722
+ // A thread that never started, a resume the server refused, a turn it
723
+ // would not accept. All three are a worker that did no work, and all
724
+ // three carry the sentence that says why -- including the app-server's
725
+ // stderr tail, which main/codex-app-server.mjs appends to every rejection.
726
+ // No signal here: a kill has already ended this worker with one, and
727
+ // `end` only ever fires once, so anything reaching this line is a real
728
+ // failure rather than something we did.
729
+ say(err?.message ?? String(err));
730
+ end(1, null);
731
+ });
732
+
733
+ /**
734
+ * OUR KILL, AND IT LOOKS LIKE ONE FROM THE OUTSIDE.
735
+ *
736
+ * `turn/interrupt` takes BOTH ids -- a turn is not identified by either one
737
+ * alone -- so a kill before the turn id has arrived can only release the
738
+ * thread, which it does. The exit is emitted immediately rather than waiting
739
+ * for the server to confirm: the supervisor kills the whole fleet on quit and
740
+ * on every restart, and an ending that waits on a process we are shutting
741
+ * down is an ending that may never come.
742
+ *
743
+ * `signal` is set here and nowhere else. That is the fact `settleDelivery`
744
+ * reads to keep our own kill from spending one of an item's three delivery
745
+ * attempts, and a later `turn/completed` saying `interrupted` cannot undo it
746
+ * because `end` only fires once.
747
+ */
748
+ worker.kill = () => {
749
+ if (ended) return false;
750
+ if (worker.threadId && worker.turnId) {
751
+ try { server.interruptTurn(worker.threadId, worker.turnId)?.catch?.(() => {}); } catch { /* dying anyway */ }
752
+ }
753
+ // 143 is what the Claude CLI reports for the same event (it traps SIGTERM
754
+ // and exits under its own power), so the two engines hand the exit handler
755
+ // the same pair of numbers for the same thing.
756
+ end(143, 'SIGTERM');
757
+ return true;
758
+ };
759
+
760
+ return worker;
761
+ }
762
+
763
+ /**
764
+ * Why a turn did not finish, in whatever words the server gave us -- AND IN THE
765
+ * ONE WORD IT GIVES US THAT IS NOT PROSE.
766
+ *
767
+ * `TurnError` is `{ message, additionalDetails, codexErrorInfo }`, and
768
+ * `CodexErrorInfo` is an enum: `usageLimitExceeded`, `unauthorized`,
769
+ * `contextWindowExceeded`, `sessionBudgetExceeded`, `other`, and a handful of
770
+ * nested variants carrying an HTTP status. That is off `codex app-server
771
+ * generate-json-schema`, the binary's own generator, run on this Mac against
772
+ * codex-cli 0.148.0 on 2026-09-05.
773
+ *
774
+ * THE CODE WAS BEING THROWN AWAY AND THE SENTENCE CLASSIFIED INSTEAD, which is
775
+ * the wrong way round: shared/spawn-trouble.mjs has to decide whether a dead
776
+ * run was a signed-out login or a limit, and a name published by the vendor
777
+ * cannot drift the way a sentence can. Measured the same day: a signed-out run
778
+ * does not even produce Codex's own words -- the app-server passes the raw HTTP
779
+ * text through, so the prose is the transport's rather than the tool's.
780
+ *
781
+ * IN FRONT, NOT BEHIND. `noteExitForBackoff` classifies the LAST stderr line
782
+ * cut to 300 characters, and the measured 401 message is already 200 of them
783
+ * before any detail is appended; a code on the end would be the first thing
784
+ * lost. Only a plain string code is carried -- a nested variant is an object and
785
+ * would arrive as "[object Object]" -- and `other` is dropped, because it is the
786
+ * schema's own way of saying nothing and would be noise in front of the sentence
787
+ * that says something.
788
+ */
789
+ function troubleText(turn) {
790
+ const why = String(turn?.error?.message ?? '').trim();
791
+ const more = String(turn?.error?.additionalDetails ?? '').trim();
792
+ const info = turn?.error?.codexErrorInfo;
793
+ const code = typeof info === 'string' && info && info !== 'other' ? info : '';
794
+ const head = why || `the turn ${turn?.status ?? 'did not finish'}`;
795
+ const said = code ? `${code}: ${head}` : head;
796
+ return more ? `${said}\n${more}` : said;
797
+ }