agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
package/main/demo.mjs ADDED
@@ -0,0 +1,254 @@
1
+ // A SECOND AGENTBOX WITH SOMEBODY ELSE'S WORK IN IT, FOR DEMOS.
2
+ //
3
+ // WHY THIS IS THE NEW-USER ROW AND NOT A NEW MECHANISM. Being a new user
4
+ // already opens a second Agentbox inside a throwaway home
5
+ // (shared/fresh-user-home.mjs, main/fresh-user.mjs), and everything that makes
6
+ // that safe is exactly what a demo needs: her own copy keeps running with every
7
+ // agent in it, nothing of hers is read, and nothing anywhere is deleted. The
8
+ // single-instance lock is keyed on userData and a throwaway home is a different
9
+ // userData, so the two stand side by side. That property is the whole thing,
10
+ // and inventing a second way to get it would be inventing a second way to get
11
+ // it wrong.
12
+ //
13
+ // WHAT IS DIFFERENT IS WHAT IS IN THE STORE. A new user opens on an empty one,
14
+ // which is correct for testing a first run and useless for a demo: an inbox
15
+ // with nothing in it demonstrates nothing. So this seeds three invented
16
+ // products and a day of invented work first (shared/demo-world.mjs), and
17
+ // because the store then has projects in it, the welcome walk does not run
18
+ // (`firstRunNeeded` counts projects) and the copy opens straight on the inbox.
19
+ //
20
+ // AND HER ~/.claude IS NOT LINKED THROUGH, which is the one place this is
21
+ // deliberately stricter than the new-user row. That folder holds the Claude
22
+ // Code sessions already running on this Mac, and the app puts them in the
23
+ // inbox as rows with her real folder names on them. In a demo those names are
24
+ // on a projector. Claude Code itself and its login ARE linked, so answering a
25
+ // row in the demo starts a real agent on an invented product, which is the
26
+ // most convincing thing the demo can do.
27
+ //
28
+ // NOTHING IS DELETED HERE, EVER, and there is no version of this file that may
29
+ // delete anything outside `HOMES` below.
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import { spawn } from 'node:child_process';
35
+ import { DEMO_DOCS, DEMO_ENV, DEMO_PRODUCTS, demoLedgerLines } from '../shared/demo-world.mjs';
36
+ import { prepareHome } from '../shared/fresh-user-home.mjs';
37
+ import { Name } from '../shared/product-name.mjs';
38
+ import { relaunchCommand } from './fresh-user.mjs';
39
+ import { encodeProjectPath, storeRootEnv } from './store/home.mjs';
40
+ import { NAME, nameSlug } from '../shared/product-name.mjs';
41
+
42
+ /**
43
+ * Where every demo home lives. Its own folder, NOT the first-run one: the two
44
+ * are cleared separately, and a demo home under the first-run folder would
45
+ * read as a fresh-user install to `runningAsAFreshUser` and send its counts
46
+ * under the wrong fixed id. */
47
+ export const HOMES = path.join(os.tmpdir(), `${nameSlug}-demo`);
48
+
49
+ /**
50
+ * The account the demo store is laid out under. It matches the id
51
+ * `main/config.mjs` defaults to, because the demo copy has no config file and
52
+ * will therefore look for its products at exactly this path. */
53
+ export const DEMO_ACCOUNT = '00000000-0000-4000-8000-000000000000';
54
+
55
+ /**
56
+ * Named by the clock, like a fresh-user home, so the newest sorts last and two
57
+ * demos opened a minute apart are told apart on disk. */
58
+ export function makeDemoHome(now = new Date()) {
59
+ const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
60
+ const home = path.join(HOMES, stamp);
61
+ fs.mkdirSync(home, { recursive: true });
62
+ return home;
63
+ }
64
+
65
+ /** The demo homes on disk, oldest first. */
66
+ export function demoHomes() {
67
+ if (!fs.existsSync(HOMES)) return [];
68
+ return fs
69
+ .readdirSync(HOMES)
70
+ .map((name) => path.join(HOMES, name))
71
+ .filter((dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } })
72
+ .sort();
73
+ }
74
+
75
+ /**
76
+ * Delete every demo home. Only ever this /tmp folder: `HOMES` is not
77
+ * configurable, and that is on purpose. */
78
+ export function clearDemoHomes() {
79
+ try {
80
+ fs.rmSync(HOMES, { recursive: true, force: true });
81
+ return { ok: true };
82
+ } catch (err) {
83
+ return { ok: false, error: err?.message ?? String(err) };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Where the demo copy will keep its store, and where it will keep its own
89
+ * records. Both are derived from the throwaway home rather than configured,
90
+ * because the copy derives them the same way from the same home: `storeRoot`
91
+ * defaults to `<home>/<Name>` (main/config.mjs) and the app's home defaults
92
+ * to `<home>/.agentbox` (main/store/home.mjs).
93
+ *
94
+ * THE FOLDER IS READ FROM THE SAME CONSTANT AS THAT DEFAULT, not spelled out
95
+ * again. It was the literal 'the app' mirroring what config.mjs then said,
96
+ * and when that default was renamed on 2026-09-22 this line went on pointing
97
+ * at the old folder: the demo copy would have written its store to one place
98
+ * and looked for it in another. Two literals that have to agree is the defect;
99
+ * one import is the fix. */
100
+ export function demoPaths(home) {
101
+ const storeRoot = path.join(home, Name);
102
+ return {
103
+ storeRoot,
104
+ accountRoot: path.join(storeRoot, 'accounts', DEMO_ACCOUNT),
105
+ appHome: path.join(home, `.${nameSlug}`),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The invented studio, written out as a real store.
111
+ *
112
+ * THE LEDGER DOES NOT GO IN THE PRODUCT FOLDER, and this is the one thing in
113
+ * here that is easy to get wrong: since 2026-08-27 the app keeps its own
114
+ * records under its own home, `<appHome>/projects/<encoded product dir>/`
115
+ * (main/store/home.mjs). Writing `work-items.jsonl` beside `project.json`
116
+ * instead would produce a demo whose inbox is empty, and the file would then be
117
+ * silently folded away the first time anything touched the store. The encoding
118
+ * is imported rather than repeated for exactly that reason.
119
+ *
120
+ * `.origin` is written alongside, because that is what the app writes on first
121
+ * use and what `removeMachinery` refuses to act without.
122
+ */
123
+ export function seedDemoStore(home, { now = Date.now() } = {}) {
124
+ const { accountRoot, appHome } = demoPaths(home);
125
+ const ledgers = demoLedgerLines(now);
126
+ const written = [];
127
+
128
+ for (const product of DEMO_PRODUCTS) {
129
+ const dir = path.join(accountRoot, product.slug);
130
+ fs.mkdirSync(dir, { recursive: true });
131
+
132
+ fs.writeFileSync(path.join(dir, 'project.json'), `${JSON.stringify({
133
+ schemaVersion: 2,
134
+ id: product.slug,
135
+ name: product.name,
136
+ createdAt: new Date(now - 21 * 86_400_000).toISOString(),
137
+ oneLiner: product.oneLiner,
138
+ }, null, 2)}\n`);
139
+
140
+ // The documents, and the catalog that makes them visible. A file on disk
141
+ // with no entry in index.json is a file the pane does not know about
142
+ // (main/store/project.mjs), so both halves or neither.
143
+ const docs = DEMO_DOCS[product.slug] ?? [];
144
+ for (const doc of docs) {
145
+ const file = path.join(dir, doc.path);
146
+ fs.mkdirSync(path.dirname(file), { recursive: true });
147
+ fs.writeFileSync(file, `${doc.text}\n`);
148
+ }
149
+ fs.writeFileSync(path.join(dir, 'index.json'), `${JSON.stringify({
150
+ schemaVersion: 2,
151
+ creations: docs.map((doc, i) => ({
152
+ id: `${product.slug}-doc-${i + 1}`,
153
+ kind: 'markdown',
154
+ title: doc.title,
155
+ path: doc.path,
156
+ ts: now - (i + 1) * 3_600_000,
157
+ schemaVersion: 2,
158
+ })),
159
+ runs: [],
160
+ reports: [],
161
+ updatedAt: now,
162
+ }, null, 2)}\n`);
163
+
164
+ // And the work, where the app will actually look for it.
165
+ const machinery = path.join(appHome, 'projects', encodeProjectPath(dir));
166
+ fs.mkdirSync(machinery, { recursive: true });
167
+ fs.writeFileSync(path.join(machinery, '.origin'), `${dir}\n`);
168
+ const jsonl = (ledgers[product.slug] ?? []).map((line) => JSON.stringify(line)).join('\n');
169
+ fs.writeFileSync(path.join(machinery, 'work-items.jsonl'), jsonl ? `${jsonl}\n` : '');
170
+
171
+ // ROWS, NOT LINES, because this number is read out loud in the toast. One
172
+ // row is two or three appended lines (filed, then said, then finished), so
173
+ // counting the file's lines said twenty where she can see nine.
174
+ const ids = new Set((ledgers[product.slug] ?? []).map((line) => line.id));
175
+ written.push({ slug: product.slug, dir, machinery, rows: ids.size });
176
+ }
177
+
178
+ return { accountRoot, appHome, products: written };
179
+ }
180
+
181
+ /**
182
+ * The environment a demo copy is launched with.
183
+ *
184
+ * BOTH HOME VARIABLES, ALWAYS, for the reason `freshEnv` gives: HOME alone
185
+ * does not move `app.getPath('appData')` on macOS, so the copy would share her
186
+ * userData, lose the single-instance lock race and quit in half a second.
187
+ *
188
+ * ASTRAL_HOME IS SET EXPLICITLY AND THAT IS NOT BELT AND BRACES. The app's home
189
+ * reads that variable first and only falls back to `~/.astral`, so a parent
190
+ * process that happens to have it set (every session Agentbox spawns does) would
191
+ * hand it straight through and the demo copy would read HER ledger while
192
+ * standing in an invented store. */
193
+ export function demoEnv(home, env = process.env) {
194
+ const { appHome } = demoPaths(home);
195
+ return { ...env, HOME: home, CFFIXED_USER_HOME: home, ...storeRootEnv(appHome), [DEMO_ENV]: '1' };
196
+ }
197
+
198
+ /**
199
+ * Open one. Returns what happened in the words the app will show her, rather
200
+ * than throwing: a row in ⌘K that fails silently is worse than one that says
201
+ * why.
202
+ *
203
+ * `withAgents` is FALSE and has no parameter, unlike the new-user row. See the
204
+ * head of this file: linking her ~/.claude through would put her real folder
205
+ * names in the demo inbox, and the demo exists so that does not happen.
206
+ */
207
+ export function openDemo({
208
+ execPath = process.execPath,
209
+ argv = process.argv,
210
+ packaged = true,
211
+ spawnFn = spawn,
212
+ now,
213
+ } = {}) {
214
+ let home;
215
+ let prepared;
216
+ let seeded;
217
+ try {
218
+ home = makeDemoHome(now ? new Date(now) : undefined);
219
+ prepared = prepareHome(home, { withAgents: false });
220
+ seeded = seedDemoStore(home, { now: now ?? Date.now() });
221
+ } catch (err) {
222
+ return { ok: false, error: `Could not lay out a demo under ${HOMES}: ${err?.message ?? err}` };
223
+ }
224
+
225
+ const { bin, args } = relaunchCommand({ execPath, argv, packaged });
226
+ if (!bin || !fs.existsSync(bin)) {
227
+ return { ok: false, error: `Cannot find this app's own program at ${bin}.`, home };
228
+ }
229
+
230
+ let child;
231
+ try {
232
+ child = spawnFn(bin, args, { detached: true, stdio: 'ignore', env: demoEnv(home) });
233
+ child.unref?.();
234
+ } catch (err) {
235
+ return { ok: false, error: `Could not start a second ${NAME}: ${err?.message ?? err}`, home };
236
+ }
237
+
238
+ // THE NOTES ARE THE HONEST PART, the same as the new-user row's. Both of
239
+ // these are things the demo copy can be missing while still looking fine, and
240
+ // both of them mean the same thing: answering a row in front of a room will
241
+ // not start an agent. Better said now than found out on the projector.
242
+ const notes = [];
243
+ if (!prepared.claudeBin) notes.push('Claude Code is not at ~/.local/bin/claude, so agents will not run in the demo.');
244
+ if (!prepared.keychain) notes.push('There is no ~/Library/Keychains here, so Claude Code will say it is not logged in.');
245
+
246
+ return {
247
+ ok: true,
248
+ home,
249
+ products: seeded.products.map((p) => p.slug),
250
+ rows: seeded.products.reduce((n, p) => n + p.rows, 0),
251
+ notes,
252
+ pid: child?.pid ?? null,
253
+ };
254
+ }
@@ -0,0 +1,103 @@
1
+ // A HOT-RELOADING WINDOW MUST NOT BE THE ONE SHE WORKS IN.
2
+ //
3
+ // She was working in a window an agent had started against a vite dev server,
4
+ // on her real store. Vite rebuilds that window whenever the source under it
5
+ // changes, and the source under it is the checkout every agent shares: it was
6
+ // checked out or merged five times in the eleven minutes before she wrote.
7
+ //
8
+ // The one that gets her is a change to App.tsx that adds, removes or moves a
9
+ // hook, which is what a branch switch does to it. React Fast Refresh keeps a
10
+ // component's state only while its hooks match, so App is rebuilt from nothing:
11
+ // `snap` is null again, App.tsx draws `<div className="boot">the app</div>`, and
12
+ // every child of App is thrown away and remade. That is the screen she means by
13
+ // the zero loading page, and that is the artifact closing and reopening. The
14
+ // page never navigates, which is why nothing in the dev server's log looked bad
15
+ // enough to be it. Six triggers were measured against a real window and exactly
16
+ // one was guilty: scripts/what-throws-her-out-of-agentbox.mjs.
17
+ //
18
+ // THE GUARD IS HERE RATHER THAN IN scripts/dev.mjs BECAUSE THAT IS NOT HOW IT
19
+ // HAPPENED. The dev server that day was started by hand, straight out of a
20
+ // scratchpad, and the app was started beside it with the environment variable
21
+ // set. A guard in the npm script would have watched it go past. Every dev window
22
+ // there can ever be goes through main.mjs asking for this verdict, so this is
23
+ // the only place that actually closes the door.
24
+ //
25
+ // WHAT IS STILL ALLOWED, because a guard that stops the work is a guard somebody
26
+ // deletes:
27
+ //
28
+ // Fixtures. Nothing in a fixtures window is hers, so there is nothing to be
29
+ // thrown out of, and this is what screenshot and proof scripts use.
30
+ //
31
+ // A throwaway store. A script that points HOME at a temp directory has no
32
+ // accounts under its store root, and a window on an empty store is nobody's
33
+ // working day.
34
+ //
35
+ // Saying you meant it, with ZERO_DEV_ON_REAL_STORE=1.
36
+
37
+ // `storeHasWork` is passed in rather than read here so this stays a pure
38
+ // decision and can be tested without a disk.
39
+ export function hotWindowVerdict({ devUrl, fixtures, forced, storeRoot, storeHasWork }) {
40
+ if (!devUrl) return { allow: true, reason: 'no dev server' };
41
+ if (fixtures) return { allow: true, reason: 'fixtures' };
42
+ if (forced) return { allow: true, reason: 'ZERO_DEV_ON_REAL_STORE' };
43
+ if (!storeHasWork) return { allow: true, reason: 'no accounts under the store root' };
44
+ return {
45
+ allow: false,
46
+ reason: 'a hot-reloading window on the real store',
47
+ message: [
48
+ '',
49
+ ` ZERO_DEV_URL is set and the store is ${storeRoot}, which is the real one.`,
50
+ ' A window served by a dev server is rebuilt whenever any agent changes the',
51
+ ' source under it, and being thrown back to the boot screen mid-task is what',
52
+ ' w-435356146a was about. Not opening one.',
53
+ '',
54
+ ' For development: ZERO_FIXTURES=1',
55
+ ' To give her a real window: npm start, with ZERO_DEV_URL unset',
56
+ ' If you truly meant this: ZERO_DEV_ON_REAL_STORE=1',
57
+ '',
58
+ ].join('\n'),
59
+ };
60
+ }
61
+
62
+ // Whether a store root has anybody's work under it. An account directory is the
63
+ // smallest true sign: a throwaway home has none, hers has one.
64
+ export function storeHasWork(fs, path, storeRoot) {
65
+ try {
66
+ const accounts = path.join(storeRoot, 'accounts');
67
+ return fs.readdirSync(accounts).some((name) => !name.startsWith('.'));
68
+ } catch {
69
+ // No store root, no accounts directory, no permission: all of them mean
70
+ // there is no working day here to interrupt.
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // A DEV SERVER MUST NEVER INHERIT A WINDOW IT DID NOT OPEN.
76
+ //
77
+ // Second half of the same bug, found 2026-08-23 16:00 while getting her out of
78
+ // it. Killing the dev server under her window does not navigate it: vite's
79
+ // client logs "server connection lost, polling for restart" and then polls
80
+ // `${devUrl}` once a second forever, and it only calls `location.reload` once
81
+ // one of those pings SUCCEEDS
82
+ // (node_modules/vite/dist/client/client.mjs:560-562, waitForSuccessfulPing at
83
+ // :732). So her window survived intact with the server gone, but it was left
84
+ // waiting on port 5199 like a trap: the next `npm run dev` anybody ran would
85
+ // bind that port, her poll would succeed, and she would be thrown straight back
86
+ // into the boot screen and back onto the treadmill.
87
+ //
88
+ // A fixed port is what makes that possible, so dev servers stop using one. Each
89
+ // run takes a free ephemeral port and tells its own electron where to find it.
90
+ // A window polling yesterday's port then polls it forever and nothing answers,
91
+ // which is the whole point.
92
+ export function chooseDevPort(net) {
93
+ const server = net.createServer();
94
+ return new Promise((resolve, reject) => {
95
+ server.once('error', reject);
96
+ // Port 0 asks the OS for one nothing else holds, so this can never pick a
97
+ // port some other window is already waiting on.
98
+ server.listen(0, '127.0.0.1', () => {
99
+ const { port } = server.address();
100
+ server.close((err) => (err ? reject(err) : resolve(port)));
101
+ });
102
+ });
103
+ }
@@ -0,0 +1,99 @@
1
+ // Reading and writing the markdown file the document pane has open.
2
+ //
3
+ // So the pane types straight onto the file on disk. Two rules make that safe
4
+ // enough to leave switched on:
5
+ //
6
+ // 1. ONLY MARKDOWN IS WRITABLE. An html page is a program; the pane shows it
7
+ // in a sandboxed frame and never edits it. Everything else the pane will
8
+ // not even open (renderer/src/doc-pane.ts).
9
+ // 2. A WRITE STAYS INSIDE THE PRODUCT'S OWN FOLDERS. Reading follows the
10
+ // finder's own rule and will honour an absolute path a worker typed, which
11
+ // is how a link into the app's repo works; writing will not. The file has
12
+ // to sit under the docs dir or the repo of the product whose card is open,
13
+ // or nothing is written.
14
+ //
15
+ // Pure of Electron on purpose: the IPC layer hands in the resolved path and the
16
+ // product, and these two functions are the whole of the disk access.
17
+ import fsDefault from 'node:fs';
18
+ import path from 'node:path';
19
+ import { artifactRoots } from './artifact-path.mjs';
20
+
21
+ export const EDITABLE_RE = /\.(md|markdown)$/i;
22
+ export const READABLE_RE = /\.(md|markdown|html?)$/i;
23
+
24
+ // A megabyte and a half of markdown is already far past anything in her store
25
+ // (the largest is decisions.md). Past that the editor is not the right tool and
26
+ // a silent truncation would eat her file on the next save, so it says so.
27
+ export const MAX_DOC_BYTES = 1_500_000;
28
+
29
+ /** Is `file` inside `dir`, without a prefix match like /a/bc counting as /a/b. */
30
+ export function isInside(dir, file) {
31
+ if (!dir || !file) return false;
32
+ const d = path.resolve(dir);
33
+ const f = path.resolve(file);
34
+ return f === d || f.startsWith(d + path.sep);
35
+ }
36
+
37
+ /** May the pane write to this file, on this product. */
38
+ export function canWrite({ file, product, fs = fsDefault }) {
39
+ if (!file || !EDITABLE_RE.test(file)) {
40
+ return { ok: false, error: 'Only markdown files can be edited here.' };
41
+ }
42
+ const roots = artifactRoots(product);
43
+ if (!roots.some((root) => isInside(root, file))) {
44
+ const name = product?.name || 'this project';
45
+ return { ok: false, error: `That file is outside ${name}, so nothing was saved.` };
46
+ }
47
+ if (!fs.existsSync(file)) {
48
+ return { ok: false, error: 'That file is no longer there, so nothing was saved.' };
49
+ }
50
+ return { ok: true };
51
+ }
52
+
53
+ /** The text of a document the pane has open, with what it is and when it changed. */
54
+ export function readDoc({ file, fs = fsDefault }) {
55
+ if (!file || !READABLE_RE.test(file)) return { ok: false, error: 'That is not a document this pane can open.' };
56
+ let stat;
57
+ try { stat = fs.statSync(file); } catch { return { ok: false, error: 'That file is no longer there.' }; }
58
+ if (stat.size > MAX_DOC_BYTES) {
59
+ return { ok: false, error: 'That file is too big to open here. It opened outside instead.', tooBig: true };
60
+ }
61
+ try {
62
+ return { ok: true, path: file, text: fs.readFileSync(file, 'utf8'), mtime: stat.mtimeMs };
63
+ } catch (err) {
64
+ return { ok: false, error: String(err?.message ?? err).slice(0, 200) };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Her edit, onto the file.
70
+ *
71
+ * `mtime` is what the pane believed was on disk when it loaded. If the file has
72
+ * moved on since — an agent rewrote it while she was typing — the write is
73
+ * refused rather than silently winning, and the pane says so. The old editor
74
+ * holds an incoming agent update off while she has unsaved edits, which is the
75
+ * same rule from the other end; here the file is the shared thing, so the check
76
+ * is on the file.
77
+ */
78
+ export function writeDoc({ file, product, text, mtime, fs = fsDefault }) {
79
+ const allowed = canWrite({ file, product, fs });
80
+ if (!allowed.ok) return allowed;
81
+ if (typeof text !== 'string') return { ok: false, error: 'Nothing to save.' };
82
+ if (mtime) {
83
+ let now = 0;
84
+ try { now = fs.statSync(file).mtimeMs; } catch { /* handled by canWrite */ }
85
+ // A second's slack: some filesystems round mtime, and our own write is what
86
+ // moved it last in the ordinary case.
87
+ if (now && now - mtime > 1000) {
88
+ return { ok: false, stale: true, error: 'Something else changed this file while you were typing, so nothing was saved.' };
89
+ }
90
+ }
91
+ try {
92
+ fs.writeFileSync(file, text, 'utf8');
93
+ let after = 0;
94
+ try { after = fs.statSync(file).mtimeMs; } catch { /* the write succeeded */ }
95
+ return { ok: true, mtime: after };
96
+ } catch (err) {
97
+ return { ok: false, error: String(err?.message ?? err).slice(0, 200) };
98
+ }
99
+ }
@@ -0,0 +1,126 @@
1
+ // THE ORIGIN THE DOCUMENT PANE SERVES A PAGE FROM, so its pictures load.
2
+ //
3
+ // She was reading one of our own design pages and every <img> in it was a
4
+ // broken placeholder with the alt text beside it.
5
+ //
6
+ // WHY THEY WERE BROKEN, measured before this was written. The pane drew the
7
+ // page in `<iframe sandbox="allow-scripts" src="file://…">`. A sandbox without
8
+ // allow-same-origin gives the frame an OPAQUE origin, and Chromium refuses to
9
+ // let an opaque origin pull a file:// subresource. The probe: the same image,
10
+ // same folder, same frame.
11
+ //
12
+ // sandbox="allow-scripts" naturalWidth 0
13
+ // console: "Not allowed to load local resource: file:///…/shot.png"
14
+ // sandbox="allow-scripts allow-same-origin" naturalWidth 2880
15
+ //
16
+ // So the one-line fix is to add allow-same-origin, AND THAT IS THE HOLE, not
17
+ // the fix. The app's own window is loaded with loadFile (main.mjs), so the app
18
+ // itself is a file:// page. A file:// frame that is allowed to be same-origin
19
+ // sits in the same origin as the app, and a page an agent wrote could then
20
+ // reach straight into her inbox. Chromium says as much out loud: "An iframe
21
+ // which has both allow-scripts and allow-same-origin for its sandbox attribute
22
+ // can escape its sandboxing."
23
+ //
24
+ // So the page gets an origin of its OWN instead. This scheme is registered as
25
+ // standard and secure, the pane points the frame at astral-doc://file/…, and
26
+ // the frame may keep allow-same-origin because "same origin" now means "the
27
+ // same as other documents", not "the same as Agentbox". Measured in the same
28
+ // probe, on this scheme:
29
+ //
30
+ // naturalWidth 2880, origin "astral-doc://file", parent.document -> BLOCKED
31
+ //
32
+ // WHAT MAY BE SERVED. A page can ask this scheme for any path it likes, so the
33
+ // answer cannot be "whatever is on disk". Grants below are the whole of it: the
34
+ // pane names a file when it opens one, and only that file's own folder and the
35
+ // product's own artifact roots become readable. Her real design pages do climb
36
+ // out of their own folder — 17 references across the 200 html files in this
37
+ // product point at ../attachments, ../landing and ../../landing/assets — which
38
+ // is why the roots are granted and not just the folder.
39
+ import path from 'node:path';
40
+ import { DOC_SCHEME, DOC_SCHEMES, isScheme } from '../shared/schemes.mjs';
41
+
42
+ // The name is read rather than typed, and every spelling this app has used is
43
+ // still answered, because a url on this scheme is STORED. shared/schemes.mjs
44
+ // carries that argument in full.
45
+ export { DOC_SCHEME, DOC_SCHEMES };
46
+
47
+ // A standard scheme needs a host, and this one has exactly one: every document
48
+ // is a full absolute path under it. Keeping a single host keeps a single
49
+ // origin, so a page and the picture beside it never look cross-origin to each
50
+ // other.
51
+ export const DOC_HOST = 'file';
52
+
53
+ /** The url the pane points its frame at, for a file already resolved on disk. */
54
+ export function docUrl(file) {
55
+ if (typeof file !== 'string' || !path.isAbsolute(file)) return null;
56
+ // Each segment on its own: encodeURIComponent would eat the separators, and
57
+ // encodeURI would leave '#' and '?' in a filename to be read as url syntax.
58
+ const parts = file.split('/').filter(Boolean).map(encodeURIComponent);
59
+ return `${DOC_SCHEME}://${DOC_HOST}/${parts.join('/')}`;
60
+ }
61
+
62
+ /** The file a request under this scheme is asking for, or null if it is not one. */
63
+ export function docPath(url) {
64
+ let parsed;
65
+ try { parsed = new URL(String(url)); } catch { return null; }
66
+ if (!isScheme(DOC_SCHEMES, parsed.protocol)) return null;
67
+ let file;
68
+ try { file = decodeURIComponent(parsed.pathname); } catch { return null; }
69
+ if (!file.startsWith('/')) return null;
70
+ // The url is the only thing between a page and the disk, so a climb written
71
+ // into it is normalised away here rather than trusted to the grant check.
72
+ const normal = path.normalize(file);
73
+ return path.isAbsolute(normal) ? normal : null;
74
+ }
75
+
76
+ /** Is `file` inside `dir`, without /a/bc counting as inside /a/b. */
77
+ function inside(dir, file) {
78
+ if (!dir || !file) return false;
79
+ const d = path.resolve(dir);
80
+ const f = path.resolve(file);
81
+ return f === d || f.startsWith(d + path.sep);
82
+ }
83
+
84
+ /**
85
+ * What the scheme is allowed to hand out.
86
+ *
87
+ * The pane opens one document at a time, so this stays small on purpose: a
88
+ * grant is made when a document is resolved for the pane and the oldest falls
89
+ * off. It is a plain object with no Electron in it so the rule can be tested
90
+ * for real rather than asserted against the source.
91
+ */
92
+ export class DocGrants {
93
+ constructor(limit = 8) {
94
+ this.limit = limit;
95
+ this.roots = [];
96
+ }
97
+
98
+ /** Open this file, and the folders that come with it, to the scheme. */
99
+ grant(file, roots = []) {
100
+ if (typeof file !== 'string' || !path.isAbsolute(file)) return;
101
+ const add = [path.dirname(file), ...roots.filter((r) => typeof r === 'string' && r)];
102
+ for (const root of add) {
103
+ const r = path.resolve(root);
104
+ if (this.roots.includes(r)) continue;
105
+ this.roots.push(r);
106
+ }
107
+ while (this.roots.length > this.limit) this.roots.shift();
108
+ }
109
+
110
+ /** May the scheme read this path. */
111
+ allows(file) {
112
+ if (typeof file !== 'string' || !path.isAbsolute(file)) return false;
113
+ return this.roots.some((root) => inside(root, file));
114
+ }
115
+ }
116
+
117
+ // A PAGE GOES OUT EXACTLY AS IT WAS WRITTEN, and that is deliberate.
118
+ //
119
+ // For a few hours on 2026-08-21 this file also injected a listener into every
120
+ // page it served, so a pinch with the cursor inside the document could reach
121
+ // the app and zoom the page on its own.
122
+ //
123
+ // So the document has no zoom of its own. The app zoom (main/menu.mjs,
124
+ // main/zoom-keys.mjs) scales the window, and the pane is an ordinary iframe in
125
+ // that window, so an open file follows it like everything else. Do not put a
126
+ // listener back into a served page: nothing is injected into a file she reads.