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
@@ -8,6 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
+ import crypto from 'node:crypto';
11
12
  import { nameSlug } from '../../shared/product-name.mjs';
12
13
  import { runningAsAFreshUser } from '../../shared/fresh-user-home.mjs';
13
14
  import { supabaseBackend } from './supabase-backend.mjs';
@@ -52,31 +53,49 @@ export function teamConfigOnThisMac(home = os.homedir()) {
52
53
  // points at another file from a checkout only, and is first because that is
53
54
  // what the tests run on.
54
55
  //
55
- // AN INSTALLED BUILD IS ALWAYS THE SINGLE-PERSON APP (2026-10-06). The first
56
- // download from agentbox.ac opened on the team's sign-in page, because the Mac
57
- // it ran on kept a team key. The founder: "users are no longer using the
58
- // multiplayer version so this should never be visible." The team version still
59
- // runs from a checkout (npm run app). See
60
- // tests/the-installed-app-is-the-single-person-app.test.mjs.
56
+ // AN INSTALLED BUILD SIGNS IN ONLY TO THE CLOUD BAKED INTO IT at release
57
+ // (`bakedTeam` in its package.json), never to a key on the Mac: the first
58
+ // download opened on a team's sign-in page because its Mac kept one
59
+ // (tests/the-installed-app-is-the-single-person-app). The sign-in is for the
60
+ // phone; the team version shows only with Alpha features › Multiplayer on
61
+ // (tests/multiplayer-is-an-alpha-feature-in-the-installed-app).
61
62
  // AND SO IS ⌘K's NEW-USER WINDOW (w-9f6975906c, 2026-10-05). It runs from her
62
63
  // checkout, which holds a team key, so it opened on the same sign-in page:
63
64
  // "when i run this command i got the login screen again ... this was the
64
65
  // critical thing to fix". A new user is a new user of the single-person app.
66
+ // A FRESH COPY HANDED A TEAM FILE IS A SECOND PERSON ON THAT TEAM (2026-10-21):
67
+ // a power tester in her room. It loads that file and no other, so the ⌘K
68
+ // window, which is handed none, stays the single-person app.
65
69
  export function loadCloudConfig(appDir, { packaged = isPackagedElectron(), home = os.homedir(), env = process.env } = {}) {
66
- if (packaged || runningAsAFreshUser(env)) return null;
67
- const tries = [
68
- !packaged && process.env.AGENTBOX_TEAM_CONFIG,
70
+ if (packaged) return bakedTeam(appDir);
71
+ if (runningAsAFreshUser(env) && !env.AGENTBOX_TEAM_CONFIG) return null;
72
+ const tries = runningAsAFreshUser(env) ? [env.AGENTBOX_TEAM_CONFIG] : [
73
+ env.AGENTBOX_TEAM_CONFIG,
69
74
  path.join(appDir, 'cloud', 'team.config.json'),
70
75
  teamConfigOnThisMac(home),
71
76
  ];
77
+ let found = null;
72
78
  for (const file of tries) {
73
79
  if (!file) continue;
74
80
  try {
75
81
  const c = JSON.parse(fs.readFileSync(file, 'utf8'));
76
- if (typeof c.url === 'string' && typeof c.anonKey === 'string') return c;
82
+ if (typeof c.url !== 'string' || typeof c.anonKey !== 'string') continue;
83
+ // A field the winning file lacks (phoneSite, added later) comes from a
84
+ // file further down for the same cloud, never from another cloud's.
85
+ if (!found) found = c;
86
+ else if (c.url === found.url) found = { ...c, ...found };
77
87
  } catch { /* try the next place; none of them is the single-person app */ }
78
88
  }
79
- return null;
89
+ return found;
90
+ }
91
+
92
+ function bakedTeam(appDir) {
93
+ try {
94
+ const b = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).bakedTeam;
95
+ if (typeof b?.url !== 'string' || typeof b.anonKey !== 'string') return null;
96
+ const site = typeof b.phoneSite === 'string' && /^https:\/\/[^\s/]+(\/\S*)?$/.test(b.phoneSite) ? { phoneSite: b.phoneSite } : {};
97
+ return { url: b.url, anonKey: b.anonKey, ...site };
98
+ } catch { return null; }
80
99
  }
81
100
 
82
101
  function fileStorage(file, { encrypt, decrypt }) {
@@ -97,10 +116,18 @@ function fileStorage(file, { encrypt, decrypt }) {
97
116
  return {
98
117
  getItem: (key) => read()[key] ?? null,
99
118
  setItem: (key, value) => { const d = read(); d[key] = value; write(d); },
100
- removeItem: (key) => { const d = read(); delete d[key]; write(d); },
119
+ // Nothing to remove writes nothing, so a Mac never signed in never asks the keychain.
120
+ removeItem: (key) => { const d = read(); if (!(key in d)) return; delete d[key]; write(d); },
101
121
  };
102
122
  }
103
123
 
124
+ // A request to the account database that never answers is ended: its
125
+ // library waits for ever, and the phone's and the team's sync wait on it.
126
+ // Long enough for a file going up on a slow network.
127
+ const DATABASE_DEADLINE_MS = 5 * 60_000;
128
+ export const withDeadline = (ms = DATABASE_DEADLINE_MS) => (url, init = {}) =>
129
+ fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms) });
130
+
104
131
  export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decrypt = null, openExternal, packaged = isPackagedElectron(), testLogin = process.env.AGENTBOX_TEAM_TEST_LOGIN }) {
105
132
  const configured = !!cloudConfig;
106
133
  if (packaged) testLogin = null;
@@ -110,6 +137,7 @@ export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decr
110
137
  if (client) return client;
111
138
  const { createClient } = await import('@supabase/supabase-js');
112
139
  client = createClient(cloudConfig.url, cloudConfig.anonKey, {
140
+ global: { fetch: withDeadline() },
113
141
  auth: {
114
142
  flowType: 'pkce',
115
143
  storage: fileStorage(sessionFile, { encrypt, decrypt }),
@@ -163,14 +191,28 @@ export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decr
163
191
  // answers { confirm: true } and the page says to check the inbox.
164
192
  async signUp({ email, password, name }) {
165
193
  if (!configured) throw new Error('this build has no team cloud configured');
166
- const c = await getClient();
167
- const { data, error } = await c.auth.signUp({
194
+ // The plain kind of email link, which the phone website can finish; this
195
+ // Mac's own client would ask for a code only this Mac could spend.
196
+ const { createClient } = await import('@supabase/supabase-js');
197
+ const plain = createClient(cloudConfig.url, cloudConfig.anonKey, {
198
+ global: { fetch: withDeadline() },
199
+ auth: { flowType: 'implicit', persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
200
+ });
201
+ const { data, error } = await plain.auth.signUp({
168
202
  email: String(email ?? '').trim(),
169
203
  password: String(password ?? ''),
170
- options: { data: { full_name: String(name ?? '').trim() || undefined } },
204
+ options: {
205
+ data: { full_name: String(name ?? '').trim() || undefined },
206
+ // Else the email's link goes to the database's default, this Mac's sign-in listener, dead outside a sign-in.
207
+ emailRedirectTo: typeof cloudConfig.phoneSite === 'string' ? cloudConfig.phoneSite : undefined,
208
+ },
171
209
  });
172
210
  if (error) throw new Error(plainAuthError(error.message));
173
- return data?.session ? { backend: supabaseBackend(c) } : { confirm: true };
211
+ if (!data?.session) return { confirm: true };
212
+ const c = await getClient();
213
+ const { error: kept } = await c.auth.setSession(data.session);
214
+ if (kept) throw new Error(plainAuthError(kept.message));
215
+ return { backend: supabaseBackend(c) };
174
216
  },
175
217
 
176
218
  cancelSignIn: () => cancelGoogleSignIn(),
@@ -194,23 +236,51 @@ export function plainAuthError(message) {
194
236
  return m || 'That did not work. Try again.';
195
237
  }
196
238
 
197
- // WHERE THE HEADLESS APP KEEPS ITS SIGN-IN: in its own data folder, readable by
198
- // this user only, never in the store root. The store is the folder people
199
- // copy, sync and hand to agents, and this file holds a refresh token in plain
200
- // JSON (review, 2026-10-01). One left in the store by an older copy is moved.
201
- export function headlessSessionFile({ userDir, storeRoot }) {
202
- const file = path.join(userDir, 'team-session.json');
203
- const old = path.join(storeRoot, '.team-session');
239
+ // THE SIGN-IN NEVER LANDS IN THE STORE ROOT. The store is in every worker's
240
+ // granted directories, and this file holds a refresh token in plain JSON, so it
241
+ // resolves to a path OUTSIDE the store. A dir inside the store moves to a
242
+ // per-store folder under the config home; if even that is inside the store
243
+ // there is nowhere safe and it throws, because writing the token where a worker
244
+ // reads it is worse than a sign-in to do again. A copy an older version left in
245
+ // the store, under either name, is carried across.
246
+ const isInside = (file, root) => {
247
+ const f = path.resolve(file);
248
+ const r = path.resolve(root);
249
+ return f === r || f.startsWith(r + path.sep);
250
+ };
251
+
252
+ function configHome({ home, env }) {
253
+ const given = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
254
+ return given && path.isAbsolute(given) ? given : path.join(home, '.config');
255
+ }
256
+
257
+ function sessionFileOutsideStore({ dir, name, storeRoot, home = os.homedir(), env = process.env }) {
258
+ let file = path.join(dir, name);
259
+ if (isInside(file, storeRoot)) {
260
+ const tag = crypto.createHash('sha256').update(path.resolve(storeRoot)).digest('hex').slice(0, 16);
261
+ file = path.join(configHome({ home, env }), nameSlug, 'sessions', tag, name);
262
+ if (isInside(file, storeRoot)) throw new Error(`no place outside the store root for the team session: ${storeRoot}`);
263
+ }
264
+ const olds = [path.join(storeRoot, '.team-session'), path.join(storeRoot, 'team-session.json')];
204
265
  try {
205
- if (!fs.existsSync(file) && fs.existsSync(old)) {
206
- fs.mkdirSync(userDir, { recursive: true });
207
- fs.renameSync(old, file);
266
+ if (!fs.existsSync(file)) {
267
+ for (const old of olds) {
268
+ if (path.resolve(old) === path.resolve(file) || !fs.existsSync(old)) continue;
269
+ fs.mkdirSync(path.dirname(file), { recursive: true });
270
+ fs.renameSync(old, file);
271
+ break;
272
+ }
208
273
  }
209
274
  if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
210
275
  } catch { /* a session that cannot be moved is a sign-in to do again */ }
211
276
  return file;
212
277
  }
213
278
 
279
+ // The headless app keeps its sign-in as plain JSON readable by this user only.
280
+ export function headlessSessionFile({ userDir, storeRoot, home, env }) {
281
+ return sessionFileOutsideStore({ dir: userDir, name: 'team-session.json', storeRoot, home, env });
282
+ }
283
+
214
284
  // The same session shape over the in-memory cloud, for tests.
215
285
  export function memorySession(backendFor) {
216
286
  let signedIn = null;
@@ -4,6 +4,7 @@
4
4
  // actually keeps people apart; nothing here is trusted to.
5
5
  import { asPulled } from '../../shared/team-rules.mjs';
6
6
  import { cleanStatus } from '../../shared/team-status.mjs';
7
+ import { roomsSupabase } from './rooms-supabase.mjs';
7
8
 
8
9
  const PAGE = 500;
9
10
  const PUSH_BATCH = 200;
@@ -19,8 +20,26 @@ function must({ data, error }, what) {
19
20
  return data;
20
21
  }
21
22
 
23
+ const COMMAND_COLUMNS = 'id,owner,device,project,item,kind,payload,created_at,claimed_at,taken_at,outcome';
24
+ // Who claimed (`cloud:<run>`, 20261026000000_phone_carries_on_in_the_cloud.sql),
25
+ // asked for only once the database has the column, so a computer that runs
26
+ // before the migration is applied still reads its phone.
27
+ const CLOUD_COLUMNS = `${COMMAND_COLUMNS},claimed_by`;
28
+ const noCloudColumn = (error) => /claimed_by|column .*cloud|cloud.* column/i.test(String(error?.message ?? ''));
29
+ const commandOut = (r) => ({ id: r.id, owner: r.owner, device: r.device, project: r.project, item: r.item, kind: r.kind, payload: r.payload ?? {}, createdAt: r.created_at, claimedAt: r.claimed_at ?? null, claimedBy: r.claimed_by ?? null, takenAt: r.taken_at ?? null, outcome: r.outcome ?? null });
30
+
22
31
  export function supabaseBackend(client) {
23
32
  let personId = null;
33
+ // Whether the database has the cloud's columns yet; the first refusal says not.
34
+ let cloudColumns = true;
35
+ const commandsWith = async (query, base = COMMAND_COLUMNS) => {
36
+ if (cloudColumns) {
37
+ const out = await query(`${base},claimed_by`);
38
+ if (!out.error || !noCloudColumn(out.error)) return out;
39
+ cloudColumns = false;
40
+ }
41
+ return query(base);
42
+ };
24
43
  async function myId() {
25
44
  if (personId) return personId;
26
45
  const { data, error } = await client.auth.getUser();
@@ -219,13 +238,174 @@ export function supabaseBackend(client) {
219
238
  }));
220
239
  },
221
240
 
241
+ // THE FILES A CONVERSATION'S MESSAGES LINK TO (w-140ac855b1). Row level
242
+ // security decides who may read and add one (20261013000000_message_files.sql).
243
+ async hasFiles(projectId, names) {
244
+ if (!names.length) return [];
245
+ const rows = must(await client.from('message_files').select('name').eq('project_id', projectId).in('name', names), 'asking which pictures your team has');
246
+ return rows.map((r) => r.name);
247
+ },
248
+
249
+ async getFile(projectId, name) {
250
+ const rows = must(await client.from('message_files').select('data').eq('project_id', projectId).eq('name', name).limit(1), 'taking a picture a teammate sent');
251
+ return rows[0]?.data ?? null;
252
+ },
253
+
254
+ async putFile(projectId, name, data) {
255
+ const me = await myId();
256
+ const rows = must(await client.from('message_files').upsert({ project_id: projectId, name, by_person: me, data }, { onConflict: 'project_id,name', ignoreDuplicates: true }).select('name'), 'sending a picture');
257
+ return (rows?.length ?? 0) > 0;
258
+ },
259
+
260
+ // HER OWN ROWS FOR HER PHONE (20261014000200_phone_inbox.sql). The owner
261
+ // column defaults to her, and row level security keeps every row hers.
262
+ // Every row names the computer it belongs to (`device`).
263
+ // Each write names who it is for, so a write begun before a sign-out
264
+ // that lands after somebody else signed in is refused by the database
265
+ // (`owner = auth.uid()`) rather than written into their account.
266
+ async putPhoneProject({ owner, device, project, name, cloud }) {
267
+ await myId();
268
+ const row = { ...(owner ? { owner } : {}), device, project, name, updated_at: new Date().toISOString() };
269
+ const put = (r) => client.from('phone_projects').upsert(r, { onConflict: 'owner,device,project' });
270
+ let out = cloud === undefined || !cloudColumns ? await put(row) : await put({ ...row, cloud: !!cloud });
271
+ if (out.error && cloud !== undefined && cloudColumns && noCloudColumn(out.error)) { cloudColumns = false; out = await put(row); }
272
+ must(out, 'putting a project on your phone');
273
+ },
274
+ async dropPhoneProject(device, project, owner) {
275
+ const me = await myId();
276
+ // Its threads go with it (the database cascades).
277
+ must(await client.from('phone_projects').delete().eq('owner', owner ?? me).eq('device', device).eq('project', project), 'taking a project off your phone');
278
+ },
279
+ async putPhoneThreads(device, project, rows, owner) {
280
+ await myId();
281
+ const at = new Date().toISOString();
282
+ for (let i = 0; i < rows.length; i += 50) {
283
+ const batch = rows.slice(i, i + 50).map((r) => ({ ...(owner ? { owner } : {}), device, project, item: r.item, snapshot: r.snapshot, updated_at: at }));
284
+ must(await client.from('phone_threads').upsert(batch, { onConflict: 'owner,device,project,item' }), 'sending threads to your phone');
285
+ }
286
+ },
287
+ // Her files for the phone, in her own folder of a private bucket
288
+ // (20261017000000_phone_files.sql). Named by what is in them, so one
289
+ // already there is the same file and is left as it is.
290
+ async putPhoneFile({ owner, device, name, data, type }) {
291
+ const me = await myId();
292
+ const { error } = await client.storage.from('phone-files').upload(`${owner ?? me}/${device}/${name}`, data, { contentType: type ?? 'application/octet-stream', upsert: false });
293
+ if (error && !/exists|duplicate/i.test(`${error.message} ${error.statusCode ?? ''}`) && String(error.statusCode) !== '409') throw new Error(`sending a file to your phone: ${error.message}`);
294
+ },
295
+ // A picture her phone put in this computer's folder for a reply; null when it is not there.
296
+ async getPhoneFile({ owner, device, name }) {
297
+ const me = await myId();
298
+ const { data, error } = await client.storage.from('phone-files').download(`${owner ?? me}/${device}/${name}`);
299
+ if (error) {
300
+ if (/not.?found/i.test(`${error.message} ${error.error ?? ''}`) || [400, 404].includes(Number(error.statusCode ?? error.status))) return null;
301
+ throw new Error(`taking a picture from your phone: ${error.message}`);
302
+ }
303
+ return Buffer.from(await data.arrayBuffer());
304
+ },
305
+ async dropPhoneFiles({ owner, device, names }) {
306
+ const me = await myId();
307
+ for (let i = 0; i < names.length; i += 100) {
308
+ const { error } = await client.storage.from('phone-files').remove(names.slice(i, i + 100).map((n) => `${owner ?? me}/${device}/${n}`));
309
+ if (error) throw new Error(`clearing old files from your phone: ${error.message}`);
310
+ }
311
+ },
312
+ async dropPhoneThreads(device, project, items, owner) {
313
+ const me = await myId();
314
+ for (let i = 0; i < items.length; i += 100) {
315
+ must(await client.from('phone_threads').delete().eq('owner', owner ?? me).eq('device', device).eq('project', project).in('item', items.slice(i, i + 100)), 'taking threads off your phone');
316
+ }
317
+ },
318
+ // What her phone reads and sends.
319
+ async phoneProjects() {
320
+ const me = await myId();
321
+ const rows = must(await client.from('phone_projects').select('owner,device,project,name').order('name'), 'reading the projects on your phone');
322
+ return (rows ?? []).filter((r) => r.owner === me);
323
+ },
324
+ async phoneThreads() {
325
+ const me = await myId();
326
+ const rows = must(await client.from('phone_threads').select('owner,device,project,item,snapshot,updated_at').order('updated_at', { ascending: false }).limit(1000), 'reading the threads on your phone');
327
+ return (rows ?? []).filter((r) => r.owner === me).map((r) => ({ owner: r.owner, device: r.device, project: r.project, item: r.item, snapshot: r.snapshot, updatedAt: Date.parse(r.updated_at) }));
328
+ },
329
+ // One row per computer (20261023000000_phone_settings.sql).
330
+ async putPhoneSettings({ owner, device, name, settings }) {
331
+ await myId();
332
+ must(await client.from('phone_settings').upsert({ ...(owner ? { owner } : {}), device, name, settings, updated_at: new Date().toISOString() }, { onConflict: 'owner,device' }), 'sending your settings to your phone');
333
+ },
334
+ async phoneSettings() {
335
+ const me = await myId();
336
+ const rows = must(await client.from('phone_settings').select('owner,device,name,settings,updated_at').order('updated_at', { ascending: false }), 'reading your computer\'s settings');
337
+ return (rows ?? []).filter((r) => r.owner === me).map((r) => ({ owner: r.owner, device: r.device, name: r.name, settings: r.settings, updatedAt: Date.parse(r.updated_at) }));
338
+ },
339
+ // The phones her alerts go to (20261025000000_push_devices.sql). The phone writes its own row; this is for tests.
340
+ async putPushDevice({ token, platform, appVersion = null }) {
341
+ await myId();
342
+ must(await client.from('push_devices').upsert({ token, platform, app_version: appVersion, updated_at: new Date().toISOString() }, { onConflict: 'owner,token' }), 'turning on alerts');
343
+ },
344
+ async pushDevices(owner) {
345
+ const me = await myId();
346
+ const rows = must(await client.from('push_devices').select('owner,token,platform,app_version,updated_at').eq('owner', owner ?? me), 'reading the phones to alert');
347
+ return (rows ?? []).map((r) => ({ owner: r.owner, token: r.token, platform: r.platform, appVersion: r.app_version ?? null, updatedAt: Date.parse(r.updated_at) }));
348
+ },
349
+ async dropPushDevice(token, owner) {
350
+ const me = await myId();
351
+ must(await client.from('push_devices').delete().eq('owner', owner ?? me).eq('token', token), 'forgetting a phone');
352
+ },
353
+ async addPhoneCommand({ device, project = null, item = null, kind, payload = {} }) {
354
+ await myId();
355
+ const row = must(await client.from('phone_commands').insert({ device, project, item, kind, payload }).select(COMMAND_COLUMNS).single(), 'sending that to your computer');
356
+ return commandOut(row);
357
+ },
358
+ async phoneCommands() {
359
+ await myId();
360
+ const rows = must(await client.from('phone_commands').select(COMMAND_COLUMNS).order('created_at', { ascending: true }).limit(500), 'reading what your phone sent');
361
+ return (rows ?? []).map(commandOut);
362
+ },
363
+ // This computer's, every one still waiting, oldest first, a page at a time.
364
+ async waitingPhoneCommands(device) {
365
+ await myId();
366
+ const out = [];
367
+ for (let from = 0; ; from += 200) {
368
+ const rows = must(await commandsWith((cols) => client.from('phone_commands').select(cols).eq('device', device).is('taken_at', null).order('created_at', { ascending: true }).range(from, from + 199)), 'reading what your phone sent');
369
+ out.push(...(rows ?? []).map(commandOut));
370
+ if (!rows || rows.length < 200) return out;
371
+ }
372
+ },
373
+ // This computer's commands the cloud claimed since `since`, taken or not,
374
+ // so a computer waking up joins the runs it started (Supervisor#adoptCloudRun).
375
+ async cloudPhoneCommands(device, since) {
376
+ await myId();
377
+ if (!cloudColumns) return [];
378
+ const out = await client.from('phone_commands').select(CLOUD_COLUMNS).eq('device', device).not('claimed_by', 'is', null).gte('created_at', since).order('created_at', { ascending: true }).limit(200);
379
+ if (out.error && noCloudColumn(out.error)) { cloudColumns = false; return []; }
380
+ return (must(out, 'reading what the cloud took from your phone') ?? []).map(commandOut);
381
+ },
382
+ // THE COMPUTER CLAIMS ONE BEFORE IT DOES IT; the phone takes one back only
383
+ // while nobody has (renderer/src/cloud-bridge.ts). The database lets one win.
384
+ async claimPhoneCommand(id, owner) {
385
+ const me = await myId();
386
+ const who = owner ?? me;
387
+ const won = must(await client.from('phone_commands').update({ claimed_at: new Date().toISOString() }).eq('id', id).eq('owner', who).is('taken_at', null).is('claimed_at', null).select('id'), 'starting what your phone sent');
388
+ if (won?.length) return 'claimed';
389
+ const row = must(await commandsWith((cols) => client.from('phone_commands').select(cols).eq('id', id).eq('owner', who).maybeSingle(), 'taken_at,claimed_at'), 'reading what your phone sent');
390
+ // The cloud's claim is the cloud's run: never done here as if it were ours.
391
+ if (row?.claimed_by) return 'cloud';
392
+ return row && !row.taken_at && row.claimed_at ? 'ours' : 'gone';
393
+ },
394
+ async markPhoneCommand(id, outcome, owner) {
395
+ const me = await myId();
396
+ must(await client.from('phone_commands').update({ taken_at: new Date().toISOString(), outcome }).eq('id', id).eq('owner', owner ?? me).is('taken_at', null), 'marking what your phone sent as done');
397
+ },
398
+
222
399
  subscribe(onChange) {
223
400
  const channel = client.channel(`team-${Math.random().toString(36).slice(2)}`)
401
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'phone_commands' }, () => onChange())
224
402
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'lines' }, () => onChange())
225
403
  .on('postgres_changes', { event: '*', schema: 'public', table: 'thread_cards' }, () => onChange())
226
404
  .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'people' }, () => onChange())
227
405
  .subscribe();
228
406
  return () => { client.removeChannel(channel); };
229
407
  },
408
+
409
+ ...roomsSupabase(client, myId, must),
230
410
  };
231
411
  }
@@ -20,9 +20,13 @@
20
20
  // 4. PRIVATE WORK. One title-free line per open task in a private project
21
21
  // (who, what state, when it moved), and only when that set changed.
22
22
  //
23
+ // And in a conversation, the files its messages link to (below, FILES).
24
+ //
23
25
  // Everything outside this file is passed in, so two "Macs" can run in one test
24
26
  // over an in-memory cloud. The app wires the real ones in main/team/index.mjs.
25
27
  import crypto from 'node:crypto';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
26
30
  import { whatATeammateMaySet, whatYourOtherCopyMaySet } from '../../shared/team-rules.mjs';
27
31
 
28
32
  const PAGE = 500;
@@ -31,6 +35,49 @@ const PAGE = 500;
31
35
  // joins no more, so a teammate cannot fill this disk with folders.
32
36
  export const MAX_SHARED_PROJECTS = 50;
33
37
 
38
+ // FILES: A PICTURE IN A MESSAGE GOES WITH IT (w-140ac855b1, 2026-10-13).
39
+ //
40
+ // A picture pasted into a message is saved in the project's attachments folder
41
+ // and the message links to it, `![…](attachments/…)`. Only the line used to
42
+ // cross, so every picture sent to a teammate arrived as "missing image". Now
43
+ // the sender's Mac puts each file it links to in the cloud BEFORE pushing the
44
+ // line, and the reader's Mac takes the file down BEFORE the line lands, so a
45
+ // message is never drawn without its picture (a picture drawn as missing stays
46
+ // missing until the thread is reopened).
47
+ //
48
+ // Only in a conversation: an ordinary shared project's words never cross
49
+ // (shared/team-rules.mjs), so nothing they link to does either. And every file
50
+ // call is best effort. A cloud without the table, or one file that will not
51
+ // come, must never stop a message.
52
+ export const MAX_MESSAGE_FILE_BYTES = 10 * 1024 * 1024;
53
+ // A file named in a message the cloud does not have yet, which is a sender who
54
+ // has not updated. Asked about again at most once a minute, for two weeks.
55
+ const WANTED_MAX = 200;
56
+ const WANTED_EVERY_MS = 60_000;
57
+ const WANTED_FOR_MS = 14 * 24 * 60 * 60 * 1000;
58
+ // A PLAIN NAME and nothing else: no slash, no leading dot. The reader writes
59
+ // what a teammate's line names, so a name is all a line may hand it.
60
+ const NAMED = /\]\(attachments\/([A-Za-z0-9][\w.-]{0,159})\)/g;
61
+
62
+ /** The files a ledger line's message links to, by name, each once. */
63
+ export function filesNamedIn(line) {
64
+ if (!line || typeof line !== 'object' || !line.patch) return [];
65
+ return [...new Set([...JSON.stringify(line.patch).matchAll(NAMED)].map((m) => m[1]))];
66
+ }
67
+
68
+ // The attachments folder of a project on this Mac.
69
+ export const diskFiles = {
70
+ size(dir, name) {
71
+ try { const st = fs.statSync(path.join(dir, 'attachments', name)); return st.isFile() ? st.size : null; } catch { return null; }
72
+ },
73
+ read: (dir, name) => fs.readFileSync(path.join(dir, 'attachments', name)),
74
+ // `wx`: a file already here under that name is never replaced.
75
+ write(dir, name, bytes) {
76
+ fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
77
+ try { fs.writeFileSync(path.join(dir, 'attachments', name), bytes, { flag: 'wx' }); } catch (err) { if (err.code !== 'EEXIST') throw err; }
78
+ },
79
+ };
80
+
34
81
  // Where each shared project's cursors stand, kept between runs.
