agentbox-app 0.1.12 → 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 (370) 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/acp.mjs +296 -0
  9. package/main/agent-homes.mjs +158 -0
  10. package/main/agent-sessions.mjs +311 -260
  11. package/main/agent-updates.mjs +9 -22
  12. package/main/agents.mjs +179 -466
  13. package/main/analytics.mjs +42 -2
  14. package/main/answer-modes.mjs +103 -37
  15. package/main/approval-prompt-core.mjs +197 -0
  16. package/main/approval-prompt-server.mjs +4 -138
  17. package/main/approvals.mjs +101 -297
  18. package/main/artifact-path.mjs +47 -18
  19. package/main/claude-bin.mjs +45 -311
  20. package/main/claude-input.mjs +57 -7
  21. package/main/claude-launch-env.mjs +54 -0
  22. package/main/claude-models.mjs +39 -7
  23. package/main/claude-plan.mjs +64 -16
  24. package/main/claude-session-open.mjs +28 -0
  25. package/main/claude-usage-process.mjs +40 -0
  26. package/main/claude-usage.mjs +94 -59
  27. package/main/cloud/account.mjs +179 -0
  28. package/main/cloud/aws.mjs +127 -0
  29. package/main/cloud/browser-return.mjs +58 -0
  30. package/main/cloud/clients.mjs +15 -0
  31. package/main/cloud/codex-cloud.mjs +53 -0
  32. package/main/cloud/config.mjs +107 -0
  33. package/main/cloud/docs.mjs +219 -0
  34. package/main/cloud/exec.mjs +27 -0
  35. package/main/cloud/host.mjs +165 -0
  36. package/main/cloud/kit.mjs +80 -0
  37. package/main/cloud/logins.mjs +543 -0
  38. package/main/cloud/managed.mjs +115 -0
  39. package/main/cloud/marker.mjs +104 -0
  40. package/main/cloud/org.mjs +53 -0
  41. package/main/cloud/reuse.mjs +208 -0
  42. package/main/cloud/run-cli.mjs +177 -0
  43. package/main/cloud/run.mjs +1934 -0
  44. package/main/cloud/usage.mjs +76 -0
  45. package/main/cloud-permission.mjs +51 -0
  46. package/main/codex-account.mjs +78 -0
  47. package/main/codex-app-server.mjs +276 -409
  48. package/main/codex-approvals.mjs +148 -433
  49. package/main/codex-bin.mjs +51 -249
  50. package/main/codex-models.mjs +20 -2
  51. package/main/codex-session.mjs +246 -506
  52. package/main/codex-threads-worker.mjs +12 -0
  53. package/main/codex-threads.mjs +112 -11
  54. package/main/codex-usage-file.mjs +5 -3
  55. package/main/codex-usage.mjs +56 -7
  56. package/main/codex-watch.mjs +83 -5
  57. package/main/codex.mjs +95 -363
  58. package/main/config.mjs +37 -5
  59. package/main/corner-tag.mjs +810 -0
  60. package/main/crash-report.mjs +26 -3
  61. package/main/dev-window.mjs +1 -1
  62. package/main/engine-bin.mjs +545 -0
  63. package/main/engine-setup.mjs +25 -24
  64. package/main/feedback.mjs +14 -31
  65. package/main/folder-pictures.mjs +2 -2
  66. package/main/freeze-recorder-thread.mjs +86 -0
  67. package/main/freeze-recorder.mjs +146 -0
  68. package/main/fresh-user.mjs +20 -9
  69. package/main/git-change-offthread.mjs +17 -3
  70. package/main/git-change-thread.mjs +5 -2
  71. package/main/harnesses/claude-stream.mjs +208 -0
  72. package/main/harnesses/claude.mjs +105 -0
  73. package/main/harnesses/codex.mjs +72 -0
  74. package/main/harnesses/common.mjs +78 -0
  75. package/main/harnesses/instructions-file.mjs +116 -0
  76. package/main/harnesses/opencode.mjs +234 -0
  77. package/main/harnesses.mjs +65 -0
  78. package/main/importable-threads.mjs +96 -0
  79. package/main/ipc.mjs +520 -135
  80. package/main/leftovers.mjs +117 -25
  81. package/main/liquid-glass.mjs +26 -0
  82. package/main/live-replies.mjs +10 -4
  83. package/main/local-app-frames.mjs +82 -0
  84. package/main/main-went-red.mjs +330 -0
  85. package/main/main.mjs +248 -37
  86. package/main/memory-gate-history.mjs +85 -20
  87. package/main/memory-gate-server.mjs +129 -11
  88. package/main/memory-gate.mjs +62 -8
  89. package/main/memory-log.mjs +124 -0
  90. package/main/menu.mjs +4 -2
  91. package/main/message-priority.mjs +2 -2
  92. package/main/mic.mjs +38 -0
  93. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  94. package/main/new-chats-watch.mjs +110 -0
  95. package/main/new-user-window.mjs +25 -0
  96. package/main/notify.mjs +64 -5
  97. package/main/opencode-server.mjs +271 -0
  98. package/main/opencode.mjs +287 -0
  99. package/main/phone-alerts.mjs +117 -0
  100. package/main/phone-inbox.mjs +625 -0
  101. package/main/phone-link.mjs +571 -0
  102. package/main/phone-uploads.mjs +476 -0
  103. package/main/picture-shape.mjs +114 -0
  104. package/main/quit-sequence.mjs +143 -0
  105. package/main/refresh-agent-models.mjs +15 -2
  106. package/main/relay-address.mjs +28 -0
  107. package/main/relay-client.mjs +314 -0
  108. package/main/repeats.mjs +10 -4
  109. package/main/row-label.mjs +18 -47
  110. package/main/run-change.mjs +13 -0
  111. package/main/run-ledger.mjs +135 -0
  112. package/main/runs-on.mjs +32 -35
  113. package/main/save-attachments.mjs +41 -0
  114. package/main/serve.mjs +155 -9
  115. package/main/settings.mjs +364 -50
  116. package/main/shell-path.mjs +234 -0
  117. package/main/ship-conflict-brief.mjs +119 -0
  118. package/main/ship-queue.mjs +134 -20
  119. package/main/sign-in-files.mjs +3 -7
  120. package/main/source-updater.mjs +45 -11
  121. package/main/stage-attachment.mjs +68 -0
  122. package/main/stall-watch.mjs +50 -0
  123. package/main/start-hold.mjs +73 -0
  124. package/main/store/home.mjs +87 -232
  125. package/main/store/paths.mjs +11 -0
  126. package/main/store/teammates-files.mjs +99 -0
  127. package/main/store/work-items.mjs +231 -13
  128. package/main/store.mjs +227 -53
  129. package/main/supervisor.mjs +3865 -699
  130. package/main/task-commands.mjs +13 -17
  131. package/main/task-folders-offthread.mjs +6 -1
  132. package/main/task-folders.mjs +897 -105
  133. package/main/task-terminals.mjs +147 -41
  134. package/main/team/index.mjs +114 -10
  135. package/main/team/memory-cloud.mjs +183 -1
  136. package/main/team/room-hours.mjs +48 -0
  137. package/main/team/room-setup.mjs +31 -0
  138. package/main/team/rooms-memory.mjs +454 -0
  139. package/main/team/rooms-shape.mjs +42 -0
  140. package/main/team/rooms-supabase.mjs +324 -0
  141. package/main/team/rooms.mjs +575 -0
  142. package/main/team/session.mjs +96 -26
  143. package/main/team/supabase-backend.mjs +180 -0
  144. package/main/team/sync.mjs +109 -1
  145. package/main/teammates.mjs +580 -0
  146. package/main/terminal-host.mjs +143 -0
  147. package/main/terminal-hosts.mjs +261 -0
  148. package/main/transcript-search.mjs +80 -0
  149. package/main/updater.mjs +71 -9
  150. package/main/webview-guard.mjs +45 -0
  151. package/main/worktree-include.mjs +365 -0
  152. package/mcp/core/documents.mjs +3 -1
  153. package/mcp/core/repeats.mjs +88 -0
  154. package/mcp/core/teammates.mjs +222 -0
  155. package/mcp/core/work.mjs +76 -4
  156. package/mcp/stdio.mjs +24 -11
  157. package/mcp/tools.mjs +116 -8
  158. package/package.json +30 -5
  159. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-CoWB6YUK.js +1 -0
  160. package/renderer/dist/assets/arc-Ch0NrCul.js +1 -0
  161. package/renderer/dist/assets/architectureDiagram-47P4ROYG-CXz9M5tQ.js +36 -0
  162. package/renderer/dist/assets/blockDiagram-E7TT5TSB-DchGUuHH.js +140 -0
  163. package/renderer/dist/assets/c4Diagram-CRA5TL53-Cip-zNyr.js +38 -0
  164. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  165. package/renderer/dist/assets/chunk-2Q5K7J3B-BoXid_2r.js +1 -0
  166. package/renderer/dist/assets/chunk-5VM5RSS4-Cpez1rRz.js +15 -0
  167. package/renderer/dist/assets/chunk-7TKQ45FW-DKA84YkE.js +1 -0
  168. package/renderer/dist/assets/chunk-AW2ZBBNX-Dg-SumPG.js +88 -0
  169. package/renderer/dist/assets/chunk-FVRAUYC3-06TCvPm1.js +2 -0
  170. package/renderer/dist/assets/chunk-HTAEGDNF-CMEBy1Td.js +1 -0
  171. package/renderer/dist/assets/chunk-JWPE2WC7-BJ2ObmV-.js +1 -0
  172. package/renderer/dist/assets/chunk-KQW6MTUR-CGWOYXUE.js +1 -0
  173. package/renderer/dist/assets/chunk-XXDRQBXY-CiZp07XC.js +1 -0
  174. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-DJNIX719.js +217 -0
  175. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  176. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  177. package/renderer/dist/assets/cose-bilkent-JH36ORCC-CbkyZOUH.js +1 -0
  178. package/renderer/dist/assets/cynefin-EF2NZ3EQ-78iOroZO.js +141 -0
  179. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-GcCopMs7.js +62 -0
  180. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  181. package/renderer/dist/assets/dagre-W4DXFKR2-DHLlXLkz.js +4 -0
  182. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  183. package/renderer/dist/assets/diagram-2UJZ2QOL-DuZSUHcH.js +200 -0
  184. package/renderer/dist/assets/diagram-OVF4WLC6-Ckxu3cqH.js +24 -0
  185. package/renderer/dist/assets/diagram-PFPMY2P6-CsTw9Ycn.js +30 -0
  186. package/renderer/dist/assets/diagram-UMYRVEAY-CTXd8qVg.js +24 -0
  187. package/renderer/dist/assets/diagram-YEKJPTXX-CJs1T_9m.js +41 -0
  188. package/renderer/dist/assets/diagram-ZIFT7M5P-CvjI23mf.js +3 -0
  189. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-d_IeAzOJ.js +1 -0
  190. package/renderer/dist/assets/elk-IJKZMXRS-zo9KFP3N.js +27 -0
  191. package/renderer/dist/assets/erDiagram-O2IAPWRE-DqfYf5Dt.js +99 -0
  192. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-DAWsMLmo.js +213 -0
  193. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-4ml_bhwR.js +292 -0
  194. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-B_WcqSId.js +106 -0
  195. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  196. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-CI-jYqzc.js +2 -0
  197. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  198. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-B56VWRlV.js +70 -0
  199. package/renderer/dist/assets/journeyDiagram-COXZFDF6-D1-p09Lr.js +139 -0
  200. package/renderer/dist/assets/kanban-definition-P3RFRI5V-Cv3w7JoN.js +89 -0
  201. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  202. package/renderer/dist/assets/linear-C35V2Spi.js +1 -0
  203. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  204. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  205. package/renderer/dist/assets/mermaid.core-BzGT5Vo9.js +314 -0
  206. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-BZUfuymC.js +96 -0
  207. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  208. package/renderer/dist/assets/pegDiagram-BGZESJAR-DFh9XNGe.js +1 -0
  209. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-C65tPVsy.js +39 -0
  210. package/renderer/dist/assets/product-name-CSNW6d7s.js +40 -0
  211. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-Cushj_nk.js +7 -0
  212. package/renderer/dist/assets/railroadDiagram-SM67HX2B-DZGebGx5.js +1 -0
  213. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-DpAB0MNo.js +84 -0
  214. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-D-29rHra.js +40 -0
  215. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-FfOQAZol.js +169 -0
  216. package/renderer/dist/assets/sizeCapture-INFHLROL-C_n6fDBY.js +1 -0
  217. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-BZjenzJG.js +291 -0
  218. package/renderer/dist/assets/swimlanes-N4OXWK64-DxaMU9vX.js +1 -0
  219. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  220. package/renderer/dist/assets/timeline-definition-YOQAKGHF-DTg5HJKz.js +120 -0
  221. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-Bc3MZDsl.js +370 -0
  222. package/renderer/dist/assets/vennDiagram-BLWOH2XV-GIVGk9Id.js +34 -0
  223. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-NmJnbQOg.js +78 -0
  224. package/renderer/dist/assets/xychartDiagram-TAQBALBS-p-kfGfnW.js +7 -0
  225. package/renderer/dist/corner-tag.html +13 -0
  226. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  227. package/renderer/dist/emulator/data/compression/README.md +11 -0
  228. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  229. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  230. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  231. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  232. package/renderer/dist/emulator/data/cores/README.md +22 -0
  233. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  234. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  235. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  236. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  237. package/renderer/dist/emulator/data/cores/package.json +65 -0
  238. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  239. package/renderer/dist/emulator/data/emulator.css +1614 -0
  240. package/renderer/dist/emulator/data/loader.js +170 -0
  241. package/renderer/dist/emulator/data/localization/README.md +63 -0
  242. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  243. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  244. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  245. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  246. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  247. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  248. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  249. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  250. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  251. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  252. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  253. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  254. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  255. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  256. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  257. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  258. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  259. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  260. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  261. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  262. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  263. package/renderer/dist/emulator/data/src/compression.js +142 -0
  264. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  265. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  266. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  267. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  268. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  269. package/renderer/dist/emulator/data/src/storage.js +125 -0
  270. package/renderer/dist/emulator/data/version.json +5 -0
  271. package/renderer/dist/emulator/host.js +297 -0
  272. package/renderer/dist/emulator/index.html +28 -0
  273. package/renderer/dist/emulator/provenance.json +6 -0
  274. package/renderer/dist/index.html +16 -3
  275. package/scripts/after-install.mjs +30 -8
  276. package/scripts/approval-server.sh +24 -5
  277. package/scripts/before-publish.mjs +12 -10
  278. package/scripts/cloud-shim.pl +57 -0
  279. package/shared/account-names.d.mts +6 -0
  280. package/shared/account-names.mjs +15 -0
  281. package/shared/agent-hours.mjs +136 -0
  282. package/shared/agent-import.mjs +62 -34
  283. package/shared/agents.d.mts +1 -0
  284. package/shared/agents.mjs +126 -522
  285. package/shared/analytics-events.mjs +12 -0
  286. package/shared/approval-card.mjs +36 -0
  287. package/shared/bridge-map.mjs +45 -1
  288. package/shared/claude-code-shape.mjs +6 -0
  289. package/shared/claude-commands.generated.mjs +6 -4
  290. package/shared/claude-models.generated.mjs +6 -4
  291. package/shared/claude-usage.d.mts +1 -0
  292. package/shared/claude-usage.mjs +42 -1
  293. package/shared/cloud-hand-back.d.mts +7 -0
  294. package/shared/cloud-hand-back.mjs +20 -0
  295. package/shared/cloud-kit.d.mts +219 -0
  296. package/shared/cloud-kit.mjs +341 -0
  297. package/shared/cloud-login-name.mjs +4 -0
  298. package/shared/cloud-start.mjs +17 -0
  299. package/shared/cloud-versions.mjs +23 -0
  300. package/shared/codex-import.mjs +2 -2
  301. package/shared/codex-modes.d.mts +22 -9
  302. package/shared/codex-modes.mjs +67 -11
  303. package/shared/codex-usage.mjs +108 -2
  304. package/shared/corner-tag.d.mts +36 -0
  305. package/shared/corner-tag.mjs +396 -0
  306. package/shared/dead-run-trace.mjs +19 -2
  307. package/shared/engines.mjs +76 -300
  308. package/shared/feedback.d.mts +7 -0
  309. package/shared/feedback.mjs +25 -0
  310. package/shared/first-run-practice.mjs +17 -24
  311. package/shared/harness-definitions.mjs +111 -0
  312. package/shared/instruction-defaults.json +2 -2
  313. package/shared/key-caps.d.mts +14 -0
  314. package/shared/key-caps.mjs +68 -0
  315. package/shared/message-edits.d.mts +7 -0
  316. package/shared/message-edits.mjs +35 -0
  317. package/shared/move-thread.d.mts +24 -0
  318. package/shared/move-thread.mjs +112 -0
  319. package/shared/named-threads.mjs +72 -0
  320. package/shared/not-shipped.d.mts +22 -0
  321. package/shared/not-shipped.mjs +89 -0
  322. package/shared/notify-rules.d.mts +31 -0
  323. package/shared/notify-rules.mjs +55 -8
  324. package/shared/phone-files.mjs +28 -0
  325. package/shared/phone-settings.mjs +118 -0
  326. package/shared/phone-snapshot.d.mts +16 -0
  327. package/shared/phone-snapshot.mjs +348 -0
  328. package/shared/picture-formats.d.mts +1 -0
  329. package/shared/picture-formats.mjs +13 -0
  330. package/shared/provider-commands.mjs +10 -2
  331. package/shared/rank.d.mts +4 -1
  332. package/shared/rank.mjs +82 -19
  333. package/shared/relay-address.mjs +6 -0
  334. package/shared/relay-crypto.mjs +165 -0
  335. package/shared/repeats.d.mts +8 -0
  336. package/shared/repeats.mjs +33 -2
  337. package/shared/room-cards.mjs +167 -0
  338. package/shared/room-links.d.mts +4 -0
  339. package/shared/room-links.mjs +21 -0
  340. package/shared/runs-on.d.mts +3 -13
  341. package/shared/runs-on.mjs +31 -52
  342. package/shared/shipped-note.d.mts +4 -0
  343. package/shared/shipped-note.mjs +6 -0
  344. package/shared/shortcuts.mjs +24 -10
  345. package/shared/spawn-trouble.d.mts +4 -0
  346. package/shared/spawn-trouble.mjs +484 -30
  347. package/shared/team-rules.mjs +9 -2
  348. package/shared/teammates.d.mts +19 -0
  349. package/shared/teammates.mjs +756 -0
  350. package/shared/terminal-focus.d.mts +1 -0
  351. package/shared/terminal-focus.mjs +13 -0
  352. package/shared/terminal-input.d.mts +1 -0
  353. package/shared/terminal-input.mjs +30 -0
  354. package/shared/terminal-keys.d.mts +1 -0
  355. package/shared/terminal-keys.mjs +16 -0
  356. package/shared/terminal-size.d.mts +4 -0
  357. package/shared/terminal-size.mjs +29 -0
  358. package/shared/thread-cards.mjs +4 -1
  359. package/shared/usage.d.mts +12 -0
  360. package/shared/usage.mjs +56 -3
  361. package/shared/websites.d.mts +15 -0
  362. package/shared/websites.mjs +61 -0
  363. package/shared/where-it-runs-from.d.mts +43 -0
  364. package/shared/where-it-runs-from.mjs +155 -0
  365. package/shared/work-items.mjs +150 -29
  366. package/shared/work-lines.mjs +93 -245
  367. package/shared/xterm-invalid-range.d.mts +2 -0
  368. package/shared/xterm-invalid-range.mjs +58 -0
  369. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  370. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -0,0 +1,476 @@
