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
@@ -66,28 +66,55 @@ export function artifactRoots(prod) {
66
66
  // now resolves by exact path or not at all.
67
67
  const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'sessions', 'runs', 'pending-writes', 'checkpoints']);
68
68
 
69
- // Hunt the file down by NAME inside one product's trees, newest match wins.
70
- // Workers write links relative to whatever directory they were standing in, so
71
- // a fixed root list will always miss some of them, and every miss cost her a
72
- // 10-minute detour (2026-08-05). Bounded: six levels, own product only.
73
- function huntByName(base, roots, fs) {
74
- let best = null;
75
- const walk = (dir, depth) => {
69
+ // How long one walk of a tree answers for it. A thread asks for every name it
70
+ // mentions the moment it opens, so the walk has to outlive that burst; a
71
+ // picture that lands later is found by the next look, which comes after this.
72
+ export const HUNT_FRESH_MS = 5000;
73
+ const walks = new Map();
74
+
75
+ // Every file name in one tree, read without holding the main process.
76
+ async function walkNames(tree, fs) {
77
+ const names = new Map();
78
+ const walk = async (dir, depth) => {
76
79
  if (depth > 6) return;
77
80
  let entries;
78
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
81
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
82
+ const below = [];
79
83
  for (const e of entries) {
80
84
  if (e.name.startsWith('.')) continue;
81
85
  const full = path.join(dir, e.name);
82
- if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full, depth + 1); }
83
- else if (e.name === base) {
84
- let m = 0;
85
- try { m = fs.statSync(full).mtimeMs; } catch {}
86
- if (!best || m > best.m) best = { full, m };
87
- }
86
+ if (e.isDirectory()) { if (!SKIP.has(e.name)) below.push(walk(full, depth + 1)); }
87
+ else if (names.has(e.name)) names.get(e.name).push(full);
88
+ else names.set(e.name, [full]);
88
89
  }
90
+ await Promise.all(below);
89
91
  };
90
- for (const root of roots) walk(root, 0);
92
+ await walk(tree, 0);
93
+ return names;
94
+ }
95
+
96
+ function namesIn(tree, fs, now) {
97
+ const kept = walks.get(tree);
98
+ if (kept && kept.fs === fs && now - kept.at < HUNT_FRESH_MS) return kept.names;
99
+ const names = walkNames(tree, fs);
100
+ walks.set(tree, { at: now, fs, names });
101
+ if (walks.size > 32) walks.delete(walks.keys().next().value);
102
+ return names;
103
+ }
104
+
105
+ // Hunt the file down by NAME inside one product's trees, newest match wins.
106
+ // Workers write links relative to whatever directory they were standing in, so
107
+ // a fixed root list will always miss some of them, and every miss cost her a
108
+ // 10-minute detour (2026-08-05). Bounded: six levels, own product only.
109
+ async function huntByName(base, trees, fs, now) {
110
+ let best = null;
111
+ for (const tree of trees) {
112
+ for (const full of (await namesIn(tree, fs, now)).get(base) ?? []) {
113
+ let m;
114
+ try { m = (await fs.promises.stat(full)).mtimeMs; } catch { continue; }
115
+ if (!best || m > best.m) best = { full, m };
116
+ }
117
+ }
91
118
  return best?.full ?? null;
92
119
  }
93
120
 
