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,127 @@
1
+ // THE TWO THINGS A CLOUD RUN NEEDS FROM AWS, AND NOTHING ELSE.
2
+ //
3
+ // main/cloud/run.mjs is written against two small interfaces so a test can
4
+ // run it against the real sandbox server on this machine (see
5
+ // tests/a-cloud-run-brings-the-agents-work-home.test.mjs). These are the real
6
+ // ones: the AgentCore session (invoke, stop) and the S3 bucket (put, get, and
7
+ // the presigned links the sandbox uses, so it holds no AWS rights of its own).
8
+ //
9
+ // They sign as the `agentbox-cloud` profile that scripts/cloud-setup.mjs made:
10
+ // an IAM user that may only start and stop sessions on one runtime and read
11
+ // and write one bucket's `runs/`. Never the 12-hour admin sign-in.
12
+ import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand } from '@aws-sdk/client-bedrock-agentcore';
13
+ import {
14
+ S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand,
15
+ CreateMultipartUploadCommand, UploadPartCopyCommand, UploadPartCommand, CompleteMultipartUploadCommand,
16
+ } from '@aws-sdk/client-s3';
17
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
18
+ import fs from 'node:fs';
19
+ import { pipeline } from 'node:stream/promises';
20
+
21
+ // A link the sandbox uses must outlive the longest run (8 hours) plus the
22
+ // upload at its end, with room to spare.
23
+ const LINK_SECONDS = 12 * 60 * 60;
24
+
25
+ // A request that hangs is retried by the caller; one that never returns would
26
+ // freeze the run. The SDK only warns at its request timeout unless told to
27
+ // throw. Minutes, not seconds: sending a run's code up and a cold start both
28
+ // take longer than a minute, and a minute's limit killed every such run.
29
+ const TIMEOUT_MS = 5 * 60_000;
30
+ const COPY_PIECE_BYTES = 32 * 1024 * 1024;
31
+
32
+ // The byte ranges `extend` copies a `size`-byte base in: each at least `piece`
33
+ // long, so never under S3's 5 MiB, and together exactly the base.
34
+ export function copyRanges(size, piece = COPY_PIECE_BYTES) {
35
+ const pieces = Math.max(1, Math.floor(size / piece));
36
+ const each = Math.ceil(size / pieces);
37
+ return Array.from({ length: pieces }, (_, i) => `bytes=${i * each}-${Math.min(size, (i + 1) * each) - 1}`);
38
+ }
39
+
40
+ const http = (timeoutMs = TIMEOUT_MS) => ({ requestTimeout: timeoutMs, connectionTimeout: Math.min(10_000, timeoutMs), throwOnRequestTimeout: true });
41
+ // `endpoint` and `credentials` are for tests, which stand in for AWS.
42
+ const where = ({ endpoint, credentials }) => ({ ...(endpoint ? { endpoint } : {}), ...(credentials ? { credentials } : {}) });
43
+
44
+ export function awsTransport({ region, runtimeArn, profile, endpoint, credentials, timeoutMs }) {
45
+ const client = new BedrockAgentCoreClient({ region, profile, requestHandler: http(timeoutMs), ...where({ endpoint, credentials }) });
46
+ return {
47
+ async invoke(sessionId, payload) {
48
+ const out = await client.send(new InvokeAgentRuntimeCommand({
49
+ agentRuntimeArn: runtimeArn,
50
+ runtimeSessionId: sessionId,
51
+ payload: Buffer.from(JSON.stringify(payload)),
52
+ contentType: 'application/json',
53
+ accept: 'application/json',
54
+ }));
55
+ const text = await out.response.transformToString();
56
+ return JSON.parse(text);
57
+ },
58
+ async stop(sessionId) {
59
+ await client.send(new StopRuntimeSessionCommand({ agentRuntimeArn: runtimeArn, runtimeSessionId: sessionId }));
60
+ },
61
+ };
62
+ }
63
+
64
+ export function awsStorage({ region, bucket, profile, endpoint, credentials, timeoutMs }) {
65
+ // WHEN_REQUIRED: by default the SDK now signs a checksum of the body into
66
+ // every upload link, which a link made before the body exists cannot match,
67
+ // so every upload from the sandbox would be refused.
68
+ const s3 = new S3Client({ region, profile, requestHandler: http(timeoutMs), requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED', ...where({ endpoint, credentials }), ...(endpoint ? { forcePathStyle: true } : {}) });
69
+ return {
70
+ async put(key, body) {
71
+ await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body }));
72
+ },
73
+ async get(key) {
74
+ try {
75
+ const o = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
76
+ return Buffer.from(await o.Body.transformToByteArray());
77
+ } catch (err) {
78
+ if (err?.name === 'NoSuchKey' || err?.$metadata?.httpStatusCode === 404) return null;
79
+ throw err;
80
+ }
81
+ },
82
+ // Straight to a file, a piece at a time; false when it is not there.
83
+ async getToFile(key, file) {
84
+ let o;
85
+ try { o = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); } catch (err) {
86
+ if (err?.name === 'NoSuchKey' || err?.$metadata?.httpStatusCode === 404) return false;
87
+ throw err;
88
+ }
89
+ await pipeline(o.Body, fs.createWriteStream(file));
90
+ return true;
91
+ },
92
+ async putFile(key, file) {
93
+ await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: fs.createReadStream(file), ContentLength: fs.statSync(file).size }));
94
+ },
95
+ // `key` made in the bucket as `base` (which is `start` bytes long)
96
+ // followed by `file` from byte `start`, so only that tail leaves this Mac.
97
+ // The base is copied in pieces at once: as one piece, 400 MiB took 11 s.
98
+ // An upload that fails part-way is cleared by the bucket's lifecycle rule
99
+ // (scripts/cloud-setup.mjs), as this login may not abort one.
100
+ async extend(key, base, file, start) {
101
+ const { UploadId } = await s3.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key }));
102
+ const copied = await Promise.all(copyRanges(start).map((range, i) => s3.send(new UploadPartCopyCommand({
103
+ Bucket: bucket, Key: key, UploadId, PartNumber: i + 1, CopySource: `${bucket}/${base}`, CopySourceRange: range,
104
+ })).then((r) => ({ PartNumber: i + 1, ETag: r.CopyPartResult?.ETag }))));
105
+ const last = copied.length + 1;
106
+ const length = fs.statSync(file).size - start;
107
+ const tail = await s3.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId, PartNumber: last, Body: fs.createReadStream(file, { start }), ContentLength: length }));
108
+ await s3.send(new CompleteMultipartUploadCommand({
109
+ Bucket: bucket, Key: key, UploadId,
110
+ MultipartUpload: { Parts: [...copied, { PartNumber: last, ETag: tail.ETag }] },
111
+ }));
112
+ },
113
+ // Whether an object is there, without reading it.
114
+ async exists(key) {
115
+ try { await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); return true; } catch (err) {
116
+ if (err?.name === 'NotFound' || err?.$metadata?.httpStatusCode === 404) return false;
117
+ throw err;
118
+ }
119
+ },
120
+ async del(key) {
121
+ await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
122
+ },
123
+ getUrl: (key, seconds = LINK_SECONDS) => getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn: seconds }),
124
+ putUrl: (key, seconds = LINK_SECONDS) => getSignedUrl(s3, new PutObjectCommand({ Bucket: bucket, Key: key }), { expiresIn: seconds }),
125
+ headUrl: (key, seconds = LINK_SECONDS) => getSignedUrl(s3, new HeadObjectCommand({ Bucket: bucket, Key: key }), { expiresIn: seconds }),
126
+ };
127
+ }
@@ -0,0 +1,58 @@
1
+ // THE BROWSER'S WAY BACK FROM OPENAI, TAKEN ON THIS COMPUTER FOR A CODEX
2
+ // SIGN-IN THAT RUNS IN THE CLOUD.
3
+ //
4
+ // Codex's browser sign-in sends the browser back to the machine running
5
+ // `codex login`, at 127.0.0.1:1455. The browser is here and the command is in
6
+ // the cloud, so this listens on that address while she signs in and hands the
7
+ // query on: what OpenAI documents doing over SSH for a remote machine. The
8
+ // query is a one-time code only the cloud's Codex can spend (it alone holds
9
+ // the PKCE secret); no login passes through here.
10
+ import http from 'node:http';
11
+ import { NAME } from '../../shared/product-name.mjs';
12
+
13
+ // Where an OpenAI sign-in link sends the browser back, when that is this
14
+ // computer's own address; null otherwise.
15
+ export function wayBack(link) {
16
+ try {
17
+ const back = new URL(new URL(link).searchParams.get('redirect_uri') || '');
18
+ const port = Number(back.port);
19
+ if (back.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(back.hostname) || !(port > 0)) return null;
20
+ return { port, path: back.pathname };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ const page = (ok) => {
27
+ const line = ok ? 'Signed in to Codex. You can close this tab.' : `That didn’t finish. Go back to ${NAME} and try again.`;
28
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${ok ? 'Signed in to Codex' : 'Codex sign-in'}</title>`
29
+ + '<style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}p{margin:0 24px;text-align:center}</style>'
30
+ + `</head><body><p>${line}</p></body></html>`;
31
+ };
32
+
33
+ // Listens at `port` until closed; anything but `path` is not found, including
34
+ // the /cancel a local `codex login` sends whatever holds its port.
35
+ // `onReturn(query)` says whether the cloud took it, and the browser is told.
36
+ export function listenForWayBack({ port, path, onReturn }) {
37
+ // Every answer closes its connection, so closing the server lets the port
38
+ // go at once and still lets a page being answered reach the browser.
39
+ const server = http.createServer(async (req, res) => {
40
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
41
+ if (req.method !== 'GET' || u.pathname !== path) {
42
+ res.writeHead(404, { 'content-type': 'text/plain', connection: 'close' });
43
+ res.end('Not Found');
44
+ return;
45
+ }
46
+ let ok = false;
47
+ try { ok = !!(await onReturn(u.search.slice(1))); } catch { /* told as not finished */ }
48
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' });
49
+ res.end(page(ok));
50
+ });
51
+ return new Promise((resolve, reject) => {
52
+ server.once('error', reject);
53
+ server.listen(port, '127.0.0.1', () => {
54
+ server.off('error', reject);
55
+ resolve({ close() { server.close(); server.closeIdleConnections?.(); } });
56
+ });
57
+ });
58
+ }
@@ -0,0 +1,15 @@
1
+ // The session and bucket clients a cloud run uses: the owner's own AWS
2
+ // account, or the managed cloud's server. Loaded only when a run needs them,
3
+ // so a Mac that never uses the cloud never loads AWS.
4
+ import { readCloudKey, forgetCloudKey } from './config.mjs';
5
+
6
+ export async function cloudClients(config, { env = process.env, home } = {}) {
7
+ if (typeof config?.gateway === 'string') {
8
+ const key = readCloudKey(env, home);
9
+ if (!key) throw new Error('this Mac has no cloud key yet; sign in for the cloud');
10
+ const { managedClients } = await import('./managed.mjs');
11
+ return managedClients({ url: config.gateway, key, onSignedOut: () => forgetCloudKey(key, env, home) });
12
+ }
13
+ const { awsTransport, awsStorage } = await import('./aws.mjs');
14
+ return { transport: awsTransport(config), storage: awsStorage(config) };
15
+ }
@@ -0,0 +1,53 @@
1
+ // A CODEX WORKER WHOSE APP-SERVER IS IN THE CLOUD (docs/cloud/PLAN.md).
2
+ //
3
+ // On the Mac a Codex worker ends when its turn ends, and the folder already
4
+ // holds its work. A cloud worker's turn ends in the sandbox, and its work
5
+ // reaches the folder only after the proxy (main/cloud/run-cli.mjs) has
6
+ // stopped the remote app-server, collected and imported. Everything that
7
+ // reads the folder on exit (the change card, the ship queue, the next run)
8
+ // must wait for that, so the worker's `exit` is held here until the proxy
9
+ // process itself has exited (Codex's review, 2026-10-13, finding 1):
10
+ //
11
+ // - the turn's own ending is kept, and the moment it happens the worker's
12
+ // input closes, its cards close (`onTurnEnd`), and the connection is
13
+ // closed, which stops the proxy and starts the homecoming;
14
+ // - a reply that arrives in that gap is refused as TURN_CLOSED, which is
15
+ // the contract main/live-replies.mjs already has for "too late for this
16
+ // turn": it is saved as an ordinary reply and carried by the next run;
17
+ // - when the proxy exits, the held ending goes out, unless the proxy said
18
+ // the work could not be brought home (75), or the account's cloud sign-in
19
+ // was missing, refused or ran out (77), each of which goes out as a
20
+ // transport fault: not a delivered reply, not an attempt spent.
21
+ import { TURN_CLOSED } from '../claude-input.mjs';
22
+
23
+ export const IMPORT_FAILED = 75;
24
+ export const LOGIN_TROUBLE = 77;
25
+
26
+ export function holdUntilHome({ worker, client, procExit, onTurnEnd = () => {} }) {
27
+ const realEmit = worker.emit.bind(worker);
28
+ let held = null;
29
+ worker.emit = (event, ...args) => {
30
+ if (event !== 'exit') return realEmit(event, ...args);
31
+ if (held) return true;
32
+ held = args; // [code, signal, how]
33
+ worker.steer = () => {
34
+ const err = new Error('The turn has ended; this reply will start the next one.');
35
+ err.code = TURN_CLOSED;
36
+ return Promise.reject(err);
37
+ };
38
+ try { onTurnEnd(); } catch { /* cards already gone */ }
39
+ try { client.close('the cloud run finished its turn'); } catch { /* already closed */ }
40
+ // A proxy that exits with a code finished: the code is the remote's, or
41
+ // 75 for work it could not bring home. One that was killed, or exited with
42
+ // no code at all, never said, so the work is not known to be home.
43
+ const notHome = (extra = {}) => realEmit('exit', IMPORT_FAILED, null, { ...(held[2] || {}), transportFault: true, ...extra });
44
+ Promise.resolve(procExit).then((out) => {
45
+ const code = out?.code;
46
+ if (code === LOGIN_TROUBLE) notHome({ cloudLoginTrouble: true });
47
+ else if (code === IMPORT_FAILED || !Number.isInteger(code) || out?.signal) notHome();
48
+ else realEmit('exit', ...held);
49
+ }, notHome);
50
+ return true;
51
+ };
52
+ return worker;
53
+ }
@@ -0,0 +1,107 @@
1
+ // WHERE A MAC'S CLOUD RUNS ARE SET UP, AND WHETHER THEY CAN RUN AT ALL.
2
+ //
3
+ // scripts/cloud-setup.mjs writes ~/.agentbox/cloud.json (the bucket, the
4
+ // runtime, the profile it signs as); it is never in git, because it names an
5
+ // AWS account. Each account's sign-in for the cloud is made and kept in the
6
+ // cloud, never on this Mac (main/cloud/logins.mjs).
7
+ import os from 'node:os';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { Name } from '../../shared/product-name.mjs';
11
+
12
+ // `home` is the supervisor's own idea of the home folder (config.home), so a
13
+ // supervisor built for a test, with a home that is not the developer's, never
14
+ // reads the developer's real setup.
15
+ export function cloudConfigPath(env = process.env, home = os.homedir()) {
16
+ return env.AGENTBOX_CLOUD_CONFIG || path.join(home || os.homedir(), '.agentbox', 'cloud.json');
17
+ }
18
+
19
+ // Either the owner's own AWS account (bucket, runtime, region), or the
20
+ // managed cloud's server (`gateway`, its address), whose key sits beside it.
21
+ export function readCloudConfig(env = process.env, home = os.homedir()) {
22
+ try {
23
+ const c = JSON.parse(fs.readFileSync(cloudConfigPath(env, home), 'utf8'));
24
+ return (c?.bucket && c?.runtimeArn && c?.region) || typeof c?.gateway === 'string' ? c : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Where Agentbox Cloud is: the server's address and the database's public
32
+ * address and key. A checkout keeps it in cloud/, a Mac may keep its own
33
+ * copy, and a release build carries it baked into its package.json
34
+ * (scripts/release.mjs). All of them are out of git.
35
+ */
36
+ export function cloudAccountConfig(appDir, { home = os.homedir() } = {}) {
37
+ const whole = (c) => typeof c?.gateway === 'string' && typeof c.supabaseUrl === 'string' && typeof c.anonKey === 'string';
38
+ for (const file of [path.join(appDir, 'cloud', 'cloud.public.json'), path.join(home, '.agentbox', 'cloud.public.json')]) {
39
+ try {
40
+ const c = JSON.parse(fs.readFileSync(file, 'utf8'));
41
+ if (whole(c)) return c;
42
+ } catch { /* try the next */ }
43
+ }
44
+ try {
45
+ const baked = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).bakedCloud;
46
+ if (whole(baked)) return baked;
47
+ } catch { /* none */ }
48
+ return null;
49
+ }
50
+
51
+ export const cloudKeyPath =(env = process.env, home = os.homedir()) => path.join(path.dirname(cloudConfigPath(env, home)), 'cloud-key');
52
+
53
+ // WHAT THE CLOUD IMAGE SAID IT CAN DO at its last start, per cloud. A run can
54
+ // only learn it from a start, so the next run reads what the last one heard;
55
+ // an image rebuilt without something says so at its first start.
56
+ const capsPath = (env, home) => path.join(path.dirname(cloudConfigPath(env, home)), 'cloud-image.json');
57
+ const cloudId = (config) => String(config?.runtimeArn || config?.gateway || '');
58
+
59
+ export function imageCaps(config, env = process.env, home = os.homedir()) {
60
+ try {
61
+ const caps = JSON.parse(fs.readFileSync(capsPath(env, home), 'utf8'))[cloudId(config)]?.caps;
62
+ return Array.isArray(caps) ? caps.filter((c) => typeof c === 'string') : [];
63
+ } catch { return []; }
64
+ }
65
+
66
+ export function noteImageCaps(config, caps, env = process.env, home = os.homedir()) {
67
+ const file = capsPath(env, home);
68
+ let all = {};
69
+ try { all = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first */ }
70
+ const id = cloudId(config);
71
+ const now = [...new Set(caps)].sort();
72
+ if (JSON.stringify(all[id]?.caps) === JSON.stringify(now)) return;
73
+ all[id] = { caps: now, at: Date.now() };
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
76
+ fs.writeFileSync(tmp, JSON.stringify(all, null, 2));
77
+ fs.renameSync(tmp, file);
78
+ }
79
+
80
+ export function readCloudKey(env = process.env, home = os.homedir()) {
81
+ try { return fs.readFileSync(cloudKeyPath(env, home), 'utf8').trim() || null; } catch { return null; }
82
+ }
83
+
84
+ /**
85
+ * The server said this key is not known (it went unused, or another Mac
86
+ * signed this one out): this Mac is signed out of the cloud, as if by its own
87
+ * hand, so nothing keeps sending threads there with it. A key a newer sign-in
88
+ * put in its place is left alone.
89
+ */
90
+ export function forgetCloudKey(key, env = process.env, home = os.homedir()) {
91
+ if (!key || readCloudKey(env, home) !== key) return false;
92
+ fs.rmSync(cloudKeyPath(env, home), { force: true });
93
+ if (typeof readCloudConfig(env, home)?.gateway === 'string') fs.rmSync(cloudConfigPath(env, home), { force: true });
94
+ return true;
95
+ }
96
+
97
+ export function writeCloudKey(key, env = process.env, home = os.homedir()) {
98
+ const file = cloudKeyPath(env, home);
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ const tmp = `${file}.${process.pid}.tmp`;
101
+ fs.writeFileSync(tmp, key, { mode: 0o600 });
102
+ fs.renameSync(tmp, file);
103
+ }
104
+
105
+ // The keychain service the first cloud build kept its own Claude tokens
106
+ // under. Nothing writes it now; main/cloud/logins.mjs deletes what is left.
107
+ export const OLD_TOKEN_SERVICE = `${Name} cloud: Claude sign-in`;
@@ -0,0 +1,219 @@
1
+ // THE PROJECT'S DOCUMENTS FOLDER, FOR A CLOUD RUN.
2
+ //
3
+ // A worker on this Mac reads the plans and notes in the project's documents
4
+ // folder and writes its designs and reports there, where the app draws them.
5
+ // A cloud run gets a copy at the same path: everything small, and this
6
+ // thread's own designs folder, newest first, as much as fits. What it writes
7
+ // there comes back while it works and once more at the end
8
+ // (main/cloud/run.mjs), and is laid in here. Never a deletion, never over a
9
+ // file someone changed on this Mac while the run was away, and never over one
10
+ // the run did not take with it: those are kept, and the run's copy goes beside.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import crypto from 'node:crypto';
15
+ import { run } from './exec.mjs';
16
+
17
+ // The folders that hold pictures by the gigabyte. Only this thread's own
18
+ // designs go up from them; the rest of the folder is small.
19
+ const HEAVY = new Set(['designs', 'attachments']);
20
+ export const DOCS_LIMITS = {
21
+ file: 256 * 1024,
22
+ ownFile: 25 * 1024 * 1024,
23
+ own: 50 * 1024 * 1024,
24
+ total: 100 * 1024 * 1024,
25
+ files: 5000,
26
+ };
27
+
28
+ const inside = (p, root) => p === root || p.startsWith(root + path.sep);
29
+
30
+ /** The files, by path inside `dir`, that go up with a cloud run for `item`. */
31
+ export function docsToSend(dir, { item = null, limits = DOCS_LIMITS } = {}) {
32
+ const own = item && /^[A-Za-z0-9_-]+$/.test(item) ? `designs/${item}` : null;
33
+ const picked = [];
34
+ let total = 0;
35
+ let ownTotal = 0;
36
+ const walk = (rel) => {
37
+ let entries = [];
38
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
39
+ entries.sort((a, b) => a.name.localeCompare(b.name));
40
+ for (const e of entries) {
41
+ if (picked.length >= limits.files) return;
42
+ if (e.name.startsWith('.')) continue;
43
+ const r = rel ? `${rel}/${e.name}` : e.name;
44
+ if (/[\n\r]/.test(r)) continue;
45
+ if (e.isDirectory()) {
46
+ if (!rel && HEAVY.has(e.name)) { if (own && e.name === 'designs') walkOwn(); continue; }
47
+ walk(r);
48
+ continue;
49
+ }
50
+ if (!e.isFile()) continue;
51
+ let size;
52
+ try { size = fs.statSync(path.join(dir, r)).size; } catch { continue; }
53
+ if (size > limits.file || total + size > limits.total) continue;
54
+ picked.push(r);
55
+ total += size;
56
+ }
57
+ };
58
+ // Newest first: a thread whose designs do not all fit takes its latest
59
+ // work, and the rest stays on this Mac.
60
+ const walkOwn = () => {
61
+ const found = [];
62
+ const stack = [own];
63
+ while (stack.length) {
64
+ const rel = stack.shift();
65
+ let entries = [];
66
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { continue; }
67
+ for (const e of entries) {
68
+ if (e.name.startsWith('.')) continue;
69
+ const r = `${rel}/${e.name}`;
70
+ if (/[\n\r]/.test(r)) continue;
71
+ if (e.isDirectory()) { stack.push(r); continue; }
72
+ if (!e.isFile()) continue;
73
+ let st;
74
+ try { st = fs.statSync(path.join(dir, r)); } catch { continue; }
75
+ if (st.size <= limits.ownFile) found.push({ r, size: st.size, at: st.mtimeMs });
76
+ }
77
+ }
78
+ found.sort((a, b) => b.at - a.at || a.r.localeCompare(b.r));
79
+ for (const f of found) {
80
+ if (picked.length >= limits.files) return;
81
+ if (ownTotal + f.size > limits.own) continue;
82
+ picked.push(f.r);
83
+ ownTotal += f.size;
84
+ }
85
+ };
86
+ walk('');
87
+ return picked;
88
+ }
89
+
90
+ /** Those files as one gzipped tar, paths inside `dir`. */
91
+ export function tarDocs(dir, files) {
92
+ return run('tar', ['-czf', '-', '-C', dir, '--null', '-T', '-'], { input: files.join('\0') + '\0', encoding: null, env: { ...process.env, COPYFILE_DISABLE: '1' } });
93
+ }
94
+
95
+ const stampOf = (st) => `${st.size}:${Math.round(st.mtimeMs)}`;
96
+ const hashOf = (body) => crypto.createHash('sha256').update(body).digest('hex');
97
+
98
+ // `notes.md` -> `notes (cloud).md`, then `notes (cloud 2).md`, and so on.
99
+ function besideName(file) {
100
+ const ext = path.extname(file);
101
+ const base = file.slice(0, file.length - ext.length);
102
+ for (let i = 1; ; i += 1) {
103
+ const p = `${base} (cloud${i > 1 ? ` ${i}` : ''})${ext}`;
104
+ if (!fs.existsSync(p)) return p;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Lay an archive of documents the run wrote into `dir`.
110
+ *
111
+ * `sentAt` is when the run left with the folder; `laid` is what earlier calls
112
+ * for the same run wrote, path -> { stamp, hash }, and is updated in place. A
113
+ * version this run already laid is never laid again: the run sends a file
114
+ * until the Mac confirms it, so the same one can arrive twice. Otherwise a file
115
+ * is written over only if it is missing here, unchanged here since the run
116
+ * left, or is still exactly what this run itself laid. Anything else is a
117
+ * change made on this Mac, and the run's copy goes beside it.
118
+ *
119
+ * `sent` is every path the run took with it. A file here that is not one of
120
+ * them was never the run's to change, however old it is, so its copy goes
121
+ * beside it too. A run recorded before the list was kept passes none.
122
+ */
123
+ export async function layDocs({ tgz, dir, sentAt = 0, laid = {}, sent = null }) {
124
+ const took = sent ? new Set(sent) : null;
125
+ const out = { written: [], beside: [], moved: [] };
126
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentbox-docs-'));
127
+ try {
128
+ // From a file, not a pipe: tar may stop reading at the archive's end
129
+ // marker, and a pipe it stopped reading fails the write (EPIPE) on a busy Mac.
130
+ const file = path.join(tmp, '.agentbox-docs.tgz');
131
+ fs.writeFileSync(file, tgz);
132
+ const into = path.join(tmp, 'docs');
133
+ fs.mkdirSync(into);
134
+ await run('tar', ['-xzf', file, '-C', into]);
135
+ fs.mkdirSync(dir, { recursive: true });
136
+ const root = fs.realpathSync(dir);
137
+ const files = [];
138
+ const walk = (rel) => {
139
+ for (const e of fs.readdirSync(path.join(into, rel), { withFileTypes: true })) {
140
+ const r = rel ? `${rel}/${e.name}` : e.name;
141
+ if (e.isDirectory()) walk(r);
142
+ else if (e.isFile()) files.push(r);
143
+ }
144
+ };
145
+ walk('');
146
+ // The nearest folder that exists, after links, must be this one or in
147
+ // it: a link inside the folder never carries a file out of it.
148
+ const safeParent = (file) => {
149
+ let parent = path.dirname(file);
150
+ while (!fs.existsSync(parent)) parent = path.dirname(parent);
151
+ if (!inside(fs.realpathSync(parent), root)) return false;
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ return inside(fs.realpathSync(path.dirname(file)), root);
154
+ };
155
+ for (const rel of files) {
156
+ const asIs = path.join(root, rel);
157
+ let target = asIs;
158
+ const body = fs.readFileSync(path.join(into, rel));
159
+ const hash = hashOf(body);
160
+ if (laid[rel]?.hash === hash) continue;
161
+ // A file whose folder here is a link out of it is not dropped: it lands
162
+ // in a folder of its own, and the run's work is all still home.
163
+ if (!safeParent(target)) {
164
+ target = path.join(root, 'from-the-cloud', rel);
165
+ if (!safeParent(target)) throw new Error(`nowhere safe to put ${rel}`);
166
+ out.moved.push(path.relative(root, target));
167
+ }
168
+ let here = null;
169
+ try { here = fs.lstatSync(target); } catch { /* new */ }
170
+ // A link or a folder where the file goes is left as it is, and the
171
+ // run's copy goes beside it.
172
+ if (here && !here.isFile()) {
173
+ target = besideName(target);
174
+ out.beside.push(path.relative(root, target));
175
+ here = null;
176
+ }
177
+ if (here) {
178
+ // The same bytes already here: nothing to write. It becomes the run's
179
+ // only if the run took it or wrote it before, never a file left here.
180
+ if (here.size === body.length && fs.readFileSync(target).equals(body)) {
181
+ const owned = !took || took.has(rel) || !!laid[rel]?.stamp;
182
+ laid[rel] = { stamp: owned ? stampOf(here) : null, hash };
183
+ continue;
184
+ }
185
+ const mine = laid[rel]?.stamp === stampOf(here);
186
+ // Whole milliseconds on both sides: `sentAt` is a Date.now() and counts
187
+ // them, an mtime does not, so a file saved 0.7 ms before the run left
188
+ // read as newer than it and the run's own edit went beside a file
189
+ // nobody had touched.
190
+ const untouched = mine || (Math.floor(here.mtimeMs) <= sentAt && (!took || took.has(rel)));
191
+ if (!untouched) {
192
+ target = besideName(target);
193
+ out.beside.push(path.relative(root, target));
194
+ }
195
+ }
196
+ // A fresh name, made only if nothing is there: a link left at a name
197
+ // this could guess would otherwise be written through.
198
+ const part = path.join(path.dirname(target), `.agentbox-${crypto.randomUUID()}.part`);
199
+ fs.writeFileSync(part, body, { flag: 'wx' });
200
+ fs.renameSync(part, target);
201
+ if (target === asIs) out.written.push(rel);
202
+ laid[rel] = { stamp: target === asIs ? stampOf(fs.statSync(target)) : laid[rel]?.stamp ?? null, hash };
203
+ }
204
+ } finally {
205
+ fs.rmSync(tmp, { recursive: true, force: true });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** The documents folder a run may take, or null: it must exist and must not hold, or sit in, the task folder. */
211
+ export function docsFolderFor(docs, cwd) {
212
+ if (!docs || !path.isAbsolute(docs)) return null;
213
+ let real;
214
+ let realCwd;
215
+ try { real = fs.realpathSync(docs); realCwd = fs.realpathSync(cwd); } catch { return null; }
216
+ if (!fs.statSync(real).isDirectory()) return null;
217
+ if (inside(real, realCwd) || inside(realCwd, real)) return null;
218
+ return docs;
219
+ }
@@ -0,0 +1,27 @@
1
+ // A command run without holding up this process, which every cloud run's relay
2
+ // shares (./host.mjs): one run's git or tar must not stop the others.
3
+ import { spawn } from 'node:child_process';
4
+
5
+ // Resolves to stdout (a string, or a Buffer when `encoding` is null); rejects,
6
+ // as execFileSync throws, with "Command failed: <command>" and its stderr.
7
+ export function run(cmd, args, { cwd, env = process.env, input = null, encoding = 'utf8' } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ let child;
10
+ try { child = spawn(cmd, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); } catch (err) { reject(err); return; }
11
+ const out = [];
12
+ const err = [];
13
+ child.stdout.on('data', (d) => out.push(d));
14
+ child.stderr.on('data', (d) => err.push(d));
15
+ child.on('error', reject);
16
+ child.on('close', (code, signal) => {
17
+ const stdout = Buffer.concat(out);
18
+ if (code === 0) { resolve(encoding ? stdout.toString(encoding) : stdout); return; }
19
+ const stderr = Buffer.concat(err).toString('utf8');
20
+ const e = new Error(`Command failed: ${cmd} ${args.join(' ')}\n${stderr}`);
21
+ Object.assign(e, { status: code, signal, stderr });
22
+ reject(e);
23
+ });
24
+ child.stdin.on('error', () => {});
25
+ child.stdin.end(input ?? undefined);
26
+ });
27
+ }