1
+ // THE PICTURES, FILMS, SOUNDS AND PAGES A THREAD NAMES, FOR HER PHONE WITH THE
2
+ // MAC SHUT.
3
+ //
4
+ // A thread's copy online is its words (shared/phone-snapshot.mjs), and a path
5
+ // in those words is a file on this Mac that the hosted phone site cannot open.
6
+ // So for each thread carried, this finds the files its words name, inside that
7
+ // project's own folders only, uploads each once to her own private storage
8
+ // (named by what is in it, so the same file twice is one upload), and lists
9
+ // them in the thread's copy: the path as the screens ask for it, and the name
10
+ // online. A page brings the files it draws with it.
11
+ //
12
+ // What goes up is bounded three ways (per file, per thread, all together), and
13
+ // newest threads go first; a file over the per-file bound is listed as too big,
14
+ // so the phone can say so instead of drawing nothing. A file no thread names
15
+ // any more is deleted after a few days.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { hideSecrets } from '../shared/phone-snapshot.mjs';
20
+ import { readPictureShapeSync } from './picture-shape.mjs';
21
+
22
+ // A tenth of the Pro plan's 100 GB, which the organization's projects share;
23
+ // past it the oldest threads' files make way (`trimBelow`).
24
+ export const UPLOAD_LIMITS = { fileBytes: 25 * 1024 * 1024, threadBytes: 100 * 1024 * 1024, totalBytes: 10 * 1024 ** 3, files: 60, days: 7 };
25
+ const KEEP_UNNAMED_MS = 3 * 24 * 3600_000;
26
+ const LOOK_AGAIN_MS = 5 * 60_000;
27
+ // After a refused upload (no bucket yet, offline, signed out of storage) the
28
+ // uploader rests, rather than reading every queued file again to be refused again.
29
+ const REST_AFTER_REFUSAL_MS = 30 * 60_000;
30
+
31
+ const TYPES = {
32
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml',
33
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg',
34
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.m4v': 'video/mp4', '.webm': 'video/webm',
35
+ '.html': 'text/html', '.htm': 'text/html', '.pdf': 'application/pdf', '.md': 'text/markdown', '.markdown': 'text/markdown',
36
+ };
37
+ // Documents, named anywhere in the words as the kinds above are.
38
+ const OFFICE = 'application/vnd.openxmlformats-officedocument';
39
+ const DOCUMENTS = {
40
+ '.txt': 'text/plain; charset=utf-8', '.log': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv; charset=utf-8', '.tsv': 'text/tab-separated-values; charset=utf-8',
41
+ '.yaml': 'text/yaml; charset=utf-8', '.yml': 'text/yaml; charset=utf-8', '.xml': 'text/xml', '.rtf': 'application/rtf', '.heic': 'image/heic',
42
+ '.doc': 'application/msword', '.docx': `${OFFICE}.wordprocessingml.document`, '.xls': 'application/vnd.ms-excel', '.xlsx': `${OFFICE}.spreadsheetml.sheet`,
43
+ '.ppt': 'application/vnd.ms-powerpoint', '.pptx': `${OFFICE}.presentationml.presentation`, '.pages': 'application/vnd.apple.pages', '.numbers': 'application/vnd.apple.numbers', '.zip': 'application/zip',
44
+ };
45
+ // What a page may bring with it, besides the kinds above.
46
+ const PAGE_PARTS = { '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf' };
47
+ const PICTURE = /\.(png|jpe?g|gif|webp|avif)$/i;
48
+ // Keys, certificates and databases never go, whatever way they are named.
49
+ const NEVER = /\.(pem|key|p12|pfx|kdbx|keystore|jks|der|crt|cer|asc|gpg|sqlite3?|db)$/i;
50
+ const EXT = [...Object.keys(TYPES), ...Object.keys(DOCUMENTS)].map((e) => e.slice(1)).join('|');
51
+ // A path as a worker writes one: bare, after ~/ or /, or in quotes with spaces.
52
+ const NAMED = new RegExp(String.raw`(?:["“'‘]((?:~\/|\/)?[^"”'’\r\n]+\.(?:${EXT}))["”'’])|((?:~\/|\/)?[\w@%+=,.\/-]*[\w-]\.(?:${EXT}))(?![\w])`, 'gi');
53
+ // Any kind at all where the screens draw it as a file: a markdown link's
54
+ // target (a reply's attachments) or a path in backticks.
55
+ const LINKED = /\]\(<?([^)\s>]+\.[A-Za-z0-9]{1,10})>?\)|`([^`\s]+\.[A-Za-z0-9]{1,10})`/g;
56
+ const FOLDER_LINE = /(?:^|\n)[ \t]*((?:~\/|\/)[\w./-]*[\w-])\/?[ \t]*(?=\n|$)/g;
57
+ const SKIP_SEGMENTS = new Set(['node_modules', '.git']);
58
+ // A file whose name says it holds a secret never goes, whatever its kind.
59
+ const SECRET_NAME = /(secret|credential|passw|token|private[-_.]?key|(^|[/.])env($|[/.]))/i;
60
+ // Words, not pictures: what goes up is the text with its keys hidden, as a
61
+ // thread's words are. A file of another kind whose bytes are text goes so too.
62
+ const TEXT = /\.(html?|md|markdown|css|svg|txt|log|json|csv|tsv|ya?ml|xml)$/i;
63
+ // Not pictures, films or sounds, which are never read as text.
64
+ const MEDIA = /\.(png|jpe?g|gif|webp|avif|heic|mp3|wav|m4a|aac|ogg|mp4|mov|m4v|webm|pdf|zip|docx?|xlsx?|pptx?|pages|numbers|woff2?|ttf|otf)$/i;
65
+ // A spreadsheet's column whose heading names a secret is emptied: a password
66
+ // in a column has no name beside it for the text rules to hide it by.
67
+ const SECRET_COLUMN = /pass|secret|token|key|credential|auth/i;
68
+ // A page or stylesheet bigger than this is listed but not read for what it draws.
69
+ const PAGE_READ_MAX = 2 * 1024 * 1024;
70
+ // And all the pages one thread names, read for what they draw, at most this much.
71
+ const PAGES_READ_MAX = 6 * 1024 * 1024;
72
+
73
+ /** At most `max` bytes of a file, through one open handle; null for a bigger one. */
74
+ function readAtMost(file, max) {
75
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
76
+ try {
77
+ const buf = Buffer.alloc(max + 1);
78
+ let n = 0;
79
+ for (let got; n <= max && (got = fs.readSync(fd, buf, n, max + 1 - n, null)) > 0;) n += got;
80
+ return n > max ? null : buf.subarray(0, n);
81
+ } finally {
82
+ fs.closeSync(fd);
83
+ }
84
+ }
85
+
86
+ const knownType = (file) => TYPES[path.extname(file).toLowerCase()] ?? PAGE_PARTS[path.extname(file).toLowerCase()] ?? null;
87
+ /** What a file goes up as, or null for one that never goes. */
88
+ export const typeOf = (file) => {
89
+ if (NEVER.test(file) || SECRET_NAME.test(path.basename(file))) return null;
90
+ return knownType(file) ?? DOCUMENTS[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
91
+ };
92
+
93
+ const cellsOf = (line, sep) => {
94
+ const out = [];
95
+ let cell = '';
96
+ let quoted = false;
97
+ for (const ch of line) {
98
+ if (ch === '"') quoted = !quoted;
99
+ if (ch === sep && !quoted) { out.push(cell); cell = ''; } else cell += ch;
100
+ }
101
+ out.push(cell);
102
+ return out;
103
+ };
104
+ /** A spreadsheet's text with every column whose heading names a secret emptied. */
105
+ export function hideCsvSecrets(text) {
106
+ const lines = String(text).split('\n');
107
+ const head = (lines[0] ?? '').replace(/\r$/, '');
108
+ const sep = head.includes('\t') && !head.includes(',') ? '\t' : ',';
109
+ const hide = cellsOf(head, sep).map((h, i) => (SECRET_COLUMN.test(h) ? i : -1)).filter((i) => i >= 0);
110
+ if (!hide.length) return String(text);
111
+ return [lines[0], ...lines.slice(1).map((line) => {
112
+ if (!line) return line;
113
+ const cr = line.endsWith('\r');
114
+ const cells = cellsOf(cr ? line.slice(0, -1) : line, sep);
115
+ for (const i of hide) if (i < cells.length) cells[i] = '[hidden]';
116
+ return cells.join(sep) + (cr ? '\r' : '');
117
+ })].join('\n');
118
+ }
119
+
120
+ // Bytes that read as text: no NUL near the start, and whole UTF-8.
121
+ function readsAsText(buf) {
122
+ if (buf.subarray(0, 8000).includes(0)) return false;
123
+ try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true; } catch { return false; }
124
+ }
125
+
126
+ // Newest first, so a long thread's latest messages are the ones its bound keeps.
127
+ function textsOf(snapshot) {
128
+ const out = [];
129
+ const add = (v) => { if (typeof v === 'string' && v) out.push(v); };
130
+ for (const v of Object.values(snapshot?.item ?? {})) add(v);
131
+ for (const l of [...(snapshot?.history ?? [])].reverse()) for (const v of Object.values(l?.patch ?? {})) add(v);
132
+ return out;
133
+ }
134
+
135
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
136
+ const within = (root, file) => !!root && (file === root || file.startsWith(root + path.sep));
137
+
138
+ /**
139
+ * Where a file is, as the phone knows it: relative to the project's documents
140
+ * folder, or `@repo/` and relative to its code. Null outside both.
141
+ */
142
+ export function keyOf(file, { docs, repo }) {
143
+ if (docs && within(docs, file)) return path.relative(docs, file).split(path.sep).join('/');
144
+ if (repo && within(repo, file)) return `@repo/${path.relative(repo, file).split(path.sep).join('/')}`;
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * The files a thread's copy names, as [{ key, file }], inside the project's
150
+ * own folders only. A bare name is looked for where the screens look for it
151
+ * (renderer/src/message-folders.ts pictureRoots), in that order.
152
+ */
153
+ export function filesNamed(snapshot, { dir, repoPath = null, home }) {
154
+ const docs = dir && real(dir);
155
+ if (!docs) return [];
156
+ const repo = repoPath ? real(repoPath) : null;
157
+ const id = snapshot?.item?.id;
158
+ const found = new Map();
159
+ const take = (abs) => {
160
+ const file = real(abs);
161
+ if (!file || file.split(path.sep).some((seg) => SKIP_SEGMENTS.has(seg)) || SECRET_NAME.test(path.basename(file))) return false;
162
+ const key = keyOf(file, { docs, repo });
163
+ if (!key) return false;
164
+ let st;
165
+ try { st = fs.statSync(file); } catch { return false; }
166
+ if (!st.isFile() || !typeOf(file)) return false;
167
+ if (!found.has(key)) found.set(key, file);
168
+ return true;
169
+ };
170
+ const full = (p) => path.resolve(p.startsWith('~/') ? path.join(home, p.slice(2)) : p);
171
+ const texts = textsOf(snapshot);
172
+ const named = [];
173
+ for (const text of texts) {
174
+ for (const m of text.matchAll(NAMED)) named.push((m[1] ?? m[2]).trim());
175
+ for (const m of text.matchAll(LINKED)) { try { named.push(decodeURI(m[1] ?? m[2]).trim()); } catch { named.push((m[1] ?? m[2]).trim()); } }
176
+ }
177
+ // The folders the words name come first, as they do on the screens.
178
+ const folders = [];
179
+ for (const n of named) if (n.includes('/')) folders.push(path.dirname(n.startsWith('/') || n.startsWith('~/') ? full(n) : path.join(docs, n)));
180
+ for (const text of texts) for (const m of text.matchAll(FOLDER_LINE)) folders.push(full(m[1]));
181
+ const roots = [...new Set([
182
+ ...folders, id && path.join(docs, 'designs', id), repo && id && path.join(repo, '.claude', 'worktrees', id),
183
+ docs, path.join(docs, 'designs'), path.join(docs, 'attachments'), repo, repo && path.join(repo, 'designs'),
184
+ ].filter(Boolean))];
185
+ for (const n of named) {
186
+ if (/^[a-z]+:\/\//i.test(n)) continue;
187
+ if (n.startsWith('/') || n.startsWith('~/')) { take(full(n)); continue; }
188
+ const rel = n.replace(/^\.\//, '');
189
+ if (rel.split('/').includes('..')) continue;
190
+ for (const root of roots) if (fs.existsSync(path.join(root, rel)) && take(path.join(root, rel))) break;
191
+ }
192
+ return [...found].map(([key, file]) => ({ key, file }));
193
+ }
194
+
195
+ /** The folders a thread's copy names on a line of their own, as [{ key, pictures: [{ key, file }] }], newest pictures first. */
196
+ export function foldersNamed(snapshot, { dir, repoPath = null, home, limit = 6 }) {
197
+ const docs = dir && real(dir);
198
+ if (!docs) return [];
199
+ const repo = repoPath ? real(repoPath) : null;
200
+ const out = [];
201
+ for (const text of textsOf(snapshot)) {
202
+ for (const m of text.matchAll(FOLDER_LINE)) {
203
+ const named = m[1];
204
+ if (/\.[a-z0-9]{1,8}$/i.test(named)) continue;
205
+ const folder = real(path.resolve(named.startsWith('~/') ? path.join(home, named.slice(2)) : named));
206
+ const key = folder && keyOf(folder, { docs, repo });
207
+ if (!key || out.some((f) => f.key === `${key}/`)) continue;
208
+ let entries = [];
209
+ try { entries = fs.readdirSync(folder, { withFileTypes: true }).filter((e) => e.isFile() && PICTURE.test(e.name)); } catch { continue; }
210
+ const pictures = entries
211
+ .map((e) => ({ key: `${key}/${e.name}`, file: path.join(folder, e.name), m: fs.statSync(path.join(folder, e.name)).mtimeMs }))
212
+ .sort((a, b) => b.m - a.m || a.key.localeCompare(b.key))
213
+ .slice(0, limit)
214
+ .map(({ key: k, file }) => ({ key: k, file }));
215
+ out.push({ key: `${key}/`, pictures });
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // What a page draws from beside it: src, href, poster, srcset and url() that
222
+ // name a file next to it rather than a site. A stylesheet's own url()s too.
223
+ const REFS = /\b(?:src|href|poster|data-src)\s*=\s*["']([^"'#?]+)[^"']*["']|\bsrcset\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')#?]+)[^"')]*["']?\s*\)/gi;
224
+ export function partsOf(text) {
225
+ const out = new Set();
226
+ for (const m of String(text).matchAll(REFS)) {
227
+ const list = m[2] ? m[2].split(',').map((s) => s.trim().split(/\s+/)[0]) : [m[1] ?? m[3]];
228
+ for (const ref of list) {
229
+ if (!ref || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(ref)) continue;
230
+ out.add(decodeURIComponent(ref));
231
+ }
232
+ }
233
+ return [...out];
234
+ }
235
+
236
+ /** A page's own files, as [{ key, file }], one stylesheet deep, inside the project's folders only. */
237
+ export function pageParts(page, { docs, repo, budget = { bytes: PAGES_READ_MAX } }) {
238
+ const out = new Map();
239
+ const visit = (file, depth) => {
240
+ let text;
241
+ try {
242
+ const raw = readAtMost(file, Math.min(PAGE_READ_MAX, budget.bytes));
243
+ if (!raw) return;
244
+ budget.bytes -= raw.length;
245
+ text = raw.toString('utf8');
246
+ } catch { return; }
247
+ for (const ref of partsOf(text)) {
248
+ const part = real(path.join(path.dirname(file), ref));
249
+ const key = part && keyOf(part, { docs, repo });
250
+ if (!key || out.has(key) || !knownType(part) || SECRET_NAME.test(path.basename(part)) || part.split(path.sep).some((seg) => SKIP_SEGMENTS.has(seg))) continue;
251
+ try { if (!fs.statSync(part).isFile()) continue; } catch { continue; }
252
+ out.set(key, part);
253
+ if (depth < 1 && /\.css$/i.test(part)) visit(part, depth + 1);
254
+ }
255
+ };
256
+ visit(page.file, 0);
257
+ return [...out].map(([key, file]) => ({ key, file }));
258
+ }
259
+
260
+ /**
261
+ * The uploader. `manifest(...)` says what a thread's copy lists now (only what
262
+ * is already online, so the phone never asks for a file that is not there yet)
263
+ * and queues the rest; `kick()` uploads what is queued, one file at a time, in
264
+ * the background; `sweep()` deletes what nothing has named for days.
265
+ */
266
+ export function createPhoneUploads({ backend, owner, device, alive = () => true, state, save, home, now = Date.now, limits = UPLOAD_LIMITS, log = () => {} }) {
267
+ state.uploaded ??= {}; // name -> { bytes, named: last moment a thread named it, rank: newest thread naming it }
268
+ // What each file on this Mac is called online, learnt by the upload (off
269
+ // the window's thread, a piece at a time) and good while it is unchanged.
270
+ const names = new Map(); // file -> { size, mtimeMs, name }
271
+ const queue = new Map(); // file -> { bytes, rank, roots, threads }
272
+ const memo = new Map(); // thread -> { base, at, sig, out }
273
+ let running = null;
274
+ let restUntil = 0;
275
+ let saidFull = false;
276
+ let madeRoom = 0;
277
+ // A file on record from before ranks were kept reads as oldest, so nothing is
278
+ // taken until every thread has looked again and said how new its files are.
279
+ const startedAt = now();
280
+
281
+ const known = (file, st) => {
282
+ const was = names.get(file);
283
+ return was && was.size === st.size && was.mtimeMs === st.mtimeMs ? was.name : null;
284
+ };
285
+ // A picture's width and height, so the phone draws its box at that shape
286
+ // before the picture arrives; read again only when the file changes.
287
+ const shapes = new Map(); // file -> { size, mtimeMs, shape }
288
+ const shapeOf = (file, st) => {
289
+ if (!PICTURE.test(file)) return null;
290
+ const was = shapes.get(file);
291
+ if (was && was.size === st.size && was.mtimeMs === st.mtimeMs) return was.shape;
292
+ let shape = null;
293
+ try { shape = readPictureShapeSync(file); } catch { /* drawn 4:3 until it arrives, as before */ }
294
+ shapes.set(file, { size: st.size, mtimeMs: st.mtimeMs, shape });
295
+ return shape;
296
+ };
297
+ const total = () => Object.values(state.uploaded).reduce((n, u) => n + (u.bytes || 0), 0);
298
+ const olderThan = (rank) => Object.entries(state.uploaded)
299
+ .filter(([, u]) => (u.rank ?? 0) < rank)
300
+ .sort((a, b) => (a[1].rank ?? 0) - (b[1].rank ?? 0) || (a[1].named ?? 0) - (b[1].named ?? 0));
301
+ const roomFor = (bytes, rank) => {
302
+ const over = total() + bytes - limits.totalBytes;
303
+ if (over <= 0) return true;
304
+ if (now() - startedAt < LOOK_AGAIN_MS) return false;
305
+ return olderThan(rank).reduce((n, [, u]) => n + (u.bytes || 0), 0) >= over;
306
+ };
307
+ // THE NEWEST WORK GOES FIRST WHEN THE SPACE IS FULL: the oldest threads'
308
+ // files leave to make room, and never those of a thread as new as this one.
309
+ // Only after the new file is up, so a refused upload takes nothing away; the
310
+ // space runs over by that one file until then.
311
+ async function trimBelow(rank) {
312
+ const over = total() - limits.totalBytes;
313
+ if (over <= 0) return;
314
+ const drop = [];
315
+ let freed = 0;
316
+ for (const [n, u] of olderThan(rank)) {
317
+ if (freed >= over) break;
318
+ drop.push(n);
319
+ freed += u.bytes || 0;
320
+ }
321
+ if (!drop.length) return;
322
+ await backend.dropPhoneFiles({ owner, device, names: drop });
323
+ for (const n of drop) delete state.uploaded[n];
324
+ save();
325
+ for (const m of memo.values()) m.at = -Infinity;
326
+ madeRoom += drop.length;
327
+ }
328
+ const full = () => {
329
+ if (!saidFull) log('phone: the space for files is full');
330
+ saidFull = true;
331
+ };
332
+
333
+ /**
334
+ * What thread `sk`'s copy (`made()`, built only when needed) lists:
335
+ * { files: { key: { n, s } | { s, big: true } }, folders?: { 'key/': [keys] } },
336
+ * or null when it names nothing that can go. Newer threads upload first.
337
+ * Its words are read again when they change (`base`, the copy's own key) and
338
+ * its files looked at again every five minutes, since a page can change under the
339
+ * same words.
340
+ */
341
+ function manifest(sk, made, { dir, repoPath = null, updatedAt = 0, base = '' }) {
342
+ const was = memo.get(sk);
343
+ if (was && was.base === base && now() - was.at < LOOK_AGAIN_MS) return was.out;
344
+ const rank = Number(updatedAt) || 0;
345
+ let out = null;
346
+ if (now() - rank <= limits.days * 24 * 3600_000) {
347
+ const snapshot = typeof made === 'function' ? made() : made;
348
+ const named = filesNamed(snapshot, { dir, repoPath, home });
349
+ const folders = foldersNamed(snapshot, { dir, repoPath, home });
350
+ const where = { docs: real(dir), repo: repoPath && real(repoPath) };
351
+ // Each page with its own files right after it.
352
+ const all = new Map();
353
+ const budget = { bytes: PAGES_READ_MAX };
354
+ for (const f of named) {
355
+ if (all.size >= limits.files) break;
356
+ if (!all.has(f.key)) all.set(f.key, f.file);
357
+ if (/\.html?$/i.test(f.file) && budget.bytes > 0) for (const p of pageParts(f, { ...where, budget })) if (!all.has(p.key)) all.set(p.key, p.file);
358
+ }
359
+ for (const f of folders) for (const p of f.pictures) if (!all.has(p.key)) all.set(p.key, p.file);
360
+ const files = {};
361
+ let bytes = 0;
362
+ for (const [key, file] of [...all].slice(0, limits.files)) {
363
+ let st;
364
+ try { st = fs.statSync(file); } catch { continue; }
365
+ if (st.size > limits.fileBytes || bytes + st.size > limits.threadBytes) { files[key] = { s: st.size, big: true }; continue; }
366
+ bytes += st.size;
367
+ const name = known(file, st);
368
+ if (name && state.uploaded[name]) {
369
+ const shape = shapeOf(file, st);
370
+ files[key] = shape ? { n: name, s: st.size, w: shape.w, h: shape.h } : { n: name, s: st.size };
371
+ state.uploaded[name].named = now();
372
+ state.uploaded[name].rank = Math.max(state.uploaded[name].rank ?? 0, rank);
373
+ } else {
374
+ const job = queue.get(file) ?? { bytes: st.size, rank, roots: [where.docs, where.repo].filter(Boolean), threads: new Set() };
375
+ job.rank = Math.max(job.rank, rank);
376
+ job.threads.add(sk);
377
+ queue.set(file, job);
378
+ }
379
+ }
380
+ if (Object.keys(files).length) {
381
+ out = { files };
382
+ const listed = Object.fromEntries(folders.map((f) => [f.key, f.pictures.map((p) => p.key).filter((k) => files[k]?.n)]).filter(([, keys]) => keys.length));
383
+ if (Object.keys(listed).length) out.folders = listed;
384
+ }
385
+ }
386
+ // The same object while nothing in it changed, so the publisher can tell.
387
+ const sig = JSON.stringify(out);
388
+ if (was?.sig === sig) out = was.out;
389
+ // Each thread's next look at its own moment, so they do not all fall in one pass.
390
+ const spread = was ? 0 : (parseInt(crypto.createHash('sha1').update(sk).digest('hex').slice(0, 8), 16) % LOOK_AGAIN_MS);
391
+ memo.set(sk, { base, at: now() - spread, sig, out });
392
+ return out;
393
+ }
394
+
395
+ // The file as it is now, read only if it is still the one that was checked:
396
+ // no link anywhere on its way, inside the project, a plain file within the bound.
397
+ async function readChecked(file, roots) {
398
+ if (real(file) !== file || !roots.some((r) => within(r, file))) return null;
399
+ const fh = await fs.promises.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
400
+ try {
401
+ const st = await fh.stat();
402
+ const there = fs.statSync(file);
403
+ if (!st.isFile() || st.ino !== there.ino || st.dev !== there.dev || real(file) !== file || st.size > limits.fileBytes) return null;
404
+ const buf = Buffer.alloc(st.size + 1);
405
+ let n = 0;
406
+ for (let got; n <= st.size && (got = (await fh.read(buf, n, st.size + 1 - n, n)).bytesRead) > 0;) n += got;
407
+ // Grew while it was read: the next pass looks at it again.
408
+ if (n !== st.size) return null;
409
+ const raw = buf.subarray(0, n);
410
+ if (!TEXT.test(file) && (MEDIA.test(file) || !readsAsText(raw))) return { st, data: raw };
411
+ const words = /\.(csv|tsv)$/i.test(file) ? hideCsvSecrets(raw.toString('utf8')) : raw.toString('utf8');
412
+ return { st, data: Buffer.from(hideSecrets(words, { home })) };
413
+ } finally {
414
+ await fh.close();
415
+ }
416
+ }
417
+
418
+ async function pump() {
419
+ while (queue.size) {
420
+ if (!alive()) return;
421
+ const [file, job] = [...queue].sort((a, b) => b[1].rank - a[1].rank)[0];
422
+ queue.delete(file);
423
+ try {
424
+ const was = names.get(file);
425
+ if (!(was && state.uploaded[was.name]) && !roomFor(job.bytes, job.rank)) { full(); continue; }
426
+ // A file gone or changed since it was named is skipped; only a refused upload rests the uploader.
427
+ const read = await readChecked(file, job.roots).catch(() => null);
428
+ if (!read) continue;
429
+ const name = `${crypto.createHash('sha256').update(read.data).digest('hex')}${path.extname(file).toLowerCase()}`;
430
+ names.set(file, { size: read.st.size, mtimeMs: read.st.mtimeMs, name });
431
+ if (!state.uploaded[name]) {
432
+ if (!roomFor(read.data.length, job.rank)) { full(); continue; }
433
+ if (!alive()) return;
434
+ await backend.putPhoneFile({ owner, device, name, data: read.data, type: typeOf(file) });
435
+ state.uploaded[name] = { bytes: read.data.length, named: now(), rank: job.rank };
436
+ saidFull = false;
437
+ save();
438
+ await trimBelow(job.rank);
439
+ } else state.uploaded[name].rank = Math.max(state.uploaded[name].rank ?? 0, job.rank);
440
+ // The threads that name it look again at what they list.
441
+ for (const sk of job.threads) { const m = memo.get(sk); if (m) m.at = -Infinity; }
442
+ } catch (err) {
443
+ log(`phone: could not send a file, trying again in half an hour (${err?.message ?? err})`);
444
+ restUntil = now() + REST_AFTER_REFUSAL_MS;
445
+ return;
446
+ }
447
+ }
448
+ }
449
+
450
+ return {
451
+ manifest,
452
+ /** Upload what is queued, in the background; the run already going if there is one. */
453
+ kick() {
454
+ if (!running && queue.size && now() >= restUntil) {
455
+ running = pump().finally(() => {
456
+ running = null;
457
+ if (madeRoom) log(`phone: took ${madeRoom} older file${madeRoom === 1 ? '' : 's'} off your phone to make room for newer ones`);
458
+ madeRoom = 0;
459
+ });
460
+ }
461
+ return running ?? Promise.resolve();
462
+ },
463
+ pending: () => queue.size,
464
+ pendingBytes: () => [...queue.values()].reduce((n, j) => n + j.bytes, 0),
465
+ /** Delete what no thread has named for days. */
466
+ async sweep() {
467
+ const old = Object.entries(state.uploaded).filter(([, u]) => now() - (u.named || 0) > KEEP_UNNAMED_MS).map(([n]) => n);
468
+ if (!old.length || !alive()) return 0;
469
+ await backend.dropPhoneFiles({ owner, device, names: old });
470
+ for (const n of old) delete state.uploaded[n];
471
+ save();
472
+ return old.length;
473
+ },
474
+ forget(sk) { memo.delete(sk); },
475
+ };
476
+ }
@@ -0,0 +1,114 @@
1
+ // A PICTURE'S WIDTH AND HEIGHT, OFF THE FIRST BYTES OF ITS FILE.
2
+ //
3
+ // The phone draws a picture it has never seen as a 4:3 box until the last byte
4
+ // arrives, because WebKit gives a picture no size before it is whole, and then
5
+ // the words under it jump by the difference: 514px for a phone screenshot at
6
+ // 390px wide (tests/a-picture-the-phone-has-never-seen-gets-its-shape-before-it-arrives).
7
+ // The phone asks for this first (main/serve.mjs, /f/<key>/shape/) and draws
8
+ // the box at the picture's own shape.
9
+ //
10
+ // PNG, GIF, JPEG and WebP, which is what agents and phones make. Anything else,
11
+ // or a file cut short, is null: the box stays 4:3, which is no worse than it
12
+ // was, rather than a guessed shape that is wrong.
13
+ import fs from 'node:fs';
14
+
15
+ /** How much of a file is read. A JPEG's frame sits after its Exif and colour profile. */
16
+ const HEAD_BYTES = 256 * 1024;
17
+
18
+ const shape = (w, h) => (w > 0 && h > 0 ? { w, h } : null);
19
+
20
+ function png(b) {
21
+ if (b.length < 24 || b.readUInt32BE(0) !== 0x89504e47 || b.toString('latin1', 12, 16) !== 'IHDR') return null;
22
+ return shape(b.readUInt32BE(16), b.readUInt32BE(20));
23
+ }
24
+
25
+ function gif(b) {
26
+ if (b.length < 10 || b.toString('latin1', 0, 4) !== 'GIF8') return null;
27
+ return shape(b.readUInt16LE(6), b.readUInt16LE(8));
28
+ }
29
+
30
+ function webp(b) {
31
+ if (b.length < 30 || b.toString('latin1', 0, 4) !== 'RIFF' || b.toString('latin1', 8, 12) !== 'WEBP') return null;
32
+ const chunk = b.toString('latin1', 12, 16);
33
+ if (chunk === 'VP8X') return shape(1 + b.readUIntLE(24, 3), 1 + b.readUIntLE(27, 3));
34
+ if (chunk === 'VP8 ') return shape(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff);
35
+ if (chunk === 'VP8L') {
36
+ const bits = b.readUInt32LE(21);
37
+ return shape(1 + (bits & 0x3fff), 1 + ((bits >>> 14) & 0x3fff));
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** The Exif orientation in an APP1 block, or 0. 5 to 8 mean it is drawn turned a quarter. */
43
+ function orientation(b, start, end) {
44
+ if (b.toString('latin1', start, start + 6) !== 'Exif\0\0') return 0;
45
+ const tiff = start + 6;
46
+ if (tiff + 8 > end) return 0;
47
+ const little = b.toString('latin1', tiff, tiff + 2) === 'II';
48
+ const u16 = (at) => (little ? b.readUInt16LE(at) : b.readUInt16BE(at));
49
+ const u32 = (at) => (little ? b.readUInt32LE(at) : b.readUInt32BE(at));
50
+ const ifd = tiff + u32(tiff + 4);
51
+ if (ifd + 2 > end) return 0;
52
+ const count = u16(ifd);
53
+ for (let i = 0; i < count; i++) {
54
+ const entry = ifd + 2 + i * 12;
55
+ if (entry + 12 > end) return 0;
56
+ if (u16(entry) === 0x0112) return u16(entry + 8);
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ function jpeg(b) {
62
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
63
+ let turned = 0;
64
+ let i = 2;
65
+ while (i + 4 <= b.length) {
66
+ if (b[i] !== 0xff) return null;
67
+ const marker = b[i + 1];
68
+ // Fill bytes before a marker.
69
+ if (marker === 0xff) { i += 1; continue; }
70
+ const length = b.readUInt16BE(i + 2);
71
+ // Every start-of-frame but the three that are not frames (DHT, JPG, DAC).
72
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
73
+ if (i + 9 > b.length) return null;
74
+ const h = b.readUInt16BE(i + 5);
75
+ const w = b.readUInt16BE(i + 7);
76
+ return turned >= 5 && turned <= 8 ? shape(h, w) : shape(w, h);
77
+ }
78
+ if (marker === 0xe1 && !turned) turned = orientation(b, i + 4, Math.min(b.length, i + 2 + length));
79
+ i += 2 + length;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** `{ w, h }` as the browser will draw it, or null. */
85
+ export function pictureShape(bytes) {
86
+ if (!bytes?.length) return null;
87
+ try {
88
+ return png(bytes) ?? gif(bytes) ?? webp(bytes) ?? jpeg(bytes);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** The same, read off a file on disk without waiting, for a pass that lists many. */
95
+ export function readPictureShapeSync(file) {
96
+ const fd = fs.openSync(file, 'r');
97
+ try {
98
+ const buffer = Buffer.alloc(HEAD_BYTES);
99
+ return pictureShape(buffer.subarray(0, fs.readSync(fd, buffer, 0, HEAD_BYTES, 0)));
100
+ } finally {
101
+ fs.closeSync(fd);
102
+ }
103
+ }
104
+
105
+ /** The same, read off a file on disk. */
106
+ export async function readPictureShape(file) {
107
+ const handle = await fs.promises.open(file, 'r');
108
+ try {
109
+ const { buffer, bytesRead } = await handle.read({ buffer: Buffer.alloc(HEAD_BYTES), position: 0 });
110
+ return pictureShape(buffer.subarray(0, bytesRead));
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ }