agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -0,0 +1,1934 @@
1
+ // ONE CLOUD RUN, AS SEEN FROM THE MAC: A LOCAL PROCESS THAT COMPUTES ELSEWHERE.
2
+ //
3
+ // docs/cloud/PLAN.md. For a cloud run the supervisor spawns this in place of
4
+ // `claude`, with the same argv, env and cwd. To the supervisor it is a worker:
5
+ // what the agent prints arrives on stdout byte for byte, what the supervisor
6
+ // writes to stdin (the prompt, live replies, interrupts, the close at the end)
7
+ // reaches the agent in order, and it exits with the agent's exit code once the
8
+ // agent's work is back in the task folder.
9
+ //
10
+ // What it does in between:
11
+ // 1. Brings home any earlier cloud run in this folder that never came back,
12
+ // before it does anything else; one the app left going when it quit is
13
+ // joined and served until it finishes.
14
+ // 2. Packs the task folder (its branch, main, and any uncommitted work) and
15
+ // the files the agent will need at the same absolute paths: the settings
16
+ // file without the Mac-only memory hooks, an MCP config whose servers are
17
+ // the sandbox's stubs, the transcript a `--resume` needs, the account's
18
+ // skills, commands and agents, her CLAUDE.md and those of her own hooks
19
+ // that can run there, and any file the prompt names.
20
+ // 3. Starts the sandbox, then pumps: events out (stdout, stderr, MCP lines),
21
+ // input in (stdin, MCP replies), every input numbered and kept until the
22
+ // sandbox has acknowledged it, so a resend after a lost reply is applied
23
+ // once and nothing is skipped.
24
+ // 4. Runs the app's MCP servers HERE, from the original config, with the
25
+ // worker's own environment, exactly as Claude Code would have, and joins
26
+ // each to its stub in the sandbox. A call to `look_at_page` is refused:
27
+ // it would look at this Mac's localhost, not the sandbox's.
28
+ // 5. When the sandbox reports its results uploaded, fetches them from S3 and,
29
+ // only if the folder is exactly as it was when the run left, lays them in:
30
+ // the branch moved to the cloud's tip, uncommitted work reapplied,
31
+ // transcripts unpacked. Only then does the marker go. A folder that
32
+ // changed here meanwhile keeps its own work, and the run's goes on a
33
+ // branch `cloud/<item>` (`park`).
34
+ // 6. On SIGTERM (Stop, archive, preemption) it cancels the remote command
35
+ // and still does 5, so the work and the transcript come home and the
36
+ // app's ordinary resume can take it from there. On SIGHUP (the app
37
+ // quitting) it leaves the run going instead (`leave`).
38
+ // 7. Lays what the agent writes in the project's documents folder into it
39
+ // here, during the run and at its end (./docs.mjs).
40
+ //
41
+ // Exit codes: the agent's own, when its work came home. 75 (EX_TEMPFAIL) when
42
+ // the agent may have finished but its work is not here; the marker stays, so
43
+ // shipping and cleanup leave the folder alone, and the app tries again. 77
44
+ // (EX_NOPERM) when the account's cloud sign-in was missing, refused or ran
45
+ // out: the work that was done is home, and the account needs signing in (or
46
+ // its Codex login renewing) before its threads go to the cloud again.
47
+ //
48
+ // Reviewed by Codex in three rounds (2026-10-13); the rules each finding left
49
+ // behind are written where they apply.
50
+
51
+ import fs from 'node:fs';
52
+ import os from 'node:os';
53
+ import path from 'node:path';
54
+ import crypto from 'node:crypto';
55
+ import { StringDecoder } from 'node:string_decoder';
56
+ import { EventEmitter } from 'node:events';
57
+ import { PassThrough } from 'node:stream';
58
+ import { pipeline } from 'node:stream/promises';
59
+ import { spawn } from 'node:child_process';
60
+ import { run } from './exec.mjs';
61
+ import { readMarker, writeMarker, clearMarker, addCloudNews, commonGitDirOf } from './marker.mjs';
62
+ import { Name, nameSlug } from '../../shared/product-name.mjs';
63
+ import { KEPT_ASIDE } from '../../shared/not-shipped.mjs';
64
+ import { codexTranscriptFile } from '../codex-session.mjs';
65
+ import { CLIENT_INFO } from '../codex-app-server.mjs';
66
+ import { appendCloudUsage } from './usage.mjs';
67
+ import { docsFolderFor, docsToSend, tarDocs, layDocs } from './docs.mjs';
68
+ import { troubleCause, limitResetMoment } from '../../shared/spawn-trouble.mjs';
69
+ import { HAND_BACK } from '../../shared/cloud-hand-back.mjs';
70
+ import { sentLine } from '../../shared/cloud-start.mjs';
71
+ import { referencedFiles } from '../../shared/referenced-files.mjs';
72
+ import { serveApprovals } from '../approval-prompt-core.mjs';
73
+ import { cacheKey, folderKey, sendOnce, pickBase, bundlePast, refsAt, sendConversation, sentRecordPath } from './reuse.mjs';
74
+ import { KIT_VERSION, checkKit, kitKey } from '../../shared/cloud-kit.mjs';
75
+ import { NEW_ITEM_MARK } from '../../shared/cloud-kit.mjs';
76
+ import { APPROVALS_SERVER } from '../../shared/cloud-kit.mjs';
77
+ import { asksPermission, approvalsHandshakes } from './kit.mjs';
78
+
79
+ // WHAT A CLOUD RUN IS TOLD ABOUT WHERE IT IS. Without it an agent there found
80
+ // its film's files, ffmpeg and a key missing, and asked the person to move the
81
+ // thread (2026-10-16). Kept true to cloud/sandbox/Dockerfile and ./docs.mjs.
82
+ export const CLOUD_NOTE = [
83
+ '# You are running on a cloud machine, not on the person\'s own computer',
84
+ '',
85
+ 'This turn runs in a Linux cloud sandbox (Debian, 2 cores, 8 GB) that is yours for this run and thrown away after it. It has: this task folder (its git branch and main, at the same path as on their computer); the project\'s documents folder at its usual path, but only its small files (up to 256 KB each), plus this thread\'s own designs folder (its newest files, up to 50 MB) and any attachment the request names; the store tools (look_at_page excepted); git, node, npm, python3 and pip, make, g++, curl, wget, unzip; and Playwright with Chromium already installed (`require(\'playwright\')` works from any folder).',
86
+ '',
87
+ 'Install whatever else the work needs: you have sudo (`sudo apt-get update && sudo apt-get install -y <package>`, `pip install --user`, `npm install`). When you install a program the project will need again, add the same lines to `.agentbox/cloud-setup.sh` in the task folder and commit it: that script runs before every cloud run of this project, and its output from this run is in /tmp/agentbox-cloud-setup.log.',
88
+ '',
89
+ 'It does not have: the project\'s other branches (only this task\'s branch and main came up); any other file from their computer (other threads\' design folders, large media, files under the home folder); files an earlier turn left outside git (node_modules, gitignored folders such as scripts/scratch, /tmp); API keys, key files or tokens kept on their computer; their computer\'s apps (Xcode, a simulator, their browser and its sign-ins). Run `npm ci --ignore-scripts` before anything that needs node_modules. Open files are capped at 1024 here (the cloud fixes that ceiling), so a test runner that watches the whole project (`vitest related`, `npm run test:changed`) fails with EMFILE: run the test files you changed and the ones that import them by name with `npx vitest run <files>`.',
90
+ '',
91
+ `Only this task's own branch comes back to their computer. A commit, merge or reset on main or any other branch stays in this sandbox and is thrown away with it, so a merge done here has not happened. When the request includes merging into main, or changing any branch but this task's, do the rest of the work and commit it on this branch, then end your turn with the one line \`${HAND_BACK} merging into main\` (as below): the app merges on their computer.`,
92
+ '',
93
+ `If this request needs something only their computer has (one of those files, a key, one of their apps, a private repository this machine cannot reach), stop at once. Do not work around it, do not write to the work item, do not give the person commands to run, and do not offer to move the thread: the app moves it. End your turn with one line and nothing else: ${HAND_BACK} followed by what was missing, in a few words they would use (your AWS sign-in, the iOS simulator), since the app shows those words to them. A person who asks to have this thread moved to their computer is answered with this line as well. That line replaces your usual final answer and its options. The app then runs the same request again on their computer, where all of it is. A program you can install is not a reason to stop. Otherwise work as usual, and never write that line.`,
94
+ ].join('\n');
95
+
96
+ // The same, for a project with no repository, whose agent works in the
97
+ // documents folder itself.
98
+ const notes = CLOUD_NOTE.split('\n\n');
99
+ export const PLAIN_CLOUD_NOTE = [
100
+ notes[0],
101
+ 'This turn runs in a Linux cloud sandbox (Debian, 2 cores, 8 GB) that is yours for this run and thrown away after it. It has: the project\'s documents folder, which is the folder you work in, at the same path as on their computer, but only its small files (up to 256 KB each), plus this thread\'s own designs folder (its newest files, up to 50 MB) and any attachment the request names; what you write there goes back to their computer as you work. There is no git repository here. It also has the store tools (look_at_page excepted); git, node, npm, python3 and pip, make, g++, curl, wget, unzip; and Playwright with Chromium already installed (`require(\'playwright\')` works from any folder).',
102
+ 'Install whatever else the work needs: you have sudo (`sudo apt-get update && sudo apt-get install -y <package>`, `pip install --user`, `npm install`).',
103
+ 'It does not have: any other file from their computer (documents over 256 KB, other threads\' design folders, large media, files under the home folder, /tmp from an earlier turn); API keys, key files or tokens kept on their computer; their computer\'s apps (Xcode, a simulator, their browser and its sign-ins).',
104
+ notes[notes.length - 1],
105
+ ].join('\n\n');
106
+
107
+ // The app's own instructions with the cloud note after them.
108
+ export function argvForTheCloud(argv, { plain = false } = {}) {
109
+ const note = plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE;
110
+ const i = argv.indexOf('--append-system-prompt');
111
+ if (i < 0 || i + 1 >= argv.length) return [...argv, '--append-system-prompt', note];
112
+ const out = [...argv];
113
+ out[i + 1] = `${argv[i + 1]}\n\n${note}`;
114
+ return out;
115
+ }
116
+
117
+ // Claude Code keeps a conversation's system text from its first turn, so a
118
+ // conversation begun on the Mac never reads the note on a cloud turn's command
119
+ // line: a resumed run is told at the top of its first message instead.
120
+ export function claudeLineForTheCloud(line, { note }) {
121
+ let msg;
122
+ try { msg = JSON.parse(line); } catch { return line; }
123
+ if (msg?.type !== 'user' || msg.message?.role !== 'user') return line;
124
+ const said = msg.message.content;
125
+ const content = Array.isArray(said) ? [{ type: 'text', text: note }, ...said] : `${note}\n\n${said ?? ''}`;
126
+ return JSON.stringify({ ...msg, message: { ...msg.message, content } }) + (line.endsWith('\n') ? '\n' : '');
127
+ }
128
+
129
+ // How long a cloud account whose limit named no hour sits out.
130
+ const LIMIT_REST_MS = 30 * 60_000;
131
+
132
+ // THE CLOUD ACCOUNT'S LIMIT, off one line of Claude Code's stream: the moment
133
+ // it resets when the line is a failed result that says the account is at its
134
+ // limit, else null. A result that worked is never a limit, whatever it quotes.
135
+ export function limitInResultLine(line, now = Date.now()) {
136
+ let msg;
137
+ try { msg = JSON.parse(line); } catch { return null; }
138
+ if (msg?.type !== 'result' || msg.is_error !== true) return null;
139
+ const text = String(msg.result ?? '');
140
+ if (troubleCause(text) !== 'at-limit') return null;
141
+ return limitResetMoment(text, now) ?? now + LIMIT_REST_MS;
142
+ }
143
+
144
+ export const IMPORT_FAILED = 75;
145
+ export const LOGIN_TROUBLE = 77;
146
+ // A folder the cloud cannot take: the thread runs on this Mac instead, and is
147
+ // not tried in the cloud again (Supervisor#_cloudKeptOnMac).
148
+ export const KEEP_ON_MAC = 78;
149
+ // The cloud server had no room for this person (no plan, hours spent, every
150
+ // agent running, paused, terms not accepted): not a failure of the thread or
151
+ // the cloud, so the thread runs on this Mac meanwhile (Supervisor#_noteCloudNoRoom).
152
+ export const CLOUD_NO_ROOM = 79;
153
+ const NO_ROOM = new Set(['plan', 'hours', 'agents', 'paused', 'terms', 'storage']);
154
+ /** A refused start that is about room, as its exit and what it says; else null. */
155
+ export function refusedForRoom(answer) {
156
+ if (!answer || answer.ok !== false || !NO_ROOM.has(answer.error)) return null;
157
+ return { code: CLOUD_NO_ROOM, line: `no room in the cloud (${answer.error}): ${answer.message || ''}`.trim() };
158
+ }
159
+ // A link to a login is good for half an hour: a run fetches it while it is
160
+ // being prepared, never later.
161
+ const LOGIN_LINK_SECONDS = 30 * 60;
162
+ const STUB = '/opt/agentbox/mcp-stub.mjs';
163
+ // Where the image's sandbox server listens for stubs (cloud/sandbox/server.mjs).
164
+ const BRIDGE_SOCK = '/tmp/agentbox-bridge.sock';
165
+ // The image's node, by its full path: Codex starts an MCP server with a bare
166
+ // environment, in which a bare `node` may not be found.
167
+ const SANDBOX_NODE = '/usr/local/bin/node';
168
+ // Refused when called, but still listed: a tool missing from the list reads
169
+ // to the agent as its whole server having disconnected.
170
+ const WITHHELD_TOOLS = new Set(['look_at_page']);
171
+ const MAX_ATTACHMENT = 25 * 1024 * 1024;
172
+ const RESULTS_WAIT_MS = 3 * 60_000;
173
+ // How often a run's new documents are fetched while it works.
174
+ const DOCS_EVERY_MS = 10_000;
175
+ // Four missed reads of the run's events: asleep, or off the network for good.
176
+ const OFFLINE_AFTER_MS = 90_000;
177
+ export const INPUT_CLOSED_IN_CLOUD = 'agentbox_input_closed_in_cloud';
178
+ // A run the app left going when it quit, and the process bringing it home.
179
+ // The store's writes an agent may make while the Mac is away, kept in the cloud
180
+ // and made when it wakes: what it writes on its thread and its documents.
181
+ // Never a read, which has nothing to answer with, nor a claim, stale by then.
182
+ export const KEPT_WHILE_AWAY = { [nameSlug]: ['update_work_item', 'create_work_item', 'write_document'] };
183
+ // What a server opened afresh to make a kept write is told first.
184
+ const HANDSHAKE_LINES = [
185
+ JSON.stringify({ jsonrpc: '2.0', id: 'kept-hello', method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: `${nameSlug}-cloud`, version: '1' } } }),
186
+ JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
187
+ ];
188
+ const LEFT_RUNNING = new Set(['detached', 'attached']);
189
+ const MAC_GONE = 'The app on the Mac restarted during this call, so its answer was lost. Check whether it took effect, then try again.';
190
+ // How long a run that is being left to carry on waits for its last inputs to
191
+ // reach the sandbox.
192
+ const LEAVE_FLUSH_MS = 10_000;
193
+ // The project's setup in the sandbox (cloud/sandbox/server.mjs), and how long
194
+ // it may take before it is worth saying.
195
+ const SETUP_FILE = '.agentbox/cloud-setup.sh';
196
+ const SETUP_LOG = '/tmp/agentbox-cloud-setup.log';
197
+ const SLOW_SETUP_MS = 3 * 60_000;
198
+
199
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
200
+
201
+ // Where Claude Code keeps a folder's transcripts: every character that is not
202
+ // a letter or a digit becomes '-'. The supervisor's transcriptFile reads the
203
+ // same rule.
204
+ export function transcriptFolderName(cwd) { return cwd.replace(/[^a-zA-Z0-9]/g, '-'); }
205
+
206
+ // What the cloud sends home of a conversation it was given: only what the run
207
+ // added, listed in this file (the same name in cloud/sandbox/server.mjs).
208
+ const TAILS = '.agentbox-tails.json';
209
+
210
+ /**
211
+ * Each tail the cloud sent into `dir`, added to the file it continues. A file
212
+ * that still starts as it was sent becomes the cloud's copy exactly, as when
213
+ * the whole file came home; one that changed here keeps its start and gains
214
+ * the run's lines after it, once: an import that is tried again untars the
215
+ * same tail, and a changed file that already ends with it is left alone. A
216
+ * file gone from here starts again from the copy this Mac sent (`sentCopy`);
217
+ * without that copy the tail stays beside it rather than pose as the whole.
218
+ */
219
+ export async function layTails(dir, say = () => {}, { sentCopy = async () => false } = {}) {
220
+ const list = path.join(dir, TAILS);
221
+ let tails;
222
+ try { tails = JSON.parse(fs.readFileSync(list, 'utf8')); } catch { return; }
223
+ for (const t of Array.isArray(tails) ? tails : []) {
224
+ const tail = plainFileIn(dir, t?.tail);
225
+ if (!tail) continue;
226
+ const file = plainFileIn(dir, t.file, { mayBeMissing: true });
227
+ if (file && Number.isSafeInteger(t.from) && t.from >= 0 && typeof t.sha256 === 'string' && await layTail(file, tail, t, say, sentCopy) === 'kept') continue;
228
+ fs.rmSync(tail, { force: true });
229
+ }
230
+ fs.rmSync(list, { force: true });
231
+ }
232
+
233
+ async function layTail(file, tail, t, say, sentCopy) {
234
+ const adds = fs.statSync(tail).size;
235
+ const size = fs.statSync(file, { throwIfNoEntry: false })?.size ?? -1;
236
+ const name = path.basename(file);
237
+ // Replaced in one rename, so a laying cut off part way leaves the old file.
238
+ const next = `${file}.agentbox-laying`;
239
+ try {
240
+ if (size >= t.from && await sha256OfStart(file, t.from) === t.sha256) {
241
+ fs.copyFileSync(file, next, fs.constants.COPYFILE_FICLONE);
242
+ fs.truncateSync(next, t.from);
243
+ } else if (size < 0) {
244
+ const back = await sentCopy(t, next).catch(() => false) && fs.statSync(next).size === t.from && await sha256OfStart(next, t.from) === t.sha256;
245
+ if (!back) {
246
+ say(`${name} was gone from this Mac while its run was in the cloud; the run's new lines are kept beside it in ${path.basename(tail)}`);
247
+ return 'kept';
248
+ }
249
+ say(`${name} was gone from this Mac while its run was in the cloud; it is laid again from the copy sent with the run`);
250
+ } else if (size >= adds && await endsWith(file, size, tail, adds)) {
251
+ return;
252
+ } else {
253
+ fs.copyFileSync(file, next, fs.constants.COPYFILE_FICLONE);
254
+ say(`${name} changed on this Mac while its run was in the cloud; the run's new lines are added after it`);
255
+ }
256
+ await pipeline(fs.createReadStream(tail), fs.createWriteStream(next, { flags: 'a' }));
257
+ fs.renameSync(next, file);
258
+ } finally {
259
+ fs.rmSync(next, { force: true });
260
+ }
261
+ }
262
+
263
+ // `rel` inside `dir` as a plain file, reached through no link; or, when it may
264
+ // be missing, where one would go.
265
+ function plainFileIn(dir, rel, { mayBeMissing = false } = {}) {
266
+ if (typeof rel !== 'string' || !rel) return null;
267
+ const p = path.resolve(dir, rel);
268
+ try {
269
+ const parent = fs.realpathSync(path.dirname(p));
270
+ const inside = path.relative(fs.realpathSync(dir), path.join(parent, path.basename(p)));
271
+ if (!inside || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return null;
272
+ const st = fs.lstatSync(p, { throwIfNoEntry: false });
273
+ if (!st) return mayBeMissing ? p : null;
274
+ return st.isFile() ? p : null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ async function sha256OfStart(file, bytes) {
281
+ const h = crypto.createHash('sha256');
282
+ if (bytes > 0) for await (const chunk of fs.createReadStream(file, { start: 0, end: bytes - 1 })) h.update(chunk);
283
+ return h.digest('hex');
284
+ }
285
+
286
+ async function endsWith(file, size, tail, bytes) {
287
+ const [a, b] = await Promise.all([fs.promises.open(file, 'r'), fs.promises.open(tail, 'r')]);
288
+ try {
289
+ const x = Buffer.alloc(Math.min(4 * 1024 * 1024, bytes));
290
+ const y = Buffer.alloc(x.length);
291
+ for (let at = 0; at < bytes; at += x.length) {
292
+ const n = Math.min(x.length, bytes - at);
293
+ await a.read(x, 0, n, size - bytes + at);
294
+ await b.read(y, 0, n, at);
295
+ if (!x.subarray(0, n).equals(y.subarray(0, n))) return false;
296
+ }
297
+ return true;
298
+ } finally {
299
+ await a.close();
300
+ await b.close();
301
+ }
302
+ }
303
+
304
+ // THE PROJECT'S SAVED MEMORIES on this Mac, for a task folder that is a
305
+ // linked worktree: Claude Code keeps them under the main checkout's folder
306
+ // name. In the cloud the task folder is a repository of its own, so Claude
307
+ // Code looks under the task folder's name instead (its transcripts' folder).
308
+ // Null when there are none, or the folder is the main checkout.
309
+ export function memoryFolderFor(cwd, configDir) {
310
+ const common = commonGitDirOf(cwd);
311
+ if (!common) return null;
312
+ const root = path.dirname(common);
313
+ if (realOrNull(root) === realOrNull(cwd)) return null;
314
+ const dir = path.join(configDir, 'projects', transcriptFolderName(root), 'memory');
315
+ try { return fs.readdirSync(dir).length ? dir : null; } catch { return null; }
316
+ }
317
+
318
+ function folderWithFiles(dir) {
319
+ try { return fs.readdirSync(dir).length ? dir : null; } catch { return null; }
320
+ }
321
+
322
+ export function argValue(argv, flag) {
323
+ const i = argv.indexOf(flag);
324
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
325
+ }
326
+
327
+ function git(cwd, args, { buffer = false, env, input } = {}) {
328
+ return run('git', args, { cwd, env, input, encoding: buffer ? null : 'utf8' });
329
+ }
330
+ const gitOk = (cwd, args) => git(cwd, args).then(() => true, () => false);
331
+
332
+ // The tree the folder's files make right now, committed or not, without
333
+ // touching the branch, the index or the files.
334
+ async function treeOfFolder(cwd) {
335
+ const index = path.join(os.tmpdir(), `agentbox-cloud-index-${process.pid}-${crypto.randomUUID()}`);
336
+ const env = { ...process.env, GIT_INDEX_FILE: index };
337
+ try {
338
+ await git(cwd, ['read-tree', 'HEAD'], { env });
339
+ await git(cwd, ['add', '-A'], { env });
340
+ return (await git(cwd, ['write-tree'], { env })).trim();
341
+ } finally {
342
+ fs.rmSync(index, { force: true });
343
+ }
344
+ }
345
+
346
+ // What a folder is: its branch, its commit, and its files. A cloud run's work
347
+ // is laid into a folder only if all three are still what the run left with.
348
+ export async function folderState(cwd) {
349
+ return {
350
+ branch: (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(),
351
+ head: (await git(cwd, ['rev-parse', 'HEAD'])).trim(),
352
+ tree: await treeOfFolder(cwd),
353
+ // What is staged, apart from what is on disk: a change staged and then
354
+ // undone on disk would otherwise read as no change at all.
355
+ index: (await git(cwd, ['write-tree'])).trim(),
356
+ };
357
+ }
358
+
359
+ // Commit what the folder holds that is not committed, onto its own ref,
360
+ // without touching the branch, the index or the files.
361
+ async function snapshotUncommitted(cwd, ref) {
362
+ if (!(await git(cwd, ['status', '--porcelain', '--untracked-files=all'])).trim()) return false;
363
+ const tree = await treeOfFolder(cwd);
364
+ const commit = (await git(cwd, ['-c', `user.name=${Name}`, '-c', `user.email=${nameSlug}@localhost`, 'commit-tree', tree, '-p', 'HEAD', '-m', 'Uncommitted work kept by a cloud run'])).trim();
365
+ await git(cwd, ['update-ref', ref, commit]);
366
+ return true;
367
+ }
368
+
369
+ // The remote the sandbox may fetch from: https only, never with credentials
370
+ // in it. An ssh address is turned into its https twin; anything else is left
371
+ // out, and `git fetch origin` in the sandbox simply fails, as it would offline.
372
+ export function remoteForTheCloud(url) {
373
+ const s = String(url || '').trim();
374
+ const ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/.exec(s);
375
+ let u;
376
+ try { u = new URL(ssh ? `https://${ssh[1]}/${ssh[2]}` : s); } catch { return null; }
377
+ if (u.protocol !== 'https:') return null;
378
+ u.username = ''; u.password = '';
379
+ return u.toString();
380
+ }
381
+
382
+ /* ------------------------------ the launch ------------------------------- */
383
+
384
+ // The settings file the supervisor wrote, minus anything that runs a program
385
+ // on THIS Mac: the memory gate's hooks call a local script over a local socket.
386
+ export function settingsForTheCloud(text) {
387
+ let s;
388
+ try { s = JSON.parse(text); } catch { return text; }
389
+ delete s.hooks;
390
+ return JSON.stringify(s, null, 2);
391
+ }
392
+
393
+ // HER OWN HOOKS, the ones that can run in the cloud: one program from the
394
+ // system running a script in her Claude hooks folder, which goes up with the
395
+ // run. Anything else (an app, a script somewhere else, a chain of commands)
396
+ // needs this Mac and stays here. `~` and $HOME become the Mac's home, where the
397
+ // folder is laid, because the agent there has a home of its own. Null when
398
+ // nothing travels. Her words, on the guard that keeps her email out of web
399
+ // requests (2026-10-23): "Can it cover the cloud too?"
400
+ const SYSTEM_PROGRAMS = ['/usr/bin/', '/bin/', '/usr/local/bin/', '/usr/sbin/', '/sbin/'];
401
+ export function hooksForTheCloud(text, { hooksDir, homeDir }) {
402
+ let s;
403
+ try { s = JSON.parse(text); } catch { return null; }
404
+ const kept = {};
405
+ for (const [event, groups] of Object.entries(s?.hooks || {})) {
406
+ if (!Array.isArray(groups)) continue;
407
+ const left = [];
408
+ for (const g of groups) {
409
+ const hooks = (Array.isArray(g?.hooks) ? g.hooks : [])
410
+ .filter((h) => h?.type === 'command' && typeof h.command === 'string')
411
+ .map((h) => ({ ...h, command: h.command.replace(/(^|\s|["'])(?:~|\$HOME|\$\{HOME\})\//g, `$1${homeDir}/`) }))
412
+ .filter((h) => runsOnlyFrom(h.command, hooksDir));
413
+ if (hooks.length) left.push({ ...g, hooks });
414
+ }
415
+ if (left.length) kept[event] = left;
416
+ }
417
+ return Object.keys(kept).length ? kept : null;
418
+ }
419
+
420
+ function runsOnlyFrom(command, hooksDir) {
421
+ if (/[;&|`]|\$\(/.test(command)) return false;
422
+ let script = false;
423
+ for (const word of command.trim().split(/\s+/)) {
424
+ const w = word.replace(/^["']|["']$/g, '');
425
+ if (!w.startsWith('/')) continue;
426
+ const p = path.resolve(w);
427
+ if (p !== hooksDir && inside(p, hooksDir)) script = true;
428
+ else if (!SYSTEM_PROGRAMS.some((d) => w.startsWith(d))) return false;
429
+ }
430
+ return script;
431
+ }
432
+
433
+ export function mcpConfigForTheCloud(config, stubPath = STUB) {
434
+ const servers = {};
435
+ for (const name of Object.keys(config?.mcpServers || {})) servers[name] = { command: 'node', args: [stubPath, name], env: {} };
436
+ return { mcpServers: servers };
437
+ }
438
+
439
+ function realOrNull(p) { try { return fs.realpathSync(p); } catch { return null; } }
440
+ const sameFolder = (a, b) => !!a && !!b && realOrNull(a) !== null && realOrNull(a) === realOrNull(b);
441
+ const inside = (p, root) => p === root || p.startsWith(root + path.sep);
442
+
443
+ // Absolute paths a prompt names that are real files the sandbox cannot have:
444
+ // outside the task folder (which travels in the bundle), under one of the
445
+ // roots the agent is allowed to read, and not too big to send. Containment is
446
+ // decided on the RESOLVED path, after `..` and symlinks, so `<root>/../secret`
447
+ // and a link inside the root pointing out of it are both refused.
448
+ // With no `cwd` (a run whose folder is the documents folder, which travels only
449
+ // in part), a file inside it is sent like any other.
450
+ //
451
+ // Given `docs`, a picture named only by its short link, `![…](attachments/x.png)`,
452
+ // is sent too: the documents folder travels without its attachments (./docs.mjs),
453
+ // and the app writes every pasted picture there.
454
+ export function filesNamedIn(text, { cwd, roots, docs = null }) {
455
+ const realRoots = roots.map(realOrNull).filter(Boolean);
456
+ const realCwd = cwd ? realOrNull(cwd) || cwd : null;
457
+ const found = new Set();
458
+ const take = (named) => {
459
+ const real = realOrNull(named);
460
+ if (!real) return;
461
+ if (realCwd && inside(real, realCwd)) return;
462
+ if (!realRoots.some((r) => inside(real, r))) return;
463
+ try {
464
+ const st = fs.statSync(real);
465
+ if (st.isFile() && st.size <= MAX_ATTACHMENT) found.add(named);
466
+ } catch { /* not a file */ }
467
+ };
468
+ for (const m of String(text || '').matchAll(/(\/(?:[^\s"'`<>()\[\]{},;\\]|\\ )+)/g)) take(m[1].replace(/[.:]+$/, ''));
469
+ const pasted = docs && path.isAbsolute(docs) ? realOrNull(path.join(docs, 'attachments')) : null;
470
+ if (pasted) {
471
+ for (const rel of referencedFiles(...stringsIn(text), { dir: docs })) {
472
+ if (!rel.startsWith('attachments/')) continue;
473
+ const named = path.join(docs, rel);
474
+ const real = realOrNull(named);
475
+ if (real && inside(real, pasted)) take(named);
476
+ }
477
+ }
478
+ return [...found];
479
+ }
480
+
481
+ // The strings of a stream-json line, unescaped: in the raw line a path after a
482
+ // line break reads as `\nattachments/…`.
483
+ function stringsIn(text) {
484
+ let msg;
485
+ try { msg = JSON.parse(text); } catch { return [String(text || '')]; }
486
+ const out = [];
487
+ const walk = (v) => {
488
+ if (typeof v === 'string') out.push(v);
489
+ else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
490
+ };
491
+ walk(msg);
492
+ return out;
493
+ }
494
+
495
+ // COPYFILE_DISABLE: a Mac's tar can add a `._name` beside a file with
496
+ // extended attributes, which the cloud reads as one more skill or command.
497
+ export function tarOf(dir) {
498
+ return run('tar', ['-czf', '-', '-C', dir, '-h', '.'], { encoding: null, env: { ...process.env, COPYFILE_DISABLE: '1' } });
499
+ }
500
+
501
+ /* -------------------------------- the run -------------------------------- */
502
+
503
+ // What a sandbox is sent of a file: its link, not the key it was put under.
504
+ const withoutKeys = (list) => list.map(({ key: _k, ...rest }) => rest);
505
+
506
+ // A NEW THREAD'S KIT is packed in the project's checkout and named for the
507
+ // task folder the thread will have (shared/cloud-kit.mjs fillNewThreadKit):
508
+ // its transcripts' folder, and what was sent into the checkout's, are that
509
+ // folder's.
510
+ function newThreadNames(n, { cwd, startSha, files, joins, bringDirs }) {
511
+ const from = `${path.sep}${transcriptFolderName(cwd)}`;
512
+ const to = `${path.sep}${transcriptFolderName(n.cwd)}`;
513
+ const rename = (p) => (typeof p === 'string' ? p.split(from).join(to) : p);
514
+ const dirs = bringDirs.map((b) => ({ ...b, dir: rename(b.dir) }));
515
+ return {
516
+ files: files.map((f) => ({ ...f, path: rename(f.path), ...(f.untar ? { untar: rename(f.untar) } : {}) })),
517
+ joins: { ...joins, bringDirs: dirs },
518
+ made: { cwd: n.cwd, bringDirs: dirs, newThread: { mainSha: startSha, brief: n.brief, model: n.model, effort: n.effort, ...(n.defaultEngine ? { defaultEngine: n.defaultEngine } : {}) } },
519
+ };
520
+ }
521
+
522
+ // THE KIT, WRITTEN: what the run would have started with, by key, with the
523
+ // reply's place in the turn's prompt and what a computer joining the run
524
+ // needs to write its marker. A copy stays on this computer for that.
525
+ async function writeKit({ storage, kit, say, files, home, joins, made }) {
526
+ const whole = {
527
+ v: KIT_VERSION, madeAt: Date.now(), device: kit.device, project: kit.project, item: kit.item,
528
+ ...made,
529
+ files: files.map((f) => ({ path: f.path, key: f.key, ...(f.untar ? { untar: f.untar } : {}) })),
530
+ homeFiles: home.map((h) => ({ rel: h.rel, key: h.key })),
531
+ frame: kit.frame,
532
+ marker: joins,
533
+ };
534
+ const problem = checkKit(whole);
535
+ if (problem) { say(`no kit for the phone: ${problem}`); return 1; }
536
+ await storage.put(kitKey(kit.device, kit.project, kit.item), JSON.stringify(whole));
537
+ if (kit.copy) {
538
+ fs.mkdirSync(path.dirname(kit.copy), { recursive: true });
539
+ const tmp = `${kit.copy}.${process.pid}.tmp`;
540
+ fs.writeFileSync(tmp, JSON.stringify(whole), { mode: 0o600 });
541
+ fs.renameSync(tmp, kit.copy);
542
+ }
543
+ return 0;
544
+ }
545
+
546
+ export async function cloudRun({
547
+ argv, env = process.env, cwd,
548
+ transport, storage,
549
+ // THE ACCOUNT'S CLOUD SIGN-IN, by name only: { engine, id, key }, the
550
+ // object the sandbox fetches it from (main/cloud/logins.mjs). The login
551
+ // itself is never read here (docs/cloud/PLAN.md, "Sign-ins live in the
552
+ // cloud").
553
+ login = null,
554
+ // Other sign-ins the run carries, in the same shape (./run-cli.mjs
555
+ // `loginsCarriedBy`). The sandbox places them if it can and starts anyway.
556
+ also = [],
557
+ // Told when the sandbox says the login was missing, refused or ran out.
558
+ onLoginTrouble = () => {},
559
+ // Told the moment the account's limit resets, when a run ends on it.
560
+ onLimit = () => {},
561
+ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr,
562
+ remoteCommand = 'claude',
563
+ homeDir = os.homedir(),
564
+ pollWaitMs = 20_000,
565
+ resultsWaitMs = RESULTS_WAIT_MS,
566
+ watchParent = true,
567
+ stubPath = STUB,
568
+ signals = process,
569
+ // Only bring an earlier run home, and start nothing: what the supervisor
570
+ // runs for a folder whose cloud work is still outstanding.
571
+ importOnly = false,
572
+ // 'claude' or 'codex'. A Codex run is `codex app-server` in the sandbox,
573
+ // spoken to over stdin/stdout by the supervisor's own Codex client.
574
+ engine = 'claude',
575
+ docsEveryMs = DOCS_EVERY_MS,
576
+ leaveFlushMs = LEAVE_FLUSH_MS,
577
+ // How long the sandbox waits without word from this Mac before it counts
578
+ // it asleep (cloud/sandbox/server.mjs "WHILE THE MAC IS AWAY").
579
+ offlineAfterMs = OFFLINE_AFTER_MS,
580
+ keptWhileAway = KEPT_WHILE_AWAY,
581
+ // The process that stands for this run, written in its marker so another
582
+ // can tell whether it is still alive: this one, or, when many runs share a
583
+ // host process, the run's own small stand-in (main/cloud/host.mjs).
584
+ pid: selfPid = process.pid,
585
+ // What the cloud image said it can do at its last start (its `caps`), and
586
+ // who to tell what this one says. 'base' lets a run send only the commits
587
+ // past a base the bucket already has (./reuse.mjs).
588
+ imageCaps = [],
589
+ onCaps = () => {},
590
+ // A KIT FOR THE PHONE, not a run (shared/cloud-kit.mjs): { device, project,
591
+ // item, frame, copy }. The pack goes up under the thread's kit and the kit
592
+ // names it; nothing starts, and this folder's marker is left alone.
593
+ kit = null,
594
+ }) {
595
+ const say = (msg) => stderr.write(`agentbox cloud: ${msg}\n`);
596
+ const begunAt = Date.now();
597
+ // Whether the sandbox keeps this run going while the Mac sleeps, so its
598
+ // tool calls are asked about before they are made (`claimCalls`).
599
+ let offlineOn = false;
600
+ const codex = engine === 'codex';
601
+ // A project with no repository: the agent works in the documents folder,
602
+ // and the thread's marker is the file the supervisor named (./marker.mjs).
603
+ const plain = !!env.AGENTBOX_CLOUD_MARKER;
604
+ const here = plain ? { folder: cwd, file: env.AGENTBOX_CLOUD_MARKER } : cwd;
605
+ const configDir = env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude');
606
+ const codexHome = env.CODEX_HOME || path.join(homeDir, '.codex');
607
+ const projectsFor = (dir) => path.join(dir || configDir, 'projects', transcriptFolderName(cwd));
608
+ const projectsDir = projectsFor(configDir);
609
+ // WHAT COMES HOME BESIDES THE FOLDER: the conversation, wherever the
610
+ // harness keeps it. Claude Code: this folder's transcripts under the
611
+ // account. Codex: its sessions folder, which in the sandbox holds only
612
+ // what this run wrote (and the one rollout a resume sent up).
613
+ const bringDirs = codex
614
+ ? [{ name: 'sessions.tgz', dir: path.join(codexHome, 'sessions') }]
615
+ : [{ name: 'projects.tgz', dir: projectsDir }];
616
+ const keyOf = (runId, name) => `runs/${runId}/${name}`;
617
+ // The links a run uploads its results to. The folders are the RUN's, which
618
+ // for a run being brought home are the ones its marker names, not whichever
619
+ // account this process happens to have been started for.
620
+ const resultUrls = async (runId, dirs = bringDirs) => ({
621
+ bundleUrl: await storage.putUrl(keyOf(runId, 'out.bundle')),
622
+ changesUrl: await storage.putUrl(keyOf(runId, 'changes.bundle')),
623
+ docsUrl: await storage.putUrl(keyOf(runId, 'docs.tgz')),
624
+ eventsUrl: await storage.putUrl(keyOf(runId, 'events.jsonl')),
625
+ doneUrl: await storage.putUrl(keyOf(runId, 'done.json')),
626
+ bring: await Promise.all(dirs.map(async (b) => ({ dir: b.dir, url: await storage.putUrl(keyOf(runId, b.name)) }))),
627
+ });
628
+ // A marker written before Codex runs existed names its account folder only.
629
+ const dirsOf = (rec) => rec.bringDirs || [{ name: 'projects.tgz', dir: projectsFor(rec.configDir) }];
630
+
631
+ // 1. An earlier run that never came home comes home first. One the app
632
+ // left going when it quit is JOINED instead, by the process the supervisor
633
+ // starts to bring it home: it serves the run's tools again and waits for it
634
+ // to finish, rather than stopping it.
635
+ if (kit && readMarker(here)) { say('this thread\'s cloud work is still on its way back, so no kit for the phone yet'); return IMPORT_FAILED; }
636
+ const earlier = kit ? null : readMarker(here);
637
+ let joining = null;
638
+ let joinedAt = null;
639
+ // A run whose relay died without a word (the shared host crashed) is left
640
+ // going too, not stopped.
641
+ const orphaned = earlier?.state === 'running' && earlier.pid && earlier.pid !== selfPid && !alive(earlier.pid);
642
+ if (earlier && (LEFT_RUNNING.has(earlier.state) || orphaned)) {
643
+ if (!importOnly) { say('an earlier cloud run in this folder is still going; it comes home before another starts'); return IMPORT_FAILED; }
644
+ if (earlier.pid && earlier.pid !== selfPid && alive(earlier.pid)) { say('an earlier cloud run in this folder is already being brought home'); return IMPORT_FAILED; }
645
+ const st = await retry(() => transport.invoke(earlier.sessionId, { action: 'status', runId: earlier.runId }), { what: 'reach the run the app left going', forever: false });
646
+ if (!st) { say('the cloud could not be reached; the run the app left going is tried again later'); return IMPORT_FAILED; }
647
+ if (st.runId === earlier.runId && (st.state === 'preparing' || st.state === 'running')) { joining = earlier; joinedAt = st; offlineOn = !!earlier.offline; }
648
+ }
649
+ // A run whose own relay is alive is that relay's to bring home. Two laying
650
+ // it in at once each see the other's work as a change made on this Mac.
651
+ if (importOnly && !joining && earlier?.pid && earlier.pid !== selfPid && alive(earlier.pid)) {
652
+ say('this cloud run is already being brought home by its own process');
653
+ return IMPORT_FAILED;
654
+ }
655
+ if (earlier && !joining) {
656
+ // One the app left going that ended while it was away still has its
657
+ // answer to give, from where the app stopped reading.
658
+ const leftGoing = importOnly && (LEFT_RUNNING.has(earlier.state) || orphaned);
659
+ const passOn = (e) => (e.kind === 'stdout' ? write(stdout, Buffer.from(e.data, 'base64')) : undefined);
660
+ const back = await bringHome(earlier, { recovering: true, ...(leftGoing ? { cursor: earlier.cursor ?? 0, replay: passOn } : {}) });
661
+ if (!back.ok) { say(`an earlier cloud run in this folder could not be brought back yet (${back.why}); not starting another`); return IMPORT_FAILED; }
662
+ }
663
+ if (importOnly && !joining) return 0;
664
+ if (!joining && (!login || login.engine !== engine)) { say(`this ${engine === 'codex' ? 'Codex' : 'Claude'} account has not signed in for the cloud`); return LOGIN_TROUBLE; }
665
+
666
+ let runId; let sessionId; let marker; let docs; let sentFiles; let sendFile;
667
+ // Files that are a conversation, sent joined to the copy the bucket has.
668
+ const conversations = new Set();
669
+ let mcpConfig = { mcpServers: {} };
670
+ let serverEnv = env;
671
+ let startCursor = 0; let startN = 0; let stdinClosed = false; let startConns = {};
672
+ const kitPrefix = kit ? kitKey(kit.device, kit.project, kit.item).replace(/\.json$/, '') : null;
673
+ const key = (name) => (kitPrefix ? `${kitPrefix}/${name}` : keyOf(runId, name));
674
+ const roots = [env.AGENTBOX_HOME, env.ZERO_STORE_ROOT, env.STORE_HOME].filter(Boolean).map((r) => path.resolve(r));
675
+ // A QUIT WHILE THE RUN IS STILL STARTING is held until it has started, and
676
+ // then leaves it going like any other (`leave`); one whose start never
677
+ // answered is marked to be joined, not stopped.
678
+ let quitEarly = false;
679
+ const early = () => { quitEarly = true; };
680
+ signals.on('SIGHUP', early);
681
+ // The agent's input is taken from the first moment, so what the app wrote
682
+ // before the run started is kept in order, and saved if the app goes.
683
+ const outbox = [];
684
+ let wake = null;
685
+ const enqueue = (msg) => { outbox.push(msg); wake?.(); };
686
+ // Stdin: the supervisor's stream-json lines, in order, each preceded by the
687
+ // files it names. Decoded as one stream, so a character split across two
688
+ // chunks is never broken.
689
+ const inDecoder = new StringDecoder('utf8');
690
+ let stdinBuf = '';
691
+ let untold = !codex && !!argValue(argv, '--resume');
692
+ if (!joining && !kit) stdin.on('data', (d) => {
693
+ stdinBuf += typeof d === 'string' ? d : inDecoder.write(d);
694
+ let i;
695
+ while ((i = stdinBuf.indexOf('\n')) >= 0) {
696
+ let line = stdinBuf.slice(0, i + 1);
697
+ stdinBuf = stdinBuf.slice(i + 1);
698
+ const named = filesNamedIn(line, { cwd: plain ? null : cwd, roots, docs: env.AGENTBOX_DOCS });
699
+ if (codex) {
700
+ const c = codexLineForTheCloud(line, { stubPath, codexHome, keep: mcpConfig.mcpServers, note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE });
701
+ line = c.line;
702
+ if (c.conversation) conversations.add(c.conversation);
703
+ named.push(...c.files.filter((f) => !named.includes(f)));
704
+ } else if (untold) {
705
+ const was = line;
706
+ line = claudeLineForTheCloud(line, { note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE });
707
+ untold = line === was;
708
+ }
709
+ if (named.length) enqueue({ action: 'files', paths: named });
710
+ enqueue({ action: 'stdin', data: Buffer.from(line).toString('base64') });
711
+ }
712
+ });
713
+ if (!joining && !kit) stdin.on('end', () => {
714
+ if (stdinClosed) return;
715
+ stdinClosed = true;
716
+ stdinBuf += inDecoder.end();
717
+ if (stdinBuf) enqueue({ action: 'stdin', data: Buffer.from(stdinBuf).toString('base64') });
718
+ enqueue({ action: 'stdin', end: true });
719
+ });
720
+ // The app may be gone before the run is: a write to its closed pipe is
721
+ // dropped rather than taking this process down.
722
+ stdout.on?.('error', () => {});
723
+ if (joining) {
724
+ ({ runId, sessionId } = joining);
725
+ const { state: _s, pid: _p, at: _a, cursor: c, n: sent, conns, ...kept } = joining;
726
+ marker = kept;
727
+ docs = joining.docs || null;
728
+ // The app's servers as they are now win over the ones the run started
729
+ // with (the approvals key is new at every start); the run's own record
730
+ // fills in any the app no longer names.
731
+ let now = {};
732
+ try { now = JSON.parse(fs.readFileSync(env.AGENTBOX_MCP_CONFIG, 'utf8')).mcpServers || {}; } catch { /* none given */ }
733
+ mcpConfig = { mcpServers: { ...(joining.mcp || {}), ...now } };
734
+ serverEnv = { ...env, ...(joining.toolEnv || {}) };
735
+ // Numbered on from what the sandbox has applied, which a process that
736
+ // died without saying knows better than its marker.
737
+ void sent;
738
+ startCursor = c || 0; startN = Number(joinedAt.inSeq) || 0; startConns = conns || {};
739
+ stdinClosed = true;
740
+ sentFiles = new Set();
741
+ sendFile = async () => { throw new Error('a joined run sends no files'); };
742
+ writeMarker(here,{ ...marker, pid: selfPid, state: 'attached', cursor: startCursor, n: startN, conns: startConns });
743
+ say('joining the cloud run the app left going, to bring it home when it finishes');
744
+ } else {
745
+ runId = crypto.randomUUID();
746
+ sessionId = `agentbox-${(env.ZERO_ITEM || 'run').replace(/[^a-zA-Z0-9-]/g, '')}-${runId}`;
747
+
748
+ // 2. The pack.
749
+ const docsDir = plain ? (sameFolder(env.AGENTBOX_DOCS, cwd) ? cwd : null) : docsFolderFor(env.AGENTBOX_DOCS, cwd);
750
+ // The supervisor checks the image first (`_cloudTakesPlain`), so this is an
751
+ // image that changed in between.
752
+ if (plain && (!docsDir || !imageCaps.includes('plain'))) {
753
+ say(docsDir ? 'this cloud image cannot take a project with no repository yet; rebuild it (node scripts/cloud-setup.mjs)' : 'a project with no repository runs in its documents folder, and this is not it');
754
+ return IMPORT_FAILED;
755
+ }
756
+ const sentAt = Date.now();
757
+ let sent = null; let branch = null; let startSha = null;
758
+ const outgoing = `refs/agentbox/outgoing/${runId}`;
759
+ let hasOutgoing = false;
760
+ const refs = [];
761
+ if (!plain && kit?.newThread) {
762
+ // A NEW THREAD'S KIT: main, as a task folder is branched from it
763
+ // (main/task-folders.mjs baseRef), under the name of the thread to come.
764
+ // None of the checkout's own branch or work goes.
765
+ const main = (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/heads/main'])) ? 'refs/heads/main'
766
+ : (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/heads/master'])) ? 'refs/heads/master' : null;
767
+ if (!main) { say('this project has no main to start a new thread from'); return 1; }
768
+ startSha = (await git(cwd, ['rev-parse', main])).trim();
769
+ const tree = (await git(cwd, ['rev-parse', `${startSha}^{tree}`])).trim();
770
+ branch = `agentbox/${NEW_ITEM_MARK}`;
771
+ sent = { branch, head: startSha, tree, index: tree };
772
+ refs.push(main);
773
+ if (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/remotes/origin/main'])) refs.push('refs/remotes/origin/main');
774
+ } else if (!plain) {
775
+ try {
776
+ if (await gitOk(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])) throw new Error('a merge in it is not finished');
777
+ sent = await folderState(cwd);
778
+ } catch (err) {
779
+ say(`this folder cannot go to the cloud (${String(err?.message || err).split('\n')[0]}), so the thread runs on this Mac`);
780
+ return KEEP_ON_MAC;
781
+ }
782
+ ({ branch, head: startSha } = sent);
783
+ if (!branch || branch === 'HEAD') { say('the task folder is not on a branch'); return 1; }
784
+ hasOutgoing = await snapshotUncommitted(cwd, outgoing);
785
+ refs.push(`refs/heads/${branch}`);
786
+ if (hasOutgoing) refs.push(outgoing);
787
+ for (const r of ['refs/heads/main', 'refs/remotes/origin/main']) if (r !== `refs/heads/${branch}` && await gitOk(cwd, ['rev-parse', '--verify', '-q', r])) refs.push(r);
788
+ }
789
+ const bundleFile = path.join(os.tmpdir(), `agentbox-cloud-${runId}.bundle`);
790
+ // Against a base the bucket already has, when the image can take one:
791
+ // a reply then sends the commits past it, not the project's whole history.
792
+ const thin = imageCaps.includes('base') && !!storage.exists;
793
+ const packed = (async () => {
794
+ if (plain) return { bundle: false, base: null, refs: null };
795
+ try {
796
+ if (!thin) {
797
+ await git(cwd, ['bundle', 'create', '-q', bundleFile, ...refs]);
798
+ await sendFromFile(storage, key('in.bundle'), bundleFile);
799
+ return { bundle: true, base: null, refs: null };
800
+ }
801
+ const base = await pickBase(cwd, storage, { runId });
802
+ const at = await refsAt(cwd, refs);
803
+ const bundle = !!(await bundlePast(cwd, refs, base.sha, bundleFile));
804
+ if (bundle) await sendFromFile(storage, key('in.bundle'), bundleFile);
805
+ return { bundle, base, refs: at };
806
+ } finally {
807
+ fs.rmSync(bundleFile, { force: true });
808
+ if (hasOutgoing) await gitOk(cwd, ['update-ref', '-d', outgoing]);
809
+ }
810
+ })();
811
+
812
+ const sending = [];
813
+ let fileCount = 0;
814
+ sendFile = async (localPath, remotePath = localPath, body = null, untar = null) => {
815
+ if (!body && conversations.has(localPath)) {
816
+ const { key: k } = await sendConversation(storage, localPath, { record: sentRecordPath(here) });
817
+ return { path: remotePath, url: await storage.getUrl(k), key: k };
818
+ }
819
+ fileCount += 1;
820
+ const k = key(`files/${fileCount}`);
821
+ await storage.put(k, body ?? fs.readFileSync(localPath));
822
+ return { path: remotePath, url: await storage.getUrl(k), key: k, ...(untar ? { untar } : {}) };
823
+ };
824
+ // A folder unchanged since an earlier run is not sent again (./reuse.mjs).
825
+ const sendFolder = async (dir, remotePath, untar) => {
826
+ if (!storage.exists) return sendFile(null, remotePath, await tarOf(dir), untar);
827
+ const { key: k } = await sendOnce(storage, cacheKey(`folder-${await folderKey(dir)}.tgz`), () => tarOf(dir));
828
+ return { path: remotePath, url: await storage.getUrl(k), key: k, untar };
829
+ };
830
+ const settingsPath = argValue(argv, '--settings');
831
+ if (settingsPath && fs.existsSync(settingsPath)) sending.push(sendFile(settingsPath, settingsPath, Buffer.from(settingsForTheCloud(fs.readFileSync(settingsPath, 'utf8')))));
832
+ const mcpPath = argValue(argv, '--mcp-config');
833
+ if (mcpPath && fs.existsSync(mcpPath)) {
834
+ mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
835
+ sending.push(sendFile(mcpPath, mcpPath, Buffer.from(JSON.stringify(mcpConfigForTheCloud(mcpConfig, stubPath)))));
836
+ }
837
+ const resumeId = argValue(argv, '--resume');
838
+ if (resumeId) {
839
+ const t = path.join(projectsDir, `${resumeId}.jsonl`);
840
+ if (!fs.existsSync(t)) say(`no transcript for ${resumeId} on this Mac; the cloud run starts without it`);
841
+ else { conversations.add(t); sending.push(sendFile(t)); }
842
+ }
843
+ // Her saved memories for this project, where Claude Code in the cloud looks.
844
+ const saved = codex ? null : plain ? folderWithFiles(path.join(projectsDir, 'memory')) : memoryFolderFor(cwd, configDir);
845
+ if (saved) sending.push(sendFolder(saved, path.join(projectsDir, '.memory.tgz'), path.join(projectsDir, 'memory')));
846
+ // The account's own skills, commands and agents, as real folders.
847
+ if (!codex) {
848
+ const tooling = path.join(homeDir, '.claude');
849
+ const own = (name) => (fs.existsSync(path.join(configDir, name)) ? path.join(configDir, name) : path.join(tooling, name));
850
+ for (const sub of ['skills', 'commands', 'agents']) {
851
+ const dir = own(sub);
852
+ if (fs.existsSync(dir)) sending.push(sendFolder(dir, path.join(configDir, `.${sub}.tgz`), path.join(configDir, sub)));
853
+ }
854
+ // Her own hooks that can run there (`hooksForTheCloud`), with the folder
855
+ // their scripts are in at its own path, so a guard she set on this Mac
856
+ // guards the cloud run too. The sandbox's config folder has no settings of
857
+ // its own, so these are all of them.
858
+ const hooksDir = own('hooks');
859
+ let mine = null;
860
+ try { mine = hooksForTheCloud(fs.readFileSync(own('settings.json'), 'utf8'), { hooksDir, homeDir }); } catch { /* no settings */ }
861
+ if (mine && fs.existsSync(hooksDir)) {
862
+ sending.push(sendFolder(hooksDir, path.join(path.dirname(hooksDir), '.hooks.tgz'), hooksDir));
863
+ sending.push(sendFile(null, path.join(configDir, 'settings.json'), Buffer.from(JSON.stringify({ hooks: mine }, null, 2))));
864
+ }
865
+ // Her own instructions to Claude, from the home folder whichever account
866
+ // it runs on, laid in the config folder, which is where Claude Code reads
867
+ // them: in the home folder there they were reported gone (2026-10-23).
868
+ const memory = path.join(tooling, 'CLAUDE.md');
869
+ if (fs.existsSync(memory) && fs.statSync(memory).size <= MAX_ATTACHMENT) sending.push(sendFile(memory, path.join(configDir, 'CLAUDE.md')));
870
+ }
871
+ // The few Codex settings that change how it runs a turn (model, effort,
872
+ // tier), so a run in the cloud behaves as one here would. Nothing else of
873
+ // her config goes: no MCP servers, hooks, providers or sockets, and never
874
+ // her sign-in: the cloud has its own.
875
+ if (codex) {
876
+ let toml = '';
877
+ try { toml = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); } catch { /* none */ }
878
+ sending.push(sendFile(null, path.join(codexHome, 'config.toml'), Buffer.from(codexConfigForTheCloud(toml))));
879
+ }
880
+ // The project's documents folder (main/cloud/docs.mjs), at its own path.
881
+ let docsSent = [];
882
+ if (docsDir) {
883
+ docsSent = docsToSend(docsDir, { item: env.ZERO_ITEM });
884
+ if (docsSent.length) sending.push(tarDocs(docsDir, docsSent).then((tgz) => sendFile(null, path.join(docsDir, '.agentbox-docs.tgz'), tgz, docsDir)));
885
+ }
886
+ const system = argValue(argv, '--append-system-prompt') || '';
887
+ for (const f of filesNamedIn(system, { cwd: plain ? null : cwd, roots })) sending.push(sendFile(f));
888
+ const codexOpen = kit?.codex && codex ? codexKitOpen(kit.codex, { stubPath, codexHome, keep: mcpConfig.mcpServers, note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE }) : null;
889
+ if (codexOpen) {
890
+ for (const c of codexOpen.conversations) conversations.add(c);
891
+ for (const f of codexOpen.files) sending.push(sendFile(f));
892
+ }
893
+ let pack; let files;
894
+ try {
895
+ [pack, files] = await Promise.all([packed, Promise.all(sending)]);
896
+ } catch (err) {
897
+ // The cloud server refuses a link that writes for the same reasons it
898
+ // refuses a start, and before anything has gone up.
899
+ const noRoom = refusedForRoom({ ok: false, error: err?.error, message: err?.said });
900
+ if (!noRoom) throw err;
901
+ say(noRoom.line);
902
+ return noRoom.code;
903
+ }
904
+ const packedAt = Date.now();
905
+ sentFiles = new Set(files.map((f) => f.path));
906
+
907
+ const results = kit ? null : await resultUrls(runId);
908
+ let remote = null;
909
+ if (!plain) try { remote = remoteForTheCloud((await git(cwd, ['remote', 'get-url', 'origin'])).trim()); } catch { /* no origin */ }
910
+
911
+ const remoteEnv = {
912
+ ...(codex ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: configDir }),
913
+ MCP_TOOL_TIMEOUT: String(8 * 60 * 60 * 1000),
914
+ DISABLE_AUTOUPDATER: '1',
915
+ ...(env.ZERO_PRODUCT ? { ZERO_PRODUCT: env.ZERO_PRODUCT } : {}),
916
+ ...(env.ZERO_ITEM ? { ZERO_ITEM: env.ZERO_ITEM } : {}),
917
+ ...(await gitIdentity(cwd)),
918
+ };
919
+
920
+ // What this run has laid into the documents folder so far, kept with the
921
+ // marker so a run brought home after a restart knows its own files.
922
+ // `sent` is what it took, so a file it did not take is never written over.
923
+ docs = docsDir ? { dir: docsDir, sentAt, sent: docsSent, laid: {}, ack: null } : null;
924
+ // And what a process joining it after a restart needs to serve its tools:
925
+ // the servers, and the worker's own variables they ran with.
926
+ marker = { runId, sessionId, branch, startSha, sent, configDir, engine, bringDirs, docs, mcp: mcpConfig.mcpServers, toolEnv: toolVariables(env), ...(plain ? { plain: true } : {}) };
927
+ if (kit) {
928
+ signals.off('SIGHUP', early);
929
+ const { runId: _r, sessionId: _s, ...joins } = marker;
930
+ const named = kit.newThread ? newThreadNames(kit.newThread, { cwd, startSha, files, joins, bringDirs }) : null;
931
+ return writeKit({
932
+ storage, kit, say, files: named?.files ?? files, home: [], joins: named?.joins ?? joins,
933
+ made: {
934
+ cwd, engine, ...(plain ? { plain: true } : { branch }), startSha,
935
+ bundleKey: pack.bundle ? key('in.bundle') : null,
936
+ ...(pack.base ? { base: { key: pack.base.key, sha: pack.base.sha }, refs: pack.refs } : {}),
937
+ argv: [remoteCommand, ...(remoteCommand === 'claude' ? argvForTheCloud(argv, { plain }) : argv)], env: remoteEnv,
938
+ ...(hasOutgoing ? { changesRef: outgoing } : {}),
939
+ ...(remote ? { remote } : {}),
940
+ ...(docsDir ? { docs: docsDir } : {}),
941
+ login: { engine: login.engine, id: login.id, key: login.key },
942
+ bringDirs, kept: keptWhileAway,
943
+ // A conversation begun on the Mac is told it is in the cloud at the top of its message.
944
+ note: untold ? (plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE) : null,
945
+ // A run that asks permission has its cards held for her phone while the computer is shut.
946
+ ...(!codex && asksPermission(argv) ? { hold: [APPROVALS_SERVER], handshakes: { [APPROVALS_SERVER]: await approvalsHandshakes() } } : {}),
947
+ ...(codexOpen ? { codex: { open: codexOpen.lines, [kit.codex.start ? 'startId' : 'resumeId']: CODEX_RESUME_ID, firstTurnId: CODEX_RESUME_ID + 1, turn: kit.codex.turn } } : {}),
948
+ ...(named ? named.made : {}),
949
+ },
950
+ });
951
+ }
952
+ writeMarker(here,{ ...marker, pid: selfPid, state: 'starting' });
953
+
954
+ // 3. Start. Starting the same run twice is answered "already", so a start
955
+ // whose reply was lost is simply asked again. A start that was REFUSED
956
+ // started nothing; one whose answer never came may have, so its marker
957
+ // stays and the next run in this folder finds out which.
958
+ const bundleUrl = pack.bundle ? await storage.getUrl(key('in.bundle')) : undefined;
959
+ const base = pack.base ? { url: await storage.getUrl(pack.base.key), sha: pack.base.sha } : null;
960
+ const loginUrl = await storage.getUrl(login.key, LOGIN_LINK_SECONDS);
961
+ const carried = await Promise.all(also.map(async (l) => ({ engine: l.engine, id: l.id, url: await storage.getUrl(l.key, LOGIN_LINK_SECONDS) })));
962
+ const started = await retry(() => transport.invoke(sessionId, {
963
+ action: 'start', runId, cwd, branch, bundleUrl,
964
+ ...(plain ? { plain: true } : {}),
965
+ ...(base ? { base, refs: pack.refs } : {}),
966
+ argv: [remoteCommand, ...(remoteCommand === 'claude' ? argvForTheCloud(argv, { plain }) : argv)], env: remoteEnv, files: withoutKeys(files),
967
+ changesRef: hasOutgoing ? outgoing : undefined,
968
+ ...(remote ? { remote } : {}),
969
+ ...(docsDir ? { docs: docsDir } : {}),
970
+ login: { engine: login.engine, id: login.id, url: loginUrl },
971
+ ...(carried.length ? { also: carried } : {}),
972
+ results,
973
+ // A conversation comes home as only what the run added (`layTails`). An
974
+ // image that does not know this ignores it and sends it whole.
975
+ tails: true,
976
+ // The run carries on while this Mac sleeps: a tool call is told it could
977
+ // not reach the app, and a Claude Code turn that has ended has its input
978
+ // closed there. A Codex turn ends only with the Mac, as before.
979
+ offline: { afterMs: offlineAfterMs, endTurn: codex ? null : 'claude-stream-json', kept: keptWhileAway },
980
+ }), { what: 'start the cloud run', forever: false });
981
+ if (!started) {
982
+ signals.off('SIGHUP', early);
983
+ if (quitEarly) {
984
+ const unsent = outbox.filter((m) => m.action === 'stdin');
985
+ writeMarker(here,{ ...marker, pid: null, state: 'detached', cursor: 0, conns: {}, unsent: stdinClosed ? unsent : [...unsent, { action: 'stdin', end: true }] });
986
+ say('the app quit while the cloud run was starting; it is joined when the app next opens');
987
+ return IMPORT_FAILED;
988
+ }
989
+ writeMarker(here,{ ...marker, pid: null, state: 'start-unknown' });
990
+ say('the cloud did not answer the start; whether it began will be checked before the next run here');
991
+ return IMPORT_FAILED;
992
+ }
993
+ if (!started.ok) {
994
+ signals.off('SIGHUP', early);
995
+ // An image that refused a start sent against a base, or with no
996
+ // repository, may not take one.
997
+ // Such a start is tried again without them, so the reply is not spent.
998
+ const outgrown = (base || plain) && started.error === 'bad-start';
999
+ if (outgrown) { try { onCaps(started.caps || []); } catch { /* a hint */ } }
1000
+ const noRoom = refusedForRoom(started);
1001
+ say(noRoom ? noRoom.line : `the cloud run could not start: ${started.error || 'no answer'} ${started.message || ''}`.trim());
1002
+ clearMarker(here);
1003
+ if (noRoom) return noRoom.code;
1004
+ return outgrown ? IMPORT_FAILED : 1;
1005
+ }
1006
+ stderr.write(sentLine({ packMs: packedAt - begunAt, startMs: Date.now() - packedAt }));
1007
+ try { onCaps(started.caps || []); } catch { /* only a hint for the next run */ }
1008
+ offlineOn = (started.caps || []).includes('offline');
1009
+ if (!offlineOn) say('this cloud image stops at its next tool call while this Mac sleeps; rebuild it (node scripts/cloud-setup.mjs) to keep runs going');
1010
+ marker.offline = offlineOn;
1011
+ writeMarker(here,{ ...marker, pid: selfPid, state: 'running' });
1012
+ }
1013
+
1014
+ // 4. The pump.
1015
+ let cursor = startCursor;
1016
+ let n = startN;
1017
+ let leaving = false;
1018
+ let cancelled = false;
1019
+ let cancelledAt = 0;
1020
+ let finished = null; // 'collected' | 'collect-failed' | 'failed' | 'lost'
1021
+ let loginTrouble = null; // { state: 'missing'|'refused'|'expired', id }
1022
+ const servers = new Map(); // conn -> { proc, name, init, pending, swallow }
1023
+ let awayNoted = false;
1024
+ // Calls in this page the sandbox said not to make, by event number.
1025
+ const refused = new Set();
1026
+ async function claimCalls(events) {
1027
+ const asks = [];
1028
+ for (const e of events) {
1029
+ if (e.kind !== 'mcp' || e.seq <= cursor) continue;
1030
+ try { const msg = JSON.parse(e.line); if (msg?.method !== undefined && msg.id !== undefined) asks.push({ seq: e.seq, conn: e.conn, id: msg.id }); } catch { /* not a call */ }
1031
+ }
1032
+ if (!asks.length) return;
1033
+ const res = await retry(() => transport.invoke(sessionId, { action: 'mcp-claim', runId, calls: asks.map(({ conn, id }) => ({ conn, id })) }), { what: 'ask about its tool calls', forever: true, stop: () => overdue() || leaving });
1034
+ const said = new Map((res?.calls || []).map((c) => [`${c.conn}\u0000${JSON.stringify(c.id)}`, c.go]));
1035
+ for (const a of asks) if (said.get(`${a.conn}\u0000${JSON.stringify(a.id)}`) === false) refused.add(a.seq);
1036
+ }
1037
+ const outDecoder = new StringDecoder('utf8');
1038
+ let outLine = '';
1039
+ const overdue = () => cancelled && Date.now() - cancelledAt > resultsWaitMs;
1040
+
1041
+ // ONE INPUT AT A TIME, KEPT UNTIL ACKNOWLEDGED. Each gets its number once,
1042
+ // and is sent again with that same number until the sandbox has applied it
1043
+ // (or says it was consumed with nothing to apply it to). A gap means the two
1044
+ // sides disagree about what was sent, which nothing can repair: the run is
1045
+ // stopped rather than left talking past itself.
1046
+ const sender = (async () => {
1047
+ for (;;) {
1048
+ if (!outbox.length) {
1049
+ if (finished || leaving) return;
1050
+ await new Promise((r) => { wake = r; setTimeout(r, 1000); });
1051
+ wake = null;
1052
+ continue;
1053
+ }
1054
+ const msg = outbox[0];
1055
+ if (msg.action === 'files' && !msg.files) {
1056
+ // An attachment that cannot be uploaded is left out and said so; the
1057
+ // prompt behind it still goes, rather than everything stopping here.
1058
+ const list = [];
1059
+ for (const f of msg.paths) {
1060
+ if (sentFiles.has(f)) continue;
1061
+ const sentOne = await retry(() => sendFile(f), { what: `send ${path.basename(f)}`, forever: false, stop: () => !!finished });
1062
+ if (sentOne) { list.push(sentOne); sentFiles.add(f); } else say(`${f} could not be sent to the cloud run; it goes on without it`);
1063
+ }
1064
+ if (!list.length) { outbox.shift(); continue; }
1065
+ msg.files = withoutKeys(list);
1066
+ }
1067
+ if (msg.n === undefined) { n += 1; msg.n = n; }
1068
+ const { paths: _p, onAck: _a, ...payload } = msg;
1069
+ let failures = 0;
1070
+ for (;;) {
1071
+ const res = await retry(() => transport.invoke(sessionId, { ...payload, runId }), { what: msg.action, forever: true, stop: () => !!finished || overdue() });
1072
+ if (!res) return;
1073
+ if (res.ok || res.dup || res.consumed) { msg.onAck?.(); break; }
1074
+ if (res.error === 'no-run') return;
1075
+ if (res.error === 'gap') { say(`the sandbox and this Mac disagree about what was sent (it expected ${res.expect}, this was ${msg.n}); stopping the run`); outbox.length = 0; cancel('input out of step'); return; }
1076
+ failures += 1;
1077
+ if (failures >= 5) { say(`could not ${msg.action} after 5 tries (${res.error} ${res.message || ''}); stopping the run`); outbox.length = 0; cancel('input refused'); return; }
1078
+ await sleep(1000 * failures);
1079
+ }
1080
+ outbox.shift();
1081
+ }
1082
+ })().catch((err) => say(`the input to the cloud run stopped: ${err.message}`));
1083
+
1084
+ // THE DOCUMENTS, WHILE IT WORKS. A picture the agent draws reaches the Mac
1085
+ // within seconds rather than when the run ends. Each archive holds every
1086
+ // file the Mac has not yet said it laid (`ack`), so an archive lost on the
1087
+ // way is simply in the next one, and the last one at the end.
1088
+ let docsCount = 0;
1089
+ const docsLoop = docs ? (async () => {
1090
+ for (;;) {
1091
+ const until = Date.now() + docsEveryMs;
1092
+ while (Date.now() < until && !finished && !cancelled && !leaving) await sleep(Math.min(500, docsEveryMs));
1093
+ if (finished || cancelled || leaving) return;
1094
+ docsCount += 1;
1095
+ // Named by this process as well, so a process joining the run later
1096
+ // never writes over an archive an earlier one has yet to fetch.
1097
+ const k = key(`docs/${process.pid}-${Date.now()}-${docsCount}.tgz`);
1098
+ const res = await retry(async () => transport.invoke(sessionId, { action: 'docs', runId, id: k, ack: docs.ack, url: await storage.putUrl(k) }), { what: 'read the run\'s documents', forever: false, stop: () => !!finished || leaving });
1099
+ if (!res?.ok || !res.files?.length) continue;
1100
+ const tgz = await storage.get(k).catch(() => null);
1101
+ if (!tgz) continue;
1102
+ // Confirmed only once laid: a write that failed here goes again.
1103
+ if (!(await layArchive(docs, tgz))) continue;
1104
+ docs.ack = k;
1105
+ saveMarker();
1106
+ }
1107
+ })().catch((err) => say(`the run's documents stopped coming back early: ${err.message}`)) : null;
1108
+
1109
+
1110
+ // `init` is the handshake an earlier process had with this connection's
1111
+ // server, said again to a fresh one so a joined run's tools answer as before;
1112
+ // its replies are not passed on, the agent had them long ago.
1113
+ function openServer(conn, name, init = []) {
1114
+ const cfg = mcpConfig.mcpServers?.[name];
1115
+ if (!cfg) { enqueue({ action: 'mcp-close', conn }); return; }
1116
+ const proc = servedHere(cfg, toolEnv({ ...serverEnv, ...(cfg.env || {}) }), `${runId}-${conn}`)
1117
+ ?? spawn(cfg.command, cfg.args || [], { cwd, env: toolEnv({ ...serverEnv, ...(cfg.env || {}) }), stdio: ['pipe', 'pipe', 'pipe'] });
1118
+ const s = { proc, name, buf: '', closed: false, init: [...init], pending: new Set(), swallow: new Set() };
1119
+ servers.set(conn, s);
1120
+ for (const line of init) {
1121
+ try { const m = JSON.parse(line); if (m?.id !== undefined && m.method) s.swallow.add(m.id); } catch { /* sent as it is */ }
1122
+ proc.stdin.write(line + '\n');
1123
+ }
1124
+ const closeOnce = () => { if (s.closed) return; s.closed = true; if (servers.get(conn) === s) servers.delete(conn); enqueue({ action: 'mcp-close', conn }); };
1125
+ proc.stdout.setEncoding('utf8');
1126
+ proc.stdout.on('data', (d) => {
1127
+ s.buf += d;
1128
+ let i;
1129
+ while ((i = s.buf.indexOf('\n')) >= 0) {
1130
+ const line = s.buf.slice(0, i);
1131
+ s.buf = s.buf.slice(i + 1);
1132
+ let reply = null;
1133
+ try { reply = JSON.parse(line); } catch { /* passed on as it is */ }
1134
+ // A call stays owed until the sandbox has its answer, so a restart
1135
+ // before then answers it rather than forgetting it.
1136
+ let onAck;
1137
+ if (reply && reply.id !== undefined && !reply.method) {
1138
+ if (s.swallow.delete(reply.id)) {
1139
+ const kept = keptOut.get(`${conn}\0${reply.id}`);
1140
+ if (kept) { kept(); if (reply.error || reply.result?.isError) say(`a write the run made while this Mac was asleep was turned down: ${reply.error?.message ?? reply.result?.content?.map((c) => c.text).join(' ') ?? 'no reason given'}`); }
1141
+ continue;
1142
+ }
1143
+ onAck = () => s.pending.delete(reply.id);
1144
+ }
1145
+ enqueue({ action: 'mcp', conn, line, onAck });
1146
+ }
1147
+ });
1148
+ proc.stderr.on('data', (d) => stderr.write(d));
1149
+ proc.stdin.on('error', () => {});
1150
+ // `close`, after its stdout has ended, so the server's last reply is
1151
+ // queued before the stub is told it has gone.
1152
+ proc.on('close', closeOnce);
1153
+ proc.on('error', (err) => { say(`the ${name} server could not run here: ${err.message}`); closeOnce(); });
1154
+ }
1155
+
1156
+ // A write the run made while this Mac was away, kept in the cloud: made now
1157
+ // through the same server, its answer dropped (the agent had one).
1158
+ const keptOut = new Map();
1159
+ let keptMade = 0;
1160
+ function makeKept(e) {
1161
+ let msg = null;
1162
+ try { msg = JSON.parse(e.line); } catch { return; }
1163
+ if (!msg || msg.method !== 'tools/call') return;
1164
+ if (!servers.get(e.conn) || servers.get(e.conn).closed) openServer(e.conn, e.server, HANDSHAKE_LINES);
1165
+ const s = servers.get(e.conn);
1166
+ if (!s) return;
1167
+ const id = `kept-${e.seq}`;
1168
+ s.swallow.add(id);
1169
+ keptOut.set(`${e.conn}\0${id}`, () => keptOut.delete(`${e.conn}\0${id}`));
1170
+ s.proc.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);
1171
+ keptMade += 1;
1172
+ }
1173
+ async function keptSettled(ms) {
1174
+ for (const end = Date.now() + ms; keptOut.size && Date.now() < end;) await sleep(50);
1175
+ if (keptMade) say(`${keptMade} ${keptMade === 1 ? 'thing' : 'things'} the run wrote while this Mac was asleep ${keptMade === 1 ? 'was' : 'were'} made now`);
1176
+ }
1177
+
1178
+ function toServer(conn, line) {
1179
+ const s = servers.get(conn);
1180
+ if (!s) return;
1181
+ let msg = null;
1182
+ try { msg = JSON.parse(line); } catch { /* pass through as is */ }
1183
+ if ((msg?.method === 'initialize' || msg?.method === 'notifications/initialized') && s.init.length < 2) s.init.push(line);
1184
+ if (msg?.method && msg.id !== undefined) s.pending.add(msg.id);
1185
+ if (msg?.method === 'tools/call' && WITHHELD_TOOLS.has(msg.params?.name)) {
1186
+ enqueue({ action: 'mcp', conn, line: JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { isError: true, content: [{ type: 'text', text: `${msg.params.name} is not available to a cloud run: it would look at the Mac's own pages, not the sandbox's.` }] } }) });
1187
+ return;
1188
+ }
1189
+ s.proc.stdin.write(line + '\n');
1190
+ }
1191
+
1192
+ function noteLimit(buf) {
1193
+ outLine += outDecoder.write(buf);
1194
+ let i;
1195
+ while ((i = outLine.indexOf('\n')) >= 0) {
1196
+ const line = outLine.slice(0, i);
1197
+ outLine = outLine.slice(i + 1);
1198
+ if (!line.includes('"result"')) continue;
1199
+ const until = limitInResultLine(line);
1200
+ if (until) { try { onLimit({ until }); } catch { /* noted elsewhere */ } }
1201
+ }
1202
+ }
1203
+
1204
+ async function write(stream, buf) {
1205
+ if (!stream.write(buf)) await new Promise((r) => stream.once('drain', r));
1206
+ }
1207
+
1208
+ async function absorb(e) {
1209
+ if (e.seq <= cursor) return;
1210
+ cursor = e.seq;
1211
+ // AGENTBOX_CLOUD_DEBUG=1 traces the tool traffic, names and sizes only.
1212
+ if (env.AGENTBOX_CLOUD_DEBUG && e.kind.startsWith('mcp')) say(`${e.kind} ${e.conn}${e.server ? ` ${e.server}` : ''}${e.line ? ` ${String(e.line).slice(0, 80)}` : ''}`);
1213
+ // A joined run's stream goes to the app too: it is how its thread hears
1214
+ // the steps and the answer made while the app was away
1215
+ // (main/supervisor.mjs `_followComingHome`).
1216
+ if (e.kind === 'stdout') {
1217
+ const buf = Buffer.from(e.data, 'base64');
1218
+ if (!codex) noteLimit(buf);
1219
+ await write(stdout, buf);
1220
+ }
1221
+ else if (e.kind === 'stderr') { if (!joining) await write(stderr, Buffer.from(e.data, 'base64')); }
1222
+ else if (e.kind === 'mcp-open') openServer(e.conn, e.server);
1223
+ else if (e.kind === 'mcp') { if (!refused.has(e.seq)) toServer(e.conn, e.line); refused.delete(e.seq); }
1224
+ else if (e.kind === 'mcp-kept') makeKept(e);
1225
+ else if (e.kind === 'mcp-offline' || e.kind === 'mcp-abandoned') {
1226
+ if (!awayNoted) { awayNoted = true; say('this Mac was asleep; the cloud run carried on, and the tool calls it made meanwhile did not reach the app'); }
1227
+ }
1228
+ // Its input closed in the cloud at the end of a turn while this Mac was
1229
+ // away: said to the app's own reader (main/claude-input.mjs) as the end
1230
+ // of the run it is.
1231
+ else if (e.kind === 'input-closed') { if (!joining) await write(stdout, Buffer.from(`${JSON.stringify({ type: 'system', subtype: INPUT_CLOSED_IN_CLOUD })}\n`)); }
1232
+ else if (e.kind === 'mcp-close') { const s = servers.get(e.conn); servers.delete(e.conn); if (s) { s.closed = true; s.proc.stdin.end(); setTimeout(() => s.proc.kill(), 2000); } }
1233
+ else if (e.kind === 'error') say(e.message);
1234
+ else if (e.kind === 'setup') noteSetup(e);
1235
+ else if (e.kind === 'login') loginTrouble = { state: e.state, id: e.id ?? login?.id ?? null };
1236
+ else if (e.kind === 'state' && ['collected', 'collect-failed', 'failed'].includes(e.state)) finished = e.state;
1237
+ }
1238
+
1239
+ // THE PROJECT'S OWN SETUP (.agentbox/cloud-setup.sh), which the sandbox
1240
+ // runs before the agent and goes on past when it fails or overruns. Said
1241
+ // here, or a broken or slow setup is invisible from the thread.
1242
+ let setupAt = null;
1243
+ function noteSetup(e) {
1244
+ if (e.state === 'running') { setupAt = e.at ?? Date.now(); return; }
1245
+ const where = `its output is in ${SETUP_LOG} on the cloud machine`;
1246
+ if (e.state === 'failed') {
1247
+ const how = e.message || (e.signal === 'SIGKILL' ? `${SETUP_FILE} ran past its time limit and was stopped` : e.signal ? `${SETUP_FILE} was stopped by ${e.signal}` : `${SETUP_FILE} exited with ${e.code}`);
1248
+ say(`the project's cloud setup failed: ${how}; the agent went on without it, and ${where}`);
1249
+ } else if (setupAt && (e.at ?? Date.now()) - setupAt > SLOW_SETUP_MS) {
1250
+ say(`the project's cloud setup took ${Math.round(((e.at ?? Date.now()) - setupAt) / 60_000)} minutes before the agent could start; ${where}`);
1251
+ }
1252
+ }
1253
+
1254
+ function cancel(why) {
1255
+ if (cancelled || finished) return;
1256
+ cancelled = true;
1257
+ cancelledAt = Date.now();
1258
+ say(`stopping the cloud run (${why})`);
1259
+ retry(() => transport.invoke(sessionId, { action: 'signal', runId, signal: 'SIGTERM' }), { what: 'stop the cloud run', forever: false }).catch(() => {});
1260
+ }
1261
+ // THE APP IS QUITTING, NOT STOPPING THE THREAD: the run in the cloud
1262
+ // carries on. It is told no more is coming, so it ends after the turn it is
1263
+ // in; a tool call left unanswered here is answered with why; and the marker
1264
+ // says where this process got to, for the one that joins it when the app
1265
+ // next opens. A Codex run is stopped as before: its app-server would end at
1266
+ // once, mid-turn, on the same signal.
1267
+ function leave(why) {
1268
+ if (leaving || finished || cancelled) return;
1269
+ if (codex) { cancel(why); return; }
1270
+ leaving = true;
1271
+ say(`leaving the cloud run going (${why}); it comes home when the app next opens`);
1272
+ for (const [conn, sv] of servers) answerUnanswered(conn, sv.pending);
1273
+ if (!stdinClosed) { stdinClosed = true; enqueue({ action: 'stdin', end: true }); }
1274
+ // Said at once, in case this process is cut off before it finishes: the
1275
+ // run is then still joined later rather than stopped. Its pid holds a
1276
+ // joiner back until its last inputs are in.
1277
+ saveMarker();
1278
+ }
1279
+ // A call the app can no longer answer is answered with why. It counts as
1280
+ // answered once the sandbox has the reply; until then a joiner says it.
1281
+ function answerUnanswered(conn, ids) {
1282
+ for (const id of [...ids]) {
1283
+ servers.get(conn)?.pending.add(id);
1284
+ enqueue({ action: 'mcp', conn, line: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: MAC_GONE } }), onAck: () => servers.get(conn)?.pending.delete(id) });
1285
+ }
1286
+ }
1287
+ function connsNow() {
1288
+ const conns = {};
1289
+ for (const [conn, sv] of servers) conns[conn] = { name: sv.name, init: sv.init, pending: [...sv.pending] };
1290
+ return conns;
1291
+ }
1292
+ // WHERE THIS PROCESS HAS GOT TO, for whichever process carries the run on:
1293
+ // its place in the events, the connections and the calls they still owe,
1294
+ // and anything for the agent's input not yet sent.
1295
+ function saveMarker(extra = {}) {
1296
+ const unsent = outbox.filter((m) => m.action === 'stdin').map(({ n: num, ...m }) => ({ ...m, ...(num ? { n: num } : {}) }));
1297
+ const state = leaving ? 'detached' : joining ? 'attached' : 'running';
1298
+ try {
1299
+ writeMarker(here,{ ...marker, pid: selfPid, state, cursor, conns: connsNow(), unsent, ...extra });
1300
+ } catch (err) { say(`could not note where the cloud run got to: ${err.message}`); }
1301
+ }
1302
+ // A joined run is left going by a SIGTERM, which is the Mac or the app
1303
+ // going. Its thread shows it running, so its Stop arrives as SIGINT.
1304
+ const onTerm = () => (joining ? leave('the app is going') : cancel('asked to stop'));
1305
+ const onInt = () => cancel('asked to stop');
1306
+ const onHup = () => leave('the app is quitting');
1307
+ signals.on('SIGTERM', onTerm);
1308
+ signals.on('SIGINT', onInt);
1309
+ signals.on('SIGHUP', onHup);
1310
+ signals.off('SIGHUP', early);
1311
+ const parentWatch = watchParent ? setInterval(() => { if (process.ppid === 1) leave('the app is gone'); }, 5000) : null;
1312
+ // A joined run's connections come back, the calls they still owed are
1313
+ // answered with why, and the agent's input the last process never sent goes.
1314
+ for (const [conn, c] of Object.entries(startConns)) { openServer(conn, c.name, c.init || []); answerUnanswered(conn, c.pending || []); }
1315
+ for (const m of joining?.unsent || []) { const { n: num, ...rest } = m; if (!(num && num <= startN)) enqueue(rest); }
1316
+ if (joining?.state === 'running') enqueue({ action: 'stdin', end: true });
1317
+ if (quitEarly) leave('the app quit while the run was starting');
1318
+
1319
+ try {
1320
+ while (!finished) {
1321
+ if (leaving) break;
1322
+ if (overdue()) { finished = 'lost'; break; }
1323
+ // Abandoned, not retried forever, once a cancel has waited long enough:
1324
+ // a Stop must end even when AWS cannot be reached.
1325
+ const page = await retry(() => transport.invoke(sessionId, { action: 'events', runId, since: cursor, waitMs: pollWaitMs }), { what: 'read the cloud run', forever: true, stop: () => overdue() || leaving });
1326
+ if (!page) continue;
1327
+ if (!page.ok && page.error === 'no-run') { finished = 'lost'; break; }
1328
+ // A call the sandbox may have given up on while this Mac was away is
1329
+ // made only once the sandbox says it has not: the agent was told it
1330
+ // did not reach the app (cloud/sandbox/server.mjs `mcp-claim`).
1331
+ if (offlineOn) await claimCalls(page.events || []);
1332
+ for (const e of page.events || []) { if (leaving) break; await absorb(e); }
1333
+ // Every run keeps its place as it goes: a process that died without
1334
+ // saying must not hand its tools the same calls twice.
1335
+ if (!leaving && page.events?.some((e) => e.kind.startsWith('mcp'))) saveMarker();
1336
+ }
1337
+ } finally {
1338
+ signals.off('SIGTERM', onTerm);
1339
+ signals.off('SIGINT', onInt);
1340
+ signals.off('SIGHUP', onHup);
1341
+ if (parentWatch) clearInterval(parentWatch);
1342
+ }
1343
+
1344
+ if (leaving && !finished) {
1345
+ // The sender stops by itself once nothing is left to send; `finished`
1346
+ // waits until then, or its retries would give up at once.
1347
+ wake?.();
1348
+ await Promise.race([sender, sleep(leaveFlushMs)]);
1349
+ finished = 'left';
1350
+ wake?.();
1351
+ await Promise.race([docsLoop, sleep(leaveFlushMs)]);
1352
+ saveMarker({ pid: null });
1353
+ for (const sv of servers.values()) { sv.closed = true; sv.proc.stdin.end(); sv.proc.kill(); }
1354
+ return IMPORT_FAILED;
1355
+ }
1356
+
1357
+ // A collect that failed in the sandbox is asked once more, with fresh links.
1358
+ if (finished === 'collect-failed') {
1359
+ const again = await retry(async () => transport.invoke(sessionId, { action: 'collect', runId, ...(await resultUrls(runId)) }), { what: 'collect the results', forever: false });
1360
+ if (again?.ok) finished = 'collected';
1361
+ }
1362
+ wake?.();
1363
+ await sender;
1364
+ await docsLoop;
1365
+ await keptSettled(10_000);
1366
+ for (const s of servers.values()) { s.closed = true; s.proc.stdin.end(); s.proc.kill(); }
1367
+
1368
+ const troubleWithLogin = () => {
1369
+ if (!loginTrouble) return false;
1370
+ say(loginTrouble.state === 'expired' ? 'this account\'s cloud sign-in needs renewing' : 'this account\'s cloud sign-in no longer works; sign in again from the cloud menu');
1371
+ try { onLoginTrouble(loginTrouble); } catch { /* noted elsewhere */ }
1372
+ return true;
1373
+ };
1374
+ // A run that failed or came home needs its machine no more; the cloud
1375
+ // server counts a run until it is stopped (cloud/gateway/).
1376
+ const letGo = () => Promise.race([Promise.resolve(transport.stop?.(sessionId)).catch(() => {}), sleep(15_000)]);
1377
+ if (finished === 'failed') { clearMarker(here); await letGo(); return troubleWithLogin() ? LOGIN_TROUBLE : 1; }
1378
+ const back = await bringHome({ ...marker }, { cursor, replay: (e) => absorb(e) });
1379
+ if (back.ok) await letGo();
1380
+ if (!back.ok) {
1381
+ say(`the cloud run's work could not be brought back (${back.why}); it is kept in the cloud for 7 days and will be tried again`);
1382
+ writeMarker(here,{ ...marker, pid: null, state: back.state || 'import-failed', ...(back.laying ? { laying: back.laying } : {}) });
1383
+ // Its marker stays either way, so nothing ships the folder until its work
1384
+ // is home; a login that failed is still said, so no account here is
1385
+ // struck for it.
1386
+ return troubleWithLogin() ? LOGIN_TROUBLE : IMPORT_FAILED;
1387
+ }
1388
+ if (troubleWithLogin()) return LOGIN_TROUBLE;
1389
+ return exitCodeOf(back.record);
1390
+
1391
+ /* --------------------------- bringing it home -------------------------- */
1392
+ // Never throws: every failure is an answer, { ok: false, why }, and the
1393
+ // marker stays, because a run whose work is not here is not finished here.
1394
+ async function bringHome(rec, { recovering = false, cursor: from = 0, replay = null } = {}) {
1395
+ try {
1396
+ return await bringHomeOnce(rec, { recovering, from, replay });
1397
+ } catch (err) {
1398
+ return { ok: false, why: String(err?.message || err).split('\n')[0] };
1399
+ }
1400
+ }
1401
+
1402
+ async function bringHomeOnce(rec, { recovering, from, replay }) {
1403
+ const k = (name) => keyOf(rec.runId, name);
1404
+ if (rec.state === 'importing' && rec.pid && rec.pid !== selfPid && alive(rec.pid)) return { ok: false, why: 'its own process is still bringing it back' };
1405
+ let done = await storage.get(k('done.json'));
1406
+ // Marked by an older app, whose results may have gone from the bucket:
1407
+ // what it kept here goes on a branch of its own.
1408
+ if (!done && rec.state === 'diverged') return park(rec, { why: rec.why || 'it could not be laid into this folder' });
1409
+ if (!done && recovering) {
1410
+ if (rec.pid && rec.pid !== selfPid && alive(rec.pid)) return { ok: false, why: 'its own process is still bringing it back' };
1411
+ // Ask the sandbox where the run stands. No answer is not an answer: the
1412
+ // run may be alive, so nothing is given up.
1413
+ const st = await retry(() => transport.invoke(rec.sessionId, { action: 'status', runId: rec.runId }), { what: 'reach the earlier run', forever: false });
1414
+ if (!st) return { ok: false, why: 'the cloud could not be reached' };
1415
+ if (st.state === 'none' || st.error === 'no-run') {
1416
+ // The session holds no run: it ended, and AgentCore has just made a
1417
+ // fresh one for this id by being asked. Let that one go, and look for
1418
+ // the results one last time; without them the run is gone for good.
1419
+ await transport.stop?.(rec.sessionId).catch(() => {});
1420
+ done = await storage.get(k('done.json'));
1421
+ if (!done) {
1422
+ clearMarker(here);
1423
+ say('an earlier cloud run in this folder ended without its results; its work is lost');
1424
+ return { ok: true, exitCode: null, lost: true };
1425
+ }
1426
+ } else {
1427
+ if (st.state === 'preparing' || st.state === 'running') {
1428
+ await retry(() => transport.invoke(rec.sessionId, { action: 'signal', runId: rec.runId, signal: 'SIGTERM' }), { what: 'stop the earlier run', forever: false });
1429
+ }
1430
+ const until = Date.now() + resultsWaitMs;
1431
+ let asked = false;
1432
+ while (!done && Date.now() < until) {
1433
+ const now = await retry(() => transport.invoke(rec.sessionId, { action: 'status', runId: rec.runId }), { what: 'watch the earlier run', forever: false });
1434
+ if (!asked && (now?.state === 'exited' || now?.state === 'collect-failed')) {
1435
+ // Finished but never uploaded (or its links ran out): fresh links.
1436
+ asked = true;
1437
+ await retry(async () => transport.invoke(rec.sessionId, { action: 'collect', runId: rec.runId, ...(await resultUrls(rec.runId, dirsOf(rec))) }), { what: 'collect the earlier run', forever: false });
1438
+ }
1439
+ done = await storage.get(k('done.json'));
1440
+ if (!done) await sleep(3000);
1441
+ }
1442
+ if (!done) return { ok: false, why: 'its results have not arrived yet' };
1443
+ }
1444
+ }
1445
+ if (!done) return { ok: false, why: 'its results never arrived' };
1446
+ const record = JSON.parse(done.toString('utf8'));
1447
+ // The heavy half, two runs at a time across every cloud run on this Mac,
1448
+ // and through files rather than memory: twenty runs finishing together
1449
+ // must not unpack twenty sets of bundles and transcripts at once.
1450
+ const release = await takeImportSlot();
1451
+ try {
1452
+ const spool = fs.mkdtempSync(path.join(os.tmpdir(), `${nameSlug}-cloud-home-`));
1453
+ try {
1454
+ return await layHome(rec, record, { recovering, replay, from, spool });
1455
+ } finally {
1456
+ try { fs.rmSync(spool, { recursive: true, force: true }); } catch { /* the tmp folder's own cleaning */ }
1457
+ }
1458
+ } finally {
1459
+ release();
1460
+ }
1461
+ }
1462
+
1463
+ async function layHome(rec, record, { recovering, replay, from, spool }) {
1464
+ const k = (name) => keyOf(rec.runId, name);
1465
+ // WHAT THE RUN USED, for setting the plan limits from real work
1466
+ // (main/cloud/usage.mjs). Recorded as soon as its results are here,
1467
+ // whatever laying them into the folder does next: the machine ran either
1468
+ // way. Once per run, however many times this is tried.
1469
+ appendCloudUsage({ runId: rec.runId, item: env.ZERO_ITEM, engine: rec.engine || engine, exitCode: record.exitCode, usage: record.usage }, { home: homeDir });
1470
+
1471
+ // Anything the stream never delivered (the session was gone before the
1472
+ // Mac could read it) is replayed from the copy in S3, and so is what the
1473
+ // sandbox said about the login, even for an earlier run brought home now
1474
+ // (Codex's review, finding 4).
1475
+ if (replay || recovering) {
1476
+ const logFile = path.join(spool, 'events.jsonl');
1477
+ const lines = (await fetchToFile(storage, k('events.jsonl'), logFile)) ? linesOf(logFile) : [];
1478
+ for await (const l of lines) {
1479
+ if (!l) continue;
1480
+ const e = JSON.parse(l);
1481
+ if (replay && e.seq > from && (e.kind === 'stdout' || e.kind === 'stderr' || e.kind === 'login' || e.kind === 'input-closed')) await replay(e);
1482
+ else if (recovering && e.kind === 'login') { try { onLoginTrouble({ state: e.state, id: e.id ?? null }); } catch { /* noted elsewhere */ } }
1483
+ }
1484
+ }
1485
+
1486
+ // EVERYTHING IS FETCHED BEFORE ANYTHING IN THE FOLDER IS TOUCHED. An import
1487
+ // that moved the branch and then failed to download the rest would leave a
1488
+ // folder that no longer matches what the run left with, and its own retry
1489
+ // would read that as somebody else's work (Codex, 2026-10-13). So: the
1490
+ // commits into refs, the uncommitted work into a ref, the transcripts into
1491
+ // memory, and only then, with nothing left that can be missing, the folder.
1492
+ const keep = `refs/agentbox/cloud/${rec.runId}`;
1493
+ const keepChanges = `refs/agentbox/cloud-changes/${rec.runId}`;
1494
+ const fetchBundle = async (name, specs, why) => {
1495
+ const f = path.join(spool, name);
1496
+ if (!(await fetchToFile(storage, k(name), f))) throw new Error(why);
1497
+ try { await git(cwd, ['fetch', '-q', f, ...specs]); } finally { fs.rmSync(f, { force: true }); }
1498
+ };
1499
+ if (record.commits > 0 || record.strayHead) {
1500
+ const specs = [];
1501
+ if (record.commits > 0) specs.push(`refs/heads/${rec.branch}:${keep}`);
1502
+ if (record.strayHead) specs.push(`refs/agentbox/head:${keep}-head`);
1503
+ await fetchBundle('out.bundle', specs, 'the commits bundle is missing');
1504
+ }
1505
+ if (record.uncommitted) await fetchBundle('changes.bundle', [`refs/agentbox/changes:${keepChanges}`], 'the uncommitted work is missing');
1506
+ // The conversation's folders, each one the run said it uploaded.
1507
+ const conversations = [];
1508
+ for (const [i, b] of dirsOf(rec).entries()) {
1509
+ const file = path.join(spool, `conversation-${i}.tgz`);
1510
+ if (await fetchToFile(storage, k(b.name), file)) conversations.push({ dir: b.dir, file });
1511
+ else if ((record.brought || []).includes(b.dir)) return { ok: false, why: 'the transcript is missing' };
1512
+ }
1513
+
1514
+ // The documents go in whatever happens to the task folder below: they are
1515
+ // not in it.
1516
+ if (rec.docs?.dir && (record.docs || []).length) {
1517
+ const tgz = await storage.get(k('docs.tgz'));
1518
+ if (!tgz) return { ok: false, why: 'the documents it wrote are missing' };
1519
+ if (!(await layArchive(rec.docs, tgz))) return { ok: false, why: 'the documents it wrote could not be laid in' };
1520
+ }
1521
+ // With no repository, the documents and the conversation are all of it.
1522
+ if (rec.plain) {
1523
+ await layConversations(conversations);
1524
+ clearMarker(here);
1525
+ return { ok: true, exitCode: record.exitCode, record };
1526
+ }
1527
+
1528
+ // The agent must have ended on the branch it was given.
1529
+ if (record.strayHead || (record.headRef && record.headRef !== rec.branch)) {
1530
+ return park(rec, { record, conversations, why: `the agent finished on ${record.headRef || 'a detached commit'} instead of ${rec.branch}` });
1531
+ }
1532
+
1533
+ // Where the branch ends: the cloud's tip, which an agent that amended or
1534
+ // rebased has taken off the line it started on.
1535
+ const startHead = rec.sent?.head ?? (await git(cwd, ['rev-parse', 'HEAD'])).trim();
1536
+ let target = startHead;
1537
+ if (record.commits > 0) target = (await git(cwd, ['rev-parse', keep])).trim();
1538
+ else if (record.tip && record.tip !== startHead && await gitOk(cwd, ['cat-file', '-e', `${record.tip}^{commit}`])) target = record.tip;
1539
+
1540
+ // THE FOLDER MUST BE AS THE RUN LEFT IT: same branch, commit, files and
1541
+ // staged changes; or as an import cut off part way left it, at one of its
1542
+ // own steps. Anything else is work done here while the run was away.
1543
+ if (rec.sent) {
1544
+ const now = await folderState(cwd);
1545
+ const at = (head, tree, index = tree) => now.branch === rec.sent.branch && now.head === head && now.tree === tree && now.index === index;
1546
+ let fits = now.branch === rec.sent.branch && now.head === rec.sent.head && now.tree === rec.sent.tree && (!rec.sent.index || now.index === rec.sent.index);
1547
+ if (!fits && rec.laying) {
1548
+ const treeOf = async (c) => (await git(cwd, ['rev-parse', `${c}^{tree}`])).trim();
1549
+ const startTree = await treeOf(startHead);
1550
+ const targetTree = await treeOf(target);
1551
+ const endTree = record.uncommitted ? await treeOf(keepChanges) : targetTree;
1552
+ fits = at(startHead, startTree) || at(target, targetTree) || at(target, endTree, targetTree);
1553
+ }
1554
+ if (!fits) return park(rec, { record, conversations, why: 'this folder changed on this Mac while the run was in the cloud' });
1555
+ }
1556
+
1557
+ // Said before the folder is touched, so an import cut off part way is
1558
+ // known for one when it is tried again.
1559
+ const tries = (rec.laying?.tries ?? 0) + 1;
1560
+ rec.laying = { tries };
1561
+ writeMarker(here,{ ...rec, pid: selfPid, state: 'importing' });
1562
+
1563
+ // What it held when the run began went up with it and comes back in the
1564
+ // run's own state; a copy of the files AND of what was staged is set aside
1565
+ // first anyway, under names no later run reuses, and so is a tip the
1566
+ // cloud's does not descend from.
1567
+ const stamp = `${rec.runId}-${Date.now()}`;
1568
+ try {
1569
+ await snapshotUncommitted(cwd, `refs/agentbox/before-cloud/${stamp}`);
1570
+ const stagedTree = (await git(cwd, ['write-tree'])).trim();
1571
+ const stagedCommit = (await git(cwd, ['-c', `user.name=${Name}`, '-c', `user.email=${nameSlug}@localhost`, 'commit-tree', stagedTree, '-p', 'HEAD', '-m', 'What was staged before a cloud run came home'])).trim();
1572
+ await git(cwd, ['update-ref', `refs/agentbox/before-cloud/${stamp}-staged`, stagedCommit]);
1573
+ if (!(await gitOk(cwd, ['merge-base', '--is-ancestor', startHead, target]))) await git(cwd, ['update-ref', `refs/agentbox/before-cloud/${rec.runId}`, startHead]);
1574
+ await git(cwd, ['reset', '-q', '--hard', target]);
1575
+ await git(cwd, ['clean', '-q', '-fd']);
1576
+ if (record.uncommitted) {
1577
+ const patch = await git(cwd, ['diff', '--binary', 'HEAD', keepChanges], { buffer: true });
1578
+ if (patch.length) await git(cwd, ['apply', '--whitespace=nowarn'], { input: patch });
1579
+ }
1580
+ await layConversations(conversations);
1581
+ } catch (err) {
1582
+ const said = String(err.message).split('\n')[0];
1583
+ if (tries >= 3) return park(rec, { record, conversations, why: `laying it into this folder failed ${tries} times (${said}); what the folder held before is on refs/agentbox/before-cloud/${stamp}` });
1584
+ writeMarker(here,{ ...rec, pid: null, state: 'import-failed' });
1585
+ return { ok: false, state: 'import-failed', laying: rec.laying, why: `laying the run into the folder failed (${said}); the earlier state is on refs/agentbox/before-cloud/${stamp}` };
1586
+ }
1587
+ await gitOk(cwd, ['update-ref', '-d', keepChanges]);
1588
+ if (record.commits > 0) await gitOk(cwd, ['update-ref', '-d', keep]);
1589
+ clearMarker(here);
1590
+ return { ok: true, exitCode: record.exitCode, record };
1591
+ }
1592
+
1593
+ async function layConversations(conversations) {
1594
+ // The conversation as this Mac sent it, for one gone from here.
1595
+ const sentCopy = async (t, into) => {
1596
+ const had = JSON.parse(fs.readFileSync(sentRecordPath(here), 'utf8'));
1597
+ return had?.sha === t.sha256 && had.size === t.from && fetchToFile(storage, had.key, into);
1598
+ };
1599
+ for (const c of conversations) {
1600
+ fs.mkdirSync(c.dir, { recursive: true });
1601
+ await run('tar', ['-xzf', c.file, '-C', c.dir]);
1602
+ await layTails(c.dir, say, { sentCopy });
1603
+ }
1604
+ }
1605
+
1606
+ // A RUN WHOSE WORK CANNOT GO INTO ITS FOLDER WITHOUT UNDOING WORK DONE HERE
1607
+ // goes on a branch of its own, `cloud/<item>`, and the folder is left as it
1608
+ // is. Neither side's work is lost, the marker goes so the thread can run
1609
+ // again, and the row is told (`addCloudNews`). A run that changed nothing
1610
+ // the folder does not already hold needs no branch and no word.
1611
+ async function park(rec, { record = null, conversations = [], why }) {
1612
+ const keep = `refs/agentbox/cloud/${rec.runId}`;
1613
+ const keepChanges = `refs/agentbox/cloud-changes/${rec.runId}`;
1614
+ const has = (ref) => gitOk(cwd, ['rev-parse', '--verify', '-q', ref]);
1615
+ let tip = null;
1616
+ for (const ref of [keepChanges, `${keep}-head`, keep]) if (!tip && await has(ref)) tip = ref;
1617
+ let nothingNew = !tip;
1618
+ if (tip && rec.sent) {
1619
+ const rev = async (spec) => (await git(cwd, ['rev-parse', spec])).trim();
1620
+ nothingNew = (await rev(`${tip}^{tree}`)) === rec.sent.tree && (await rev(tip === keepChanges ? `${tip}^` : tip)) === rec.sent.head;
1621
+ }
1622
+ await layConversations(conversations);
1623
+ const item = rec.toolEnv?.ZERO_ITEM || env.ZERO_ITEM || null;
1624
+ const product = rec.toolEnv?.ZERO_PRODUCT || env.ZERO_PRODUCT || null;
1625
+ let branch = null;
1626
+ if (!nothingNew) {
1627
+ const base = `cloud/${String(item || rec.branch.replace(/^agentbox\//, '') || rec.runId.slice(0, 8)).replace(/[^A-Za-z0-9._-]/g, '-')}`;
1628
+ branch = base;
1629
+ for (let i = 2; await has(`refs/heads/${branch}`); i += 1) branch = `${base}-${i}`;
1630
+ await git(cwd, ['branch', branch, tip]);
1631
+ }
1632
+ for (const ref of [keepChanges, `${keep}-head`, keep]) {
1633
+ if (await has(ref) && (nothingNew || await gitOk(cwd, ['merge-base', '--is-ancestor', ref, branch]))) await gitOk(cwd, ['update-ref', '-d', ref]);
1634
+ }
1635
+ if (branch) {
1636
+ const note = `${KEPT_ASIDE}${why}. Nothing is lost. The folder is as this Mac left it, and the run's version is on the branch ${branch}. Reply to have the agent bring the two together; it will not ship until then.`;
1637
+ say(note);
1638
+ addCloudNews(cwd, { runId: rec.runId, item, product, branch, note });
1639
+ }
1640
+ clearMarker(here);
1641
+ return { ok: true, exitCode: record?.exitCode ?? null, record, parked: branch };
1642
+ }
1643
+
1644
+ async function layArchive(d, tgz) {
1645
+ try {
1646
+ const out = await layDocs({ tgz, dir: d.dir, sentAt: d.sentAt, laid: d.laid, sent: d.sent ?? null });
1647
+ for (const b of out.beside) say(`${b} was changed on this Mac while the run had it; the run's copy is beside it`);
1648
+ for (const m of out.moved) say(`the run's ${m} could not go where it was written, through a link out of the folder, so it is there instead`);
1649
+ return true;
1650
+ } catch (err) {
1651
+ say(`some of the run's documents could not be laid in yet (${String(err.message).split('\n')[0]})`);
1652
+ return false;
1653
+ }
1654
+ }
1655
+
1656
+ // Calls that throw (network, AWS) are retried with backoff; `forever` for
1657
+ // the ones a run cannot do without, bounded otherwise; `stop` ends either.
1658
+ // Returns null when it gave up, never throws.
1659
+ async function retry(fn, { what, forever = false, stop = () => false }) {
1660
+ let delay = 500;
1661
+ for (let i = 0; ; i += 1) {
1662
+ try { return await fn(); } catch (err) {
1663
+ if (stop()) return null;
1664
+ if (!forever && i >= 5) { say(`could not ${what}: ${err.message}`); return null; }
1665
+ if (i === 3) say(`still trying to ${what} (${err.message})`);
1666
+ await sleep(delay);
1667
+ delay = Math.min(delay * 2, 30_000);
1668
+ if (stop()) return null;
1669
+ }
1670
+ }
1671
+ }
1672
+ }
1673
+
1674
+ // A CODEX KIT'S OPENING LINES (shared/cloud-kit.mjs): what the computer's own
1675
+ // client (main/codex-app-server.mjs) sends to open the thread, the resume
1676
+ // rewritten here as a run's is, so the store is the sandbox's stub and the
1677
+ // rollout the resume needs goes with the pack. The ids are the kit's own.
1678
+ export const CODEX_RESUME_ID = 2;
1679
+ // A new thread's kit starts its thread, as the client's `startThread` does.
1680
+ export function codexKitOpen({ resume, start }, { stubPath = STUB, codexHome, keep, note = null, findRollout = codexTranscriptFile }) {
1681
+ const asked = start
1682
+ ? { jsonrpc: '2.0', id: CODEX_RESUME_ID, method: 'thread/start', params: { ephemeral: false, ...start } }
1683
+ : { jsonrpc: '2.0', id: CODEX_RESUME_ID, method: 'thread/resume', params: resume };
1684
+ const rewritten = codexLineForTheCloud(`${JSON.stringify(asked)}\n`, { stubPath, codexHome, keep, note, findRollout });
1685
+ return {
1686
+ lines: [
1687
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: CLIENT_INFO } }),
1688
+ JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }),
1689
+ rewritten.line.replace(/\n$/, ''),
1690
+ ],
1691
+ files: rewritten.files,
1692
+ conversations: rewritten.conversation ? [rewritten.conversation] : [],
1693
+ };
1694
+ }
1695
+
1696
+ // ONE LINE FROM THE SUPERVISOR'S CODEX CLIENT, MADE READY FOR THE SANDBOX.
1697
+ //
1698
+ // Codex is told its MCP servers inside `thread/start` and `thread/resume`
1699
+ // (`config.mcp_servers`), not on a command line. Each server that has a
1700
+ // command (the store; her own come switched off, `{ enabled: false }`, and
1701
+ // pass untouched) is swapped for the sandbox's stub, and the original is kept
1702
+ // in `keep` to run here when the stub calls. A `thread/resume` also names the
1703
+ // one file Codex will need to find that thread: its rollout, sent up first
1704
+ // (`conversation`, sent as a Claude Code transcript is).
1705
+ // Any other line, or one that is not JSON, passes exactly as it came.
1706
+ // `note` is what the run is told about the cloud, which Claude Code gets on its
1707
+ // command line (`argvForTheCloud`) and Codex only here.
1708
+ export function codexLineForTheCloud(line, { stubPath = STUB, codexHome, keep, findRollout = codexTranscriptFile, note = null }) {
1709
+ let msg;
1710
+ try { msg = JSON.parse(line); } catch { return { line, files: [] }; }
1711
+ // A picture rides in a turn as `localImage` with a path on this Mac; the
1712
+ // file goes up first, at that same path (Codex's review, finding 5).
1713
+ if (msg?.method === 'turn/start' || msg?.method === 'turn/steer') {
1714
+ const files = (Array.isArray(msg.params?.input) ? msg.params.input : [])
1715
+ .filter((i) => i && i.type === 'localImage' && typeof i.path === 'string' && path.isAbsolute(i.path))
1716
+ .map((i) => i.path);
1717
+ return { line, files };
1718
+ }
1719
+ if (msg?.method !== 'thread/start' && msg?.method !== 'thread/resume') return { line, files: [] };
1720
+ const files = [];
1721
+ const servers = msg.params?.config?.mcp_servers;
1722
+ if (servers && typeof servers === 'object') {
1723
+ for (const name of Object.keys(servers)) {
1724
+ const s = servers[name];
1725
+ if (!s || typeof s !== 'object' || !s.command) continue;
1726
+ keep[name] = { command: s.command, args: s.args || [], env: s.env || {} };
1727
+ // Codex starts an MCP server with a bare environment, so the stub is
1728
+ // told where the sandbox's bridge is in its own entry (measured
1729
+ // 2026-10-13: without it the stub never connected).
1730
+ servers[name] = { ...s, command: SANDBOX_NODE, args: [stubPath, name], env: { AGENTBOX_BRIDGE_SOCK: BRIDGE_SOCK } };
1731
+ }
1732
+ }
1733
+ // THE CLOUD MACHINE IS CODEX'S SANDBOX (her decision, 2026-10-13: "Yes, let
1734
+ // the cloud machine be Codex's sandbox"). Codex's own command sandbox
1735
+ // (bubblewrap) cannot nest inside an AgentCore microVM: measured that day,
1736
+ // every write and every command was refused ("bwrap: Can't mkdir parents
1737
+ // ... Permission denied"). OpenAI's docs say to use full access inside a
1738
+ // container or VM, which is then the boundary. This one holds only the
1739
+ // task's folder and a Codex sign-in that cannot renew, and is thrown away
1740
+ // after the run. A read-only run stays read-only; approvals are untouched.
1741
+ if (msg.params && msg.params.sandbox === 'workspace-write') msg.params.sandbox = 'danger-full-access';
1742
+ if (note && msg.params) {
1743
+ const mine = msg.params.developerInstructions;
1744
+ msg.params.developerInstructions = mine ? `${mine}\n\n${note}` : note;
1745
+ }
1746
+ let conversation = null;
1747
+ if (msg.method === 'thread/resume' && msg.params?.threadId) {
1748
+ conversation = findRollout(msg.params.threadId, { home: codexHome });
1749
+ if (conversation) files.push(conversation);
1750
+ }
1751
+ return { line: JSON.stringify(msg) + (line.endsWith('\n') ? '\n' : ''), files, ...(conversation ? { conversation } : {}) };
1752
+ }
1753
+
1754
+ // THE PART OF HER CODEX CONFIG A CLOUD RUN TAKES: the top-level model, effort
1755
+ // and service tier, each a plain quoted string, and nothing else. Read by
1756
+ // line rather than by a TOML parser because only these three are wanted, and
1757
+ // anything that is not exactly `key = "value"` before the first [section] is
1758
+ // left behind rather than guessed at.
1759
+ const CODEX_SETTINGS = new Set(['model', 'model_reasoning_effort', 'service_tier']);
1760
+ export function codexConfigForTheCloud(toml) {
1761
+ const out = [];
1762
+ for (const raw of String(toml || '').split('\n')) {
1763
+ const line = raw.trim();
1764
+ if (line.startsWith('[')) break;
1765
+ const m = /^([A-Za-z_]+)\s*=\s*"([^"\\\n]*)"\s*(#.*)?$/.exec(line);
1766
+ if (m && CODEX_SETTINGS.has(m[1])) out.push(`${m[1]} = "${m[2]}"`);
1767
+ }
1768
+ return out.join('\n') + (out.length ? '\n' : '');
1769
+ }
1770
+
1771
+ // THE ENVIRONMENT THE APP'S OWN TOOLS RUN IN, HERE. The worker's, as Claude
1772
+ // Code would have given it, minus anything that signs in to AWS: this process
1773
+ // signs as the cloud runs' own AWS user, and an app started from a shell that
1774
+ // was signed in to AWS would otherwise hand that sign-in to every tool.
1775
+ export function toolEnv(env) {
1776
+ const out = { ...env };
1777
+ for (const k of Object.keys(out)) {
1778
+ if (/^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN|SECURITY_TOKEN|PROFILE|DEFAULT_PROFILE|CREDENTIAL_EXPIRATION|WEB_IDENTITY_TOKEN_FILE|ROLE_ARN)$/.test(k)) delete out[k];
1779
+ if (k === 'CLAUDE_CODE_OAUTH_TOKEN') delete out[k];
1780
+ }
1781
+ return out;
1782
+ }
1783
+
1784
+ // The remote command's exit, as one number: its own code, or for a command a
1785
+ // signal ended, 128 plus the signal's number, the way a shell reports it.
1786
+ export function exitCodeOf(record) {
1787
+ if (Number.isInteger(record?.exitCode)) return record.exitCode;
1788
+ const num = record?.exitSignal ? os.constants.signals[record.exitSignal] : null;
1789
+ return num ? 128 + num : 1;
1790
+ }
1791
+
1792
+ // The worker's own variables its tools ran with, kept for a process that joins
1793
+ // the run after a restart and so was not started with them. Names only this
1794
+ // app sets; never a key or a login.
1795
+ export function toolVariables(env) {
1796
+ const out = {};
1797
+ for (const [k, v] of Object.entries(env || {})) {
1798
+ if (/^(ZERO_|STORE_|AGENTBOX_)/.test(k) && !/(KEY|TOKEN|SECRET|PASSWORD)/.test(k)) out[k] = v;
1799
+ }
1800
+ return out;
1801
+ }
1802
+
1803
+
1804
+ async function gitIdentity(cwd) {
1805
+ const get = (k) => git(cwd, ['config', k]).then((v) => v.trim(), () => '');
1806
+ const name = await get('user.name'); const email = await get('user.email');
1807
+ return {
1808
+ ...(name ? { GIT_AUTHOR_NAME: name, GIT_COMMITTER_NAME: name } : {}),
1809
+ ...(email ? { GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_EMAIL: email } : {}),
1810
+ };
1811
+ }
1812
+
1813
+ function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
1814
+
1815
+ // A stored object straight to a file, and a file straight to storage, when the
1816
+ // storage can (main/cloud/aws.mjs); a stand-in that cannot goes through memory.
1817
+ async function fetchToFile(storage, k, file) {
1818
+ if (storage.getToFile) return storage.getToFile(k, file);
1819
+ const body = await storage.get(k);
1820
+ if (!body) return false;
1821
+ fs.writeFileSync(file, body);
1822
+ return true;
1823
+ }
1824
+ async function sendFromFile(storage, k, file) {
1825
+ if (storage.putFile) return storage.putFile(k, file);
1826
+ return storage.put(k, fs.readFileSync(file));
1827
+ }
1828
+ async function* linesOf(file) {
1829
+ const { createInterface } = await import('node:readline');
1830
+ yield* createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
1831
+ }
1832
+
1833
+ // AT MOST TWO CLOUD RUNS BRING THEIR WORK HOME AT ONCE on this Mac, whichever
1834
+ // process each runs in. The slots are files in one folder, looked at and
1835
+ // changed only while holding that folder's guard (a directory made only if
1836
+ // absent, held for milliseconds), so two processes never both reclaim one.
1837
+ // A slot names its holder's process and a token of its own, and its holder
1838
+ // touches it while it works: one whose process is gone, or that nobody has
1839
+ // touched for ten minutes (a process id reused by something else), is free.
1840
+ // If the folder cannot be used at all, the import goes ahead unlimited rather
1841
+ // than never.
1842
+ export const IMPORT_SLOTS = 2;
1843
+ const SLOT_STALE_MS = 10 * 60_000;
1844
+ const GUARD_STALE_MS = 30_000;
1845
+ export async function takeImportSlot({ dir = path.join(os.tmpdir(), `${nameSlug}-cloud-imports`), slots = IMPORT_SLOTS, everyMs = 500, staleMs = SLOT_STALE_MS } = {}) {
1846
+ const token = `${process.pid}:${crypto.randomUUID()}`;
1847
+ const guard = path.join(dir, 'guard');
1848
+ const unlimited = () => () => {};
1849
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { return unlimited(); }
1850
+ const tryOnce = () => {
1851
+ try { fs.mkdirSync(guard); } catch (err) {
1852
+ if (err?.code !== 'EEXIST') throw err;
1853
+ try { if (Date.now() - fs.statSync(guard).mtimeMs > GUARD_STALE_MS) fs.rmSync(guard, { recursive: true, force: true }); } catch { /* freed meanwhile */ }
1854
+ return null;
1855
+ }
1856
+ try {
1857
+ for (let i = 0; i < slots; i += 1) {
1858
+ const file = path.join(dir, `slot-${i}`);
1859
+ let held = null;
1860
+ try { held = { text: fs.readFileSync(file, 'utf8'), at: fs.statSync(file).mtimeMs }; } catch { /* free */ }
1861
+ const pid = held ? Number(held.text.split(':')[0]) : 0;
1862
+ if (held && pid && alive(pid) && Date.now() - held.at < staleMs) continue;
1863
+ fs.writeFileSync(file, token);
1864
+ return file;
1865
+ }
1866
+ return null;
1867
+ } finally {
1868
+ fs.rmSync(guard, { recursive: true, force: true });
1869
+ }
1870
+ };
1871
+ for (let failures = 0; ;) {
1872
+ let file = null;
1873
+ try { file = tryOnce(); } catch {
1874
+ failures += 1;
1875
+ if (failures >= 20) return unlimited();
1876
+ }
1877
+ if (file) {
1878
+ const touch = setInterval(() => { try { const t = new Date(); fs.utimesSync(file, t, t); } catch { /* gone */ } }, 30_000);
1879
+ touch.unref?.();
1880
+ return () => {
1881
+ clearInterval(touch);
1882
+ try { if (fs.readFileSync(file, 'utf8') === token) fs.rmSync(file, { force: true }); } catch { /* gone */ }
1883
+ };
1884
+ }
1885
+ await sleep(everyMs);
1886
+ }
1887
+ }
1888
+
1889
+ // THE APP'S OWN TWO TOOL SERVERS, SERVED IN THIS PROCESS. Each was a process of
1890
+ // its own per cloud run (the approvals server about 46 MB, measured on her Mac
1891
+ // 2026-10-17), most of it the runtime starting up, not work. In here each
1892
+ // connection gets its own channel: the approvals for its own row, the store
1893
+ // with its own claims. Any other server is still started as configured.
1894
+ export function servedHere(cfg, env, holder) {
1895
+ const cmd = String(cfg?.command || '');
1896
+ if (cmd.endsWith('/approval-server.sh')) return channel((io) => serveApprovals({ env, ...io, warn: (m) => io.output.emit('warn', m) }));
1897
+ if (cmd.endsWith(`/${nameSlug}-mcp.sh`)) {
1898
+ // One store, one account, for every run on this Mac: the store reads where
1899
+ // it is and whose lines it writes from the environment, so this run's are
1900
+ // set on it, the server's own winning as they would for a server of its
1901
+ // own, and a person id this run has none of is taken away.
1902
+ const want = { AGENTBOX_PERSON_ID: env.AGENTBOX_PERSON_ID, ...cfg.env };
1903
+ for (const [k, v] of Object.entries(want)) { if (v == null) delete process.env[k]; else process.env[k] = v; }
1904
+ // Named here: runs share this process, so its own ZERO_ITEM is whichever run set it last.
1905
+ const ownItem = cfg.env?.ZERO_ITEM || env.ZERO_ITEM || null;
1906
+ return channel(async (io) => (await import('../../mcp/stdio.mjs')).serveStore({ ...io, holder: `mcp-cloud-${holder}`, ownItem }));
1907
+ }
1908
+ return null;
1909
+ }
1910
+
1911
+ // A server in this process, shaped like the child process the relay would
1912
+ // otherwise have started: lines in on `stdin`, lines out on `stdout`, `close`
1913
+ // once it has said its last.
1914
+ function channel(serve) {
1915
+ const proc = new EventEmitter();
1916
+ proc.stdin = new PassThrough();
1917
+ proc.stdout = new PassThrough();
1918
+ proc.stderr = new PassThrough();
1919
+ proc.stdout.on('warn', (m) => proc.stderr.write(`${m}\n`));
1920
+ let handle = null;
1921
+ let ended = false;
1922
+ const end = () => {
1923
+ if (ended) return;
1924
+ ended = true;
1925
+ Promise.resolve(handle?.close?.()).catch(() => {}).finally(() => proc.stdout.end());
1926
+ };
1927
+ proc.stdout.on('end', () => proc.emit('close', 0, null));
1928
+ proc.stdin.on('finish', end);
1929
+ proc.kill = () => { end(); return true; };
1930
+ Promise.resolve().then(() => serve({ input: proc.stdin, output: proc.stdout }))
1931
+ .then((h) => { handle = h; if (ended) Promise.resolve(h?.close?.()).catch(() => {}); })
1932
+ .catch((err) => { proc.stderr.write(`${err?.message ?? err}\n`); if (proc.listenerCount('error')) proc.emit('error', err); end(); });
1933
+ return proc;
1934
+ }