35
82
  export function memorySyncState() {
36
83
  const map = new Map();
@@ -78,8 +125,65 @@ function adopt(line, me) {
78
125
  return { ...line, by: line.by || me, uid };
79
126
  }
80
127
 
81
- export function createTeamSync({ backend, disk, state, listShared, joinProject, listCards = () => [], teamIdOf = () => null }) {
128
+ export function createTeamSync({ backend, disk, state, listShared, joinProject, listCards = () => [], teamIdOf = () => null, files = diskFiles, now = Date.now, log = () => {} }) {
82
129
  let running = null;
130
+ const quietly = async (what, fn, fallback) => {
131
+ try { return await fn(); } catch (err) { log(`team files: ${what}: ${err?.message ?? err}`); return fallback; }
132
+ };
133
+
134
+ // Takes down whichever of `names` the cloud has and this Mac does not.
135
+ // Answers the names still missing.
136
+ async function takeFiles(project, names) {
137
+ const missing = [...new Set(names)].filter((n) => files.size(project.dir, n) == null);
138
+ if (!missing.length || !backend.hasFiles) return missing;
139
+ const there = new Set(await quietly('asking', () => backend.hasFiles(project.projectId, missing), []));
140
+ for (const name of missing.filter((n) => there.has(n))) {
141
+ // One at a time: each can be ten megabytes.
142
+ const data = await quietly(`taking ${name}`, () => backend.getFile(project.projectId, name), null);
143
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'base64') : null;
144
+ if (bytes && bytes.length <= MAX_MESSAGE_FILE_BYTES) await quietly(`saving ${name}`, () => files.write(project.dir, name, bytes));
145
+ }
146
+ return missing.filter((n) => files.size(project.dir, n) == null);
147
+ }
148
+
149
+ // Every line written here since the last look: the files your messages name
150
+ // go up, and the files anyone's name that are not here are wanted. The first
151
+ // look after updating starts at the top of the ledger, which is what sends a
152
+ // picture from before this existed.
153
+ async function sendFiles(project, me) {
154
+ if (!backend.putFile) return;
155
+ const cursor = state.get(project.projectId);
156
+ const { lines, end } = disk.readLinesFrom(project.dir, cursor.filesFrom ?? 0);
157
+ const mine = new Set();
158
+ const wanted = new Map((cursor.wanted ?? []).map((w) => [w.name, w]));
159
+ for (const line of lines) {
160
+ for (const name of filesNamedIn(line)) {
161
+ const size = files.size(project.dir, name);
162
+ if (size == null) { if (!wanted.has(name)) wanted.set(name, { name, since: now(), checked: 0 }); }
163
+ else if ((!line.by || line.by === me) && size <= MAX_MESSAGE_FILE_BYTES) mine.add(name);
164
+ }
165
+ }
166
+ if (mine.size) {
167
+ const there = await quietly('asking', () => backend.hasFiles(project.projectId, [...mine]), null);
168
+ if (!there) return; // Not now; this stretch of the ledger is looked at again next pass.
169
+ for (const name of [...mine].filter((n) => !there.includes(n))) {
170
+ const sent = await quietly(`sending ${name}`, async () => { await backend.putFile(project.projectId, name, files.read(project.dir, name).toString('base64')); return true; }, false);
171
+ if (!sent) return;
172
+ }
173
+ }
174
+ state.set(project.projectId, { ...state.get(project.projectId), filesFrom: end, wanted: [...wanted.values()].slice(-WANTED_MAX) });
175
+ }
176
+
177
+ async function takeWanted(project) {
178
+ const cursor = state.get(project.projectId);
179
+ const at = now();
180
+ const wanted = (cursor.wanted ?? []).filter((w) => at - w.since < WANTED_FOR_MS && files.size(project.dir, w.name) == null);
181
+ const due = wanted.filter((w) => at - w.checked >= WANTED_EVERY_MS).map((w) => w.name);
182
+ const still = new Set(due.length ? await takeFiles(project, due) : []);
183
+ const kept = wanted.filter((w) => !due.includes(w.name) || still.has(w.name))
184
+ .map((w) => (due.includes(w.name) ? { ...w, checked: at } : w));
185
+ if (kept.length !== (cursor.wanted ?? []).length || due.length) state.set(project.projectId, { ...state.get(project.projectId), wanted: kept });
186
+ }
83
187
 
84
188
  async function pushProject(project, me) {
85
189
  const cursor = state.get(project.projectId);
@@ -103,6 +207,8 @@ export function createTeamSync({ backend, disk, state, listShared, joinProject,
103
207
  // teammate's keeps only what they may set here (shared/team-rules.mjs).
104
208
  .map((l) => (l.by === me ? whatYourOtherCopyMaySet(l, { direct }) : whatATeammateMaySet(l, { direct })))
105
209
  .filter(Boolean);
210
+ // The pictures first, so no message lands without its own.
211
+ if (direct) await takeFiles(project, bring.flatMap(filesNamedIn));
106
212
  pulled += disk.appendForeignLines(project.dir, bring);
107
213
  // Lines pulled in move the file's end, but they were never ours to push,
108
214
  // and a push cursor left behind them would only re-read and skip them.
@@ -143,8 +249,10 @@ export function createTeamSync({ backend, disk, state, listShared, joinProject,
143
249
  for (const project of cloudProjects) {
144
250
  const here = local.get(project.id);
145
251
  if (!here) continue;
252
+ if (here.direct === true) await sendFiles(here, me);
146
253
  report.pushed += await pushProject(here, me);
147
254
  report.pulled += await pullProject(here, me);
255
+ if (here.direct === true) await takeWanted(here);
148
256
  }
149
257
 
150
258
  report.cards = await publishCards();