@@ -104,7 +131,7 @@ export function notFoundMessage(raw, own, product) {
104
131
  //
105
132
  // `products` is the whole list only so the card's own product can be found in
106
133
  // it. Nothing in here reads another entry.
107
- export function resolveArtifact({ src, product, products = [], accountRoot, storeRoot, fs = fsDefault }) {
134
+ export async function resolveArtifact({ src, product, products = [], accountRoot, storeRoot, fs = fsDefault, now = Date.now() }) {
108
135
  if (typeof src !== 'string' || !src) return { ok: false, error: 'No file was named.' };
109
136
  const raw = decodeURIComponent(src.replace(/^file:\/\//, '')).replace(/^\.\//, '');
110
137
  const own = products.find((x) => x.slug === product) ?? null;
@@ -144,10 +171,12 @@ export function resolveArtifact({ src, product, products = [], accountRoot, stor
144
171
 
145
172
  // The hunt takes the two TREES, not the root list: designs/ and attachments/
146
173
  // sit inside the docs dir already, and walking them twice finds nothing new.
174
+ // A run's change record is never hunted for: `runs` is skipped, so the walk
175
+ // could not find it, and the open thread asks for it on every refresh.
147
176
  const base = path.basename(raw);
148
177
  const trees = own ? [own.dir, own.repoPath].filter(Boolean) : [];
149
- if (base && trees.length) {
150
- const found = huntByName(base, trees, fs);
178
+ if (base && trees.length && raw.split('/')[0] !== 'runs') {
179
+ const found = await huntByName(base, trees, fs, now);
151
180
  if (found) return { ok: true, path: found };
152
181
  }
153
182
 
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { Name } from '../shared/product-name.mjs';
2
3
 
3
4
  // THE SESSION STOPPED LISTENING BEFORE IT TOOK THIS MESSAGE. Not a refusal:
4
5
  // the turn closed, or the process went, in the seconds between her pressing
@@ -16,6 +17,17 @@ function turnClosed(message) {
16
17
  // before the session is closed anyway. Measured wakes arrive in about a second.
17
18
  const WAKE_GRACE_MS = 2 * 60 * 1000;
18
19
 
20
+ // Said to the agent, never shown to the person: the trace keeps no user turns.
21
+ // Paths are cut to their last part: a cloud run's relay uploads any file its
22
+ // input names outside the task folder (main/cloud/run.mjs `filesNamedIn`).
23
+ export function stillRunningNote(tasks) {
24
+ const plain = (text) => String(text).replace(/(^|[\s"'`(=:])\/(?:[^\s"'`<>()[\]{},;\\]*\/)+/g, '$1');
25
+ const names = tasks.map((t) => `"${plain(t.description || t.task_id).slice(0, 120)}"`).join(', ');
26
+ return `This is ${Name}, not the person. Your turn ended with ${tasks.length === 1 ? 'this' : 'these'} still running in the background: ${names}. `
27
+ + 'Your run stays open, and your answer is not delivered, until nothing you started is still running. '
28
+ + 'Stop any you no longer need. If you are waiting on one, say what for in one line and end your turn.';
29
+ }
30
+
19
31
  // THE PROMPT LEAVES THE LINE WHEN REPLIES ARRIVE ON STDIN. Both the print flag
20
32
  // and the input-format flags are read off the engine's `run` definition, so an
21
33
  // engine of this family that spells either of them differently is a definition
@@ -45,13 +57,35 @@ export function attachClaudeInput(child) {
45
57
  // end of the run, and closing there killed the suite it was waiting on
46
58
  // (tests/an-agent-waiting-on-its-own-tests-does-not-come-back-to-you).
47
59
  let waiting = [], wakeTimer = null;
60
+ // Jobs the agent has been told are still out, and the messages that told it.
61
+ // A forgotten job holds a finished answer until Claude Code's own limit
62
+ // stops it, thirty minutes and more
63
+ // (tests/a-command-an-agent-left-running-does-not-hold-its-answer-back).
64
+ const named = new Set(), ours = new Set();
48
65
  // Input only ever closes at a result with nothing out, so a closed input is
49
66
  // the one proof the run reached its end. A session that exits with input
50
67
  // still open was stopped part-way, whatever its last turn said
51
68
  // (tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
52
69
  let ended = false;
70
+ // A TURN IS RUNNING between its `system/init` and its result. A message
71
+ // written while none is starts the next one, and a turn that starts while
72
+ // one waits takes it with it, so either way it is being answered, though
73
+ // Claude replays it only when that turn first speaks. Send now must not cut
74
+ // that turn: it was the answer (w-0202576e73).
75
+ let inTurn = false;
53
76
  const closeInput = () => { closed = true; ended = true; clearTimeout(wakeTimer); child.stdin.end(); };
54
77
  const mayClose = () => !held && atResult && !pending.size && !waiting.length;
78
+ // Not into a session she is driving, and not when her own message is about
79
+ // to start the next turn. Kept out of `pending`, so it never holds input open.
80
+ const nameWhatIsStillOut = () => {
81
+ if (closed || held || pending.size || !waiting.some((t) => t?.task_id && !named.has(t.task_id))) return;
82
+ for (const t of waiting) if (t?.task_id) named.add(t.task_id);
83
+ const uuid = randomUUID();
84
+ ours.add(uuid);
85
+ try {
86
+ child.stdin.write(JSON.stringify({ type: 'user', uuid, message: { role: 'user', content: stillRunningNote(waiting) }, parent_tool_use_id: null, session_id: '' }) + '\n');
87
+ } catch { /* the input is gone; `fail` hears it */ }
88
+ };
55
89
  const fail = reason => {
56
90
  closed = true;
57
91
  for (const request of pending.values()) {
@@ -81,12 +115,20 @@ export function attachClaudeInput(child) {
81
115
  }
82
116
  }
83
117
  if (event.type === 'system' && event.subtype === 'bridge_state') child.emit('bridge-state', event);
84
- if (event.type === 'assistant') atResult = false;
118
+ // A cloud run whose input the sandbox closed at the end of its turn
119
+ // while this Mac slept (main/cloud/run.mjs): the end of the run, as if
120
+ // closed here. A reply still waiting is kept for the next run.
121
+ if (event.type === 'system' && event.subtype === 'agentbox_input_closed_in_cloud' && !closed) closeInput();
122
+ if (event.type === 'system' && event.subtype === 'init') {
123
+ inTurn = true;
124
+ for (const request of pending.values()) request.taken = true;
125
+ }
126
+ if (event.type === 'assistant') { atResult = false; inTurn = true; }
85
127
  if (event.type === 'user' && !event.parent_tool_use_id && typeof event.uuid === 'string' && !seenUsers.has(event.uuid)) {
86
128
  seenUsers.add(event.uuid);
87
129
  const content = event.message?.content;
88
130
  const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
89
- if (held && !pending.has(event.uuid) && text && !event.isMeta) child.emit('remote-user', event);
131
+ if (held && !pending.has(event.uuid) && !ours.has(event.uuid) && text && !event.isMeta) child.emit('remote-user', event);
90
132
  if (text) { atResult = false; child.emit('input-turn', event); }
91
133
  }
92
134
  if (event.type === 'user' && pending.has(event.uuid)) {
@@ -109,7 +151,9 @@ export function attachClaudeInput(child) {
109
151
  }
110
152
  if (event.type === 'result') {
111
153
  atResult = true;
154
+ inTurn = false;
112
155
  if (mayClose()) closeInput();
156
+ else if (waiting.length) nameWhatIsStillOut();
113
157
  }
114
158
  }
115
159
  });
@@ -136,7 +180,7 @@ export function attachClaudeInput(child) {
136
180
  // ONLY WHILE A MESSAGE OF HERS IS WAITING. The cut turn ends in a result,
137
181
  // and with nothing pending that result closes the input and ends the run.
138
182
  child.interrupt = () => {
139
- if (closed || !pending.size) return Promise.resolve({ interrupted: false });
183
+ if (closed || ![...pending.values()].some(request => !request.taken)) return Promise.resolve({ interrupted: false });
140
184
  child.lastInterruptAt = Date.now();
141
185
  return child.control({ subtype: 'interrupt' }).then(() => ({ interrupted: true }));
142
186
  };
@@ -153,7 +197,7 @@ export function attachClaudeInput(child) {
153
197
  child.steer = text => new Promise((resolve, reject) => {
154
198
  if (closed || !child.stdin.writable) return reject(turnClosed('The turn has ended. Send the message again to continue it.'));
155
199
  const uuid = randomUUID();
156
- pending.set(uuid, { resolve, reject });
200
+ pending.set(uuid, { resolve, reject, taken: !inTurn });
157
201
  try {
158
202
  child.stdin.write(JSON.stringify({ type: 'user', uuid, message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '' }) + '\n');
159
203
  } catch (error) {
@@ -96,8 +96,28 @@ export function isMaxTier(raw) {
96
96
  * Claude Code has not fetched a profile for yet: all of them come back
97
97
  * `known: false`, which every caller here treats as "leave everything exactly
98
98
  * as it was". */
99
+ const planCache = new Map();
100
+
99
101
  export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
100
102
  const at = file ?? planFile(profile, home);
103
+ // Keyed on the file's mtime AND its size, as accountIdentity is keyed on the
104
+ // mtime alone: the cloud tiles ask on every snapshot.
105
+ //
106
+ // THE SIZE IS THERE BECAUSE AN MTIME CAN STAND STILL (w-dac993a554). Two
107
+ // writes inside one tick of the filesystem's clock carry the same mtime, and
108
+ // the cache then served the first one's plan for ever. A subscription
109
+ // renewed in that window read as ended until the file was written again, so
110
+ // `Supervisor#_forgetRenewedSubscriptions` never let the account back in.
111
+ let stat = null;
112
+ try { stat = fs.statSync(at); } catch { /* read below says none */ }
113
+ const hit = planCache.get(at);
114
+ if (stat && hit?.mtime === stat.mtimeMs && hit?.size === stat.size) return hit.plan;
115
+ const plan = readPlanNow(at);
116
+ if (stat) planCache.set(at, { mtime: stat.mtimeMs, size: stat.size, plan });
117
+ return plan;
118
+ }
119
+
120
+ function readPlanNow(at) {
101
121
  const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
102
122
  let account = null;
103
123
  try {
@@ -128,6 +148,34 @@ export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
128
148
  return { known: true, ended: false, max: isMaxTier(tier), label: planLabel(tier), tier, from, file: at };
129
149
  }
130
150
 
151
+ /**
152
+ * WHICH ACCOUNT AND PLAN ONE LOGIN IS ON, as a string that changes only when
153
+ * one of them does: an upgrade, extra usage switched on, a renewal, or a
154
+ * /login as somebody else. Claude Code rewrites this file all day for other
155
+ * reasons, so only these fields count (named as Claude Code 2.1.305 writes
156
+ * them). Null when the file names no account or cannot be read.
157
+ */
158
+ const PLAN_FIELDS = ['accountUuid', 'organizationUuid', 'organizationType', 'organizationRateLimitTier',
159
+ 'userRateLimitTier', 'seatTier', 'billingType', 'hasExtraUsageEnabled', 'planDisplayName', 'subscriptionCreatedAt'];
160
+ const stampCache = new Map();
161
+
162
+ export function planStamp(profile, { home = os.homedir(), file = null } = {}) {
163
+ const at = file ?? planFile(profile, home);
164
+ let mtime;
165
+ try { mtime = fs.statSync(at).mtimeMs; } catch { return null; }
166
+ const hit = stampCache.get(at);
167
+ if (hit?.mtime === mtime) return hit.stamp;
168
+ let stamp = null;
169
+ try {
170
+ const account = JSON.parse(fs.readFileSync(at, 'utf8'))?.oauthAccount;
171
+ if (account?.accountUuid) {
172
+ stamp = JSON.stringify(PLAN_FIELDS.map((k) => (k === 'hasExtraUsageEnabled' ? account[k] === true : account[k] ?? null)));
173
+ }
174
+ } catch { /* half written: the next write has a new mtime and is read again */ }
175
+ stampCache.set(at, { mtime, stamp });
176
+ return stamp;
177
+ }
178
+
131
179
  /**
132
180
  * THE PLAN ON EVERY ACCOUNT AGENTBOX IS CONFIGURED TO RUN ON, in the same order
133
181
  * the supervisor round-robins them. */
@@ -0,0 +1,28 @@
1
+ // Whether Claude Code still has a session open somewhere, read off the state
2
+ // file it keeps for each open session (`<home>/sessions/<pid>.json`, with the
3
+ // session id and the pid holding it). A state file whose process has gone is a
4
+ // session somebody closed.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ function alive(pid) {
9
+ try { process.kill(pid, 0); return true; } catch (err) { return err?.code === 'EPERM'; }
10
+ }
11
+
12
+ export function claudeSessionOpen(sessionId, homes = []) {
13
+ const id = String(sessionId ?? '').trim();
14
+ if (!id) return false;
15
+ for (const home of new Set(homes.filter(Boolean))) {
16
+ const dir = path.join(home, 'sessions');
17
+ let names = [];
18
+ try { names = fs.readdirSync(dir); } catch { continue; }
19
+ for (const name of names) {
20
+ if (!name.endsWith('.json')) continue;
21
+ let rec = null;
22
+ try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; }
23
+ const pid = Number(rec?.pid);
24
+ if (rec?.sessionId === id && pid > 0 && alive(pid)) return true;
25
+ }
26
+ }
27
+ return false;
28
+ }
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { attachClaudeInput } from './claude-input.mjs';
3
+
4
+ // No user message is submitted. The CLI owns authentication and the provider request.
5
+ export function runClaudeUsage(bin, args, opts, done) {
6
+ let child, timer, killTimer, finished = false, bytes = 0;
7
+ const finish = (error, report = '') => {
8
+ if (finished) return;
9
+ finished = true;
10
+ clearTimeout(timer);
11
+ if (child?.exitCode == null && child?.pid) {
12
+ child.kill('SIGTERM');
13
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
14
+ killTimer.unref();
15
+ }
16
+ done(error, report);
17
+ };
18
+ try {
19
+ child = attachClaudeInput(spawn(bin, args, { cwd: opts.cwd, env: opts.env, stdio: ['pipe', 'pipe', 'ignore'] }));
20
+ child.holdInput(true);
21
+ child.once('error', error => finish(error));
22
+ child.once('exit', () => {
23
+ clearTimeout(killTimer);
24
+ finish(Error('Claude Code closed before reporting usage.'));
25
+ });
26
+ child.stdout.on('data', data => {
27
+ bytes += data.length;
28
+ if (bytes > opts.maxBuffer) finish(Error('Claude Code sent too much usage output.'));
29
+ });
30
+ timer = setTimeout(() => finish(Error('Claude Code did not report usage in time.')), opts.timeout);
31
+ void (async () => {
32
+ try {
33
+ await child.control({ subtype: 'initialize' }, opts.timeout);
34
+ const report = await child.control({ subtype: 'get_usage', skip_behaviors: true }, opts.timeout);
35
+ finish(null, JSON.stringify(report));
36
+ } catch (error) { finish(error); }
37
+ })();
38
+ } catch (error) { finish(error); }
39
+ return child;
40
+ }
@@ -1,40 +1,13 @@
1
- // RUNNING `/usage`, WHICH TAKES TWENTY-FIVE SECONDS AND MUST NEVER BE WAITED ON.
2
- //
3
- // The pure half is shared/claude-usage.mjs: the text in, the limits out, the
4
- // words the pill prints. This is the half that shells out, and everything about
5
- // its shape comes from one measurement.
6
- //
7
- // TIMED TWICE ON HER MAC, 2026-08-31: 23.2s and 28.0s for `claude -p '/usage'`.
8
- // The percentage did not move between the two, which is the other half of the
9
- // answer: this is not a number that changes second to second, so nothing is lost
10
- // by reading it every few minutes and everything is lost by reading it on a
11
- // timer the window waits for.
12
- //
13
- // SO IT IS A CACHE WITH A BACKGROUND REFRESH. Only the explicit Settings
14
- // refresh waits for a result; snapshots always return immediately:
15
- //
16
- // - `read` answers instantly, always, from the last measurement or with null.
17
- // It never blocks and never throws.
18
- // - A refresh is kicked off when the cached figure is older than STALE_MS,
19
- // and only ever one at a time.
20
- // - The window is told when a new one lands, the same way everything else in
21
- // this app tells it: a push.
22
- //
23
- // WHY NOT A SESSION. Agentbox spawns real workers through the supervisor and could
24
- // have asked one of them. It must not: a worker is a unit of her work, it lands
25
- // in her inbox and burns a slot in the fleet, and this is a status reading. It
26
- // gets its own short-lived child process with no store, no brief and no row.
27
- //
28
- // WHAT IS NOT SENT ANYWHERE. The output carries her usage figures and nothing
29
- // else leaves this machine because of it; `main/analytics.mjs` is the only file
30
- // that sends, and none of its eight counts is fed from here.
1
+ // Cached provider usage, read through Claude Code without starting a model turn.
2
+ // Snapshots never wait; only an explicit refresh waits for the CLI's answer.
31
3
 
32
- import { execFile } from 'node:child_process';
33
4
  import fs from 'node:fs';
34
5
  import os from 'node:os';
35
6
  import path from 'node:path';
36
- import { readUsage } from '../shared/claude-usage.mjs';
7
+ import { readUsageReport } from '../shared/claude-usage.mjs';
37
8
  import { localZone, USAGE_UNREADABLE } from '../shared/usage.mjs';
9
+ import { runClaudeUsage } from './claude-usage-process.mjs';
10
+ import { NO_METERED_KEY } from './harnesses/claude.mjs';
38
11
 
39
12
  // WHERE THE READING RUNS, AND WHY IT IS NOT "WHEREVER THE APP IS". An app
40
13
  // opened from Finder has `/` as its working directory, and this used to spawn
@@ -62,9 +35,7 @@ export function usageCwd() {
62
35
  // than the number itself is reported to.
63
36
  export const STALE_MS = 5 * 60_000;
64
37
 
65
- // Long enough for the slowest run measured plus most of the same again. Past
66
- // this the reading is not late, it is not coming.
67
- const TIMEOUT_MS = 90_000;
38
+ const TIMEOUT_MS = 30_000;
68
39
 
69
40
  // After a failure, how long before it is worth trying again. A machine that is
70
41
  // signed out, rate limited, or has no Claude Code at all would otherwise be
@@ -87,7 +58,7 @@ export class ClaudeUsage {
87
58
  * the account's name ('default' or its CLAUDE_CONFIG_DIR) and `env` is the
88
59
  * environment to run it in. Supervisor.usageAccount is the real one.
89
60
  */
90
- constructor({ bin, onChange = () => {}, run = execFile, now = () => Date.now(), where = usageCwd, account = () => ({ profile: 'default' }) } = {}) {
61
+ constructor({ bin, onChange = () => {}, run = runClaudeUsage, now = () => Date.now(), where = usageCwd, account = () => ({ profile: 'default' }) } = {}) {
91
62
  this._bin = bin;
92
63
  this._selectedProfile = null;
93
64
  this._generation = 0;
@@ -164,7 +135,7 @@ export class ClaudeUsage {
164
135
  const profile = this._syncProfile();
165
136
  const generation = this._generation;
166
137
  const bin = (() => { try { return this._bin(); } catch { return null; } })();
167
- const failure = () => ({ ok: false, error: USAGE_UNREADABLE });
138
+ const failure = (error = USAGE_UNREADABLE) => ({ ok: false, error });
168
139
  if (!bin) { this._missed(); return Promise.resolve(failure()); }
169
140
  this._busy = true;
170
141
  this._pendingGeneration = generation;
@@ -178,8 +149,13 @@ export class ClaudeUsage {
178
149
  this._syncProfile();
179
150
  if (generation !== this._generation) { resolve(failure()); this._onChange(); return; }
180
151
  // Failed or unreadable output cannot acquire a fresh checked time.
181
- const limits = err ? [] : readUsage(stdout, this._now(), localZone());
182
- if (!limits.length) { this._missed(); resolve(failure()); return; }
152
+ const limits = err ? [] : readUsageReport(stdout, this._now());
153
+ if (!limits.length) {
154
+ this._missed();
155
+ const unsupported = /get_usage.*(?:not supported|unknown)|unknown.*get_usage/i.test(err?.message ?? '');
156
+ resolve(failure(unsupported ? 'Update Claude Code to read usage for this account.' : USAGE_UNREADABLE));
157
+ return;
158
+ }
183
159
  this._last = { limits, at: this._now(), zone: localZone(), profile };
184
160
  resolve({ ok: true, reading: this.peek() });
185
161
  // Even unchanged percentages now have a newly checked time to draw.
@@ -192,7 +168,9 @@ export class ClaudeUsage {
192
168
  if (profile !== 'default') env.CLAUDE_CONFIG_DIR = profile;
193
169
  try { this._run(
194
170
  bin,
195
- ['-p', '/usage', '--output-format', 'text'],
171
+ ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
172
+ '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--tools', '',
173
+ '--settings', JSON.stringify({ ...NO_METERED_KEY, disableAllHooks: true })],
196
174
  // `cwd` is the whole fix for the permission panels; see usageCwd above.
197
175
  { cwd: this._where(), env, timeout: TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' },
198
176
  finish,
@@ -0,0 +1,179 @@
1
+ // SIGNING IN TO AGENTBOX CLOUD FROM THIS MAC.
2
+ //
3
+ // A Google sign-in, through the database's own page, is traded once with the
4
+ // cloud server (cloud/gateway/) for a cloud key, kept beside cloud.json; the
5
+ // Mac's cloud.json then names the server, so its runs go there
6
+ // (main/cloud/clients.mjs). The database session itself is not kept. A Mac
7
+ // set up on its owner's own AWS account is never moved onto the server.
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { cloudConfigPath, readCloudConfig, readCloudKey, writeCloudKey, cloudKeyPath, cloudAccountConfig, forgetCloudKey } from './config.mjs';
12
+ import { withDeadline, plainAuthError } from '../team/session.mjs';
13
+ import { signInWithGoogle, cancelGoogleSignIn } from '../team/sign-in.mjs';
14
+ import { Name } from '../../shared/product-name.mjs';
15
+
16
+ const CALL_MS = 30_000;
17
+ const RETURN_URL = 'http://127.0.0.1:54783/auth/callback';
18
+
19
+ export { cloudAccountConfig };
20
+
21
+ async function ask(gateway, route, { method = 'GET', key, body } = {}) {
22
+ const init = { method, headers: { ...(key ? { authorization: `Bearer ${key}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined };
23
+ const res = await fetch(`${gateway.replace(/\/+$/, '')}${route}`, { ...init, signal: AbortSignal.timeout(CALL_MS) });
24
+ return { status: res.status, body: await res.json().catch(() => ({})) };
25
+ }
26
+
27
+ /** The version of the terms a person accepts now. */
28
+ export async function currentCloudTerms(config) {
29
+ return (await ask(config.gateway, '/v1/terms')).body.terms ?? null;
30
+ }
31
+
32
+ /**
33
+ * The ways in that the app's sign-in page offers (team/SignInPage.tsx):
34
+ * Google, an email and a password, or a new account. Each gives the access
35
+ * token of a fresh sign-in, held in memory only: the cloud key is what this
36
+ * Mac keeps, so nothing asks the keychain.
37
+ */
38
+ export function cloudAccountSession({ config, openExternal, log = () => {}, createClient = null }) {
39
+ let lastUrl = null;
40
+ const client = async () => {
41
+ const make = createClient || (await import('@supabase/supabase-js')).createClient;
42
+ const mem = new Map();
43
+ return make(config.supabaseUrl, config.anonKey, {
44
+ auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: false, storage: { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v), removeItem: (k) => mem.delete(k) } },
45
+ global: { fetch: withDeadline() },
46
+ });
47
+ };
48
+ const tokenOf = (data) => {
49
+ if (!data?.session?.access_token) throw new Error('Signing in came back without a session. Try again.');
50
+ return data.session.access_token;
51
+ };
52
+ return {
53
+ async google() {
54
+ const c = await client();
55
+ await signInWithGoogle({ client: c, openExternal, returnUrl: config.signInReturn || RETURN_URL, log, onUrl: (u) => { lastUrl = u; } });
56
+ return tokenOf((await c.auth.getSession()).data);
57
+ },
58
+ async email(email, password) {
59
+ const { data, error } = await (await client()).auth.signInWithPassword({ email: String(email ?? '').trim(), password: String(password ?? '') });
60
+ if (error) throw new Error(plainAuthError(error.message));
61
+ return tokenOf(data);
62
+ },
63
+ // With confirmation on, a new account has no session until its email's
64
+ // link is followed: null, and the page says to check the inbox.
65
+ async signUp({ name, email, password }) {
66
+ const { data, error } = await (await client()).auth.signUp({
67
+ email: String(email ?? '').trim(), password: String(password ?? ''),
68
+ options: { data: { full_name: String(name ?? '').trim() || undefined } },
69
+ });
70
+ if (error) throw new Error(plainAuthError(error.message));
71
+ return data?.session?.access_token ?? null;
72
+ },
73
+ cancel: () => cancelGoogleSignIn(),
74
+ reopen: () => (lastUrl ? openExternal(lastUrl) : null),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * One press on the sign-in page, to a cloud key. It agrees to the current
80
+ * terms only when the page showed them (`acceptTerms`, and every new account);
81
+ * otherwise a person who never agreed gets `needsTerms`, and `agree` finishes
82
+ * that same sign-in once they do.
83
+ */
84
+ export async function cloudSignIn({ config, session, method, email, password, name, acceptTerms = false, env = process.env, home = os.homedir() }) {
85
+ let token;
86
+ let agreeing = acceptTerms;
87
+ try {
88
+ if (method === 'google') token = await session.google();
89
+ else if (method === 'email') token = await session.email(email, password);
90
+ else if (method === 'sign-up') {
91
+ agreeing = true;
92
+ token = await session.signUp({ name, email, password });
93
+ if (!token) return { ok: true, confirm: true };
94
+ } else if (method === 'agree') {
95
+ token = session.held;
96
+ agreeing = true;
97
+ if (!token) return { ok: false, error: 'Sign in again to finish.' };
98
+ } else return { ok: false, error: 'No such way to sign in.' };
99
+ } catch (err) {
100
+ return { ok: false, error: err?.message || String(err), ...(err?.cancelled ? { cancelled: true } : {}) };
101
+ }
102
+ session.held = token;
103
+ const acceptedTerms = agreeing ? await currentCloudTerms(config) : undefined;
104
+ const out = await signInToCloud({ config, acceptedTerms, googleSignIn: async () => token, env, home });
105
+ if (out.ok) { session.held = null; return out; }
106
+ if (out.error === 'terms' && !agreeing) return { ok: false, needsTerms: true, terms: out.terms ?? null };
107
+ return { ok: false, error: out.message || out.error };
108
+ }
109
+
110
+ // `googleSignIn` gives the access token of whichever sign-in the page made.
111
+ export async function signInToCloud({ config, acceptedTerms, name = os.hostname(), env = process.env, home = os.homedir(), googleSignIn }) {
112
+ const now = readCloudConfig(env, home);
113
+ if (now?.bucket) return { ok: false, error: 'own-aws', message: 'This Mac runs the cloud in its own AWS account.' };
114
+ let token;
115
+ try { token = await googleSignIn(); } catch (err) { return { ok: false, error: 'google', message: err?.message || String(err) }; }
116
+ const made = await ask(config.gateway, '/v1/devices', { method: 'POST', key: token, body: { acceptedTerms, name: String(name).slice(0, 80) } });
117
+ if (made.status !== 200 || !made.body.key) return { ok: false, error: made.body.error || 'refused', message: made.body.message || `the cloud said ${made.status}`, ...(made.body.terms ? { terms: made.body.terms } : {}) };
118
+ writeCloudKey(made.body.key, env, home);
119
+ const file = cloudConfigPath(env, home);
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ fs.writeFileSync(file, JSON.stringify({ gateway: config.gateway }, null, 2) + '\n', { mode: 0o600 });
122
+ const me = await ask(config.gateway, '/v1/me', { key: made.body.key });
123
+ return { ok: true, ...(me.status === 200 ? me.body : {}) };
124
+ }
125
+
126
+ // The server's cloud and this Mac's key for it, or null when signed out.
127
+ function signedIn(env, home) {
128
+ const gateway = readCloudConfig(env, home)?.gateway;
129
+ const key = gateway ? readCloudKey(env, home) : null;
130
+ return key ? { gateway, key } : null;
131
+ }
132
+
133
+ /** The plan, its agents and hours, and what is used, as the server counts them. */
134
+ export async function cloudPlan({ env = process.env, home = os.homedir() } = {}) {
135
+ const at = signedIn(env, home);
136
+ if (!at) return { signedIn: false };
137
+ const me = await ask(at.gateway, '/v1/me', { key: at.key });
138
+ if (me.status === 401) forgetCloudKey(at.key, env, home);
139
+ return me.status === 200 ? { signedIn: true, ...me.body } : { signedIn: me.status !== 401, error: me.body.error || 'refused', message: me.body.message };
140
+ }
141
+
142
+ /** End every other Mac's cloud key, for a Mac that was lost; this one stays signed in. */
143
+ export async function signOutOtherMacs({ env = process.env, home = os.homedir() } = {}) {
144
+ const at = signedIn(env, home);
145
+ if (!at) return { ok: false, error: 'signed-out', message: `Sign in to ${Name} Cloud first.` };
146
+ const out = await ask(at.gateway, '/v1/devices', { method: 'DELETE', key: at.key, body: { others: true } });
147
+ if (out.status === 401) forgetCloudKey(at.key, env, home);
148
+ return out.status === 200 ? { ok: true } : { ok: false, error: out.body.error || 'refused', message: out.body.message || `the cloud said ${out.status}` };
149
+ }
150
+
151
+ // Only Stripe's own two pages are opened: whatever else a server answers stays shut.
152
+ const STRIPE_PAGE = /^https:\/\/(checkout|billing)\.stripe\.com\//;
153
+
154
+ async function openStripe(route, body, openExternal, env, home) {
155
+ const at = signedIn(env, home);
156
+ if (!at) return { ok: false, error: 'signed-out', message: `Sign in to ${Name} Cloud first.` };
157
+ const out = await ask(at.gateway, route, { method: 'POST', key: at.key, body });
158
+ if (out.status !== 200) return { ok: false, error: out.body.error || 'refused', message: out.body.message || `the cloud said ${out.status}` };
159
+ if (typeof out.body.url !== 'string' || !STRIPE_PAGE.test(out.body.url)) return { ok: false, error: 'not-stripe', message: 'The cloud answered with a page that is not Stripe\'s.' };
160
+ await openExternal(out.body.url);
161
+ return { ok: true, ...(out.body.where ? { where: out.body.where } : {}) };
162
+ }
163
+
164
+ /** Stripe's checkout for a plan, in the browser; the billing page for someone already paying. */
165
+ export const payForCloud = ({ plan, openExternal, env = process.env, home = os.homedir() }) => openStripe('/v1/checkout', { plan }, openExternal, env, home);
166
+
167
+ /** Stripe's billing page: change plan, cancel, the card, invoices. */
168
+ export const openCloudBilling = ({ openExternal, env = process.env, home = os.homedir() }) => openStripe('/v1/billing', {}, openExternal, env, home);
169
+
170
+ /** Let this Mac's cloud key go, there and here. An own-AWS setup is left alone. */
171
+ export async function signOutOfCloud({ env = process.env, home = os.homedir() } = {}) {
172
+ const now = readCloudConfig(env, home);
173
+ if (!now?.gateway) return { ok: true };
174
+ const key = readCloudKey(env, home);
175
+ if (key) await ask(now.gateway, '/v1/devices', { method: 'DELETE', key }).catch(() => {});
176
+ fs.rmSync(cloudKeyPath(env, home), { force: true });
177
+ fs.rmSync(cloudConfigPath(env, home), { force: true });
178
+ return { ok: true };
179
+ }