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/main.mjs ADDED
@@ -0,0 +1,972 @@
1
+ // The app: the agent inbox. Electron shell around a keyboard-first renderer, a
2
+ // file-derived store, and a supervisor of headless sessions.
3
+
4
+ import { app, BrowserWindow, Menu, Notification, clipboard, crashReporter, dialog, ipcMain, nativeImage, net, powerMonitor, protocol, screen as electronScreen, shell, session } from 'electron';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { configDir, loadConfig } from './config.mjs';
9
+ import { installMenu, applyZoom, requestFind } from './menu.mjs';
10
+ import { zoomDeltaFor } from './zoom-keys.mjs';
11
+ import { copySelection } from './copy-selection.mjs';
12
+ import { screenDetailFor } from './screen-detail.mjs';
13
+ import { Store } from './store.mjs';
14
+ import { storeRootEnv } from './store/home.mjs';
15
+ import { Supervisor } from './supervisor.mjs';
16
+ import { startCodexWatch } from './codex-watch.mjs';
17
+ import { registerIpc } from './ipc.mjs';
18
+ import { carryHerBriefsAcross, joinMessageRules } from './instruction-settings.mjs';
19
+ import * as approvals from './approvals.mjs';
20
+ import { recoveryToast } from '../shared/recovery.mjs';
21
+ import { dataFolderName, isNewUserBuild } from '../shared/side-build.mjs';
22
+ import { runningAsAFreshUser } from '../shared/fresh-user-home.mjs';
23
+ import { openFreshUser } from './fresh-user.mjs';
24
+ import { installCrashReports, reportFromRenderer, pending as pendingCrashes, setTransport } from './crash-report.mjs';
25
+ import { createAnalytics } from './analytics.mjs';
26
+ import { createUpdater } from './updater.mjs';
27
+ import { installNotifier } from './notify.mjs';
28
+ import { DOC_SCHEMES, DocGrants, docPath } from './doc-scheme.mjs';
29
+ import { IMG_SCHEMES, imgPath, servable } from './img-scheme.mjs';
30
+ import { hotWindowVerdict, storeHasWork } from './dev-window.mjs';
31
+ import { IS_SHE_TYPING_IN_THE_PAGE, theAppKeepsThisInput, theAppKeepsThisLetterInAPage } from '../shared/artifact-keys.mjs';
32
+ import { NAME, Name, WAS } from '../shared/product-name.mjs';
33
+
34
+ // HOW LONG TO WAIT AFTER THE LID OPENS, and the wait is for the network rather
35
+ // than for the wake. `powerMonitor`'s resume event fires while wifi is still
36
+ // coming back, and a session resumed into a dead network dies in about a
37
+ // second, which the supervisor reads as a fast exit: three of those strike the
38
+ // subscription and arm a fleet-wide spawn cooldown of up to thirty minutes. So
39
+ // the repair would take the fleet down with it.
40
+ const WAKE_SETTLE_MS = Number(process.env.ZERO_WAKE_SETTLE_MS ?? 8000);
41
+
42
+ // THE DOCUMENT PANE'S OWN ORIGIN. This has to be said before the app is ready
43
+ // or the scheme is not a standard one, and a page served under a non-standard
44
+ // scheme has no origin for its own pictures to be same-origin with. `secure`
45
+ // keeps it out of the mixed-content rules; the grants in doc-scheme.mjs are
46
+ // what decide the pane may read a given file.
47
+ //
48
+ // THE SECOND ONE IS WHERE THE APP'S OWN PICTURES COME FROM: a pasted thumbnail
49
+ // and every screenshot in a message. They used to be file:// urls, which a
50
+ // dev-served window may not load, so the app she uses all day drew a broken
51
+ // image for every one of them.
52
+ //
53
+ // EVERY SPELLING THIS APP HAS USED IS REGISTERED, not only today's. A url on
54
+ // either scheme is stored: a product's logo is kept in the store as one, and
55
+ // every picture in a message she was sent months ago is one. An unregistered
56
+ // scheme does not degrade, it fails to load, so the old names stay served.
57
+ protocol.registerSchemesAsPrivileged([...DOC_SCHEMES.map((scheme) => ({
58
+ scheme,
59
+ privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
60
+ })), ...IMG_SCHEMES.map((scheme) => ({
61
+ // `corsEnabled` IS WHAT LETS A COPIED MESSAGE CARRY THE PICTURE ITSELF.: a
62
+ // picture on this scheme taints a canvas like any other cross-origin image,
63
+ // so the copy handler in the window could not read one back to put on the
64
+ // clipboard, and a message copied into an email arrived with a dead url
65
+ // where the screenshot was. With this flag, the header the handler sends and
66
+ // `crossOrigin` on the <img>, a canvas reads it cleanly. MEASURED, all three
67
+ // needed: without the flag the same <img> never loads at all, and without
68
+ // `crossOrigin` the canvas throws SecurityError.
69
+ scheme,
70
+ privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true },
71
+ }))]);
72
+
73
+ // A local file answered so the window asks again next time instead of reusing
74
+ // what it drew before. Why, measured, is at the page scheme's handler below.
75
+ function noStore(res) {
76
+ const headers = new Headers(res.headers);
77
+ headers.set('cache-control', 'no-store');
78
+ return new Response(res.body, { status: res.status, headers });
79
+ }
80
+
81
+ // Which folders that scheme may read from. Filled in when the pane resolves a
82
+ // document (main/ipc.mjs) and empty until then, so nothing is readable until
83
+ // she opens something.
84
+ const docGrants = new DocGrants();
85
+
86
+ // The picture scheme's roots, as a function so it reads the live store. Nothing
87
+ // is servable until the window has been built, which is the same shape as the
88
+ // grants above: an empty list is a closed door, never an open one.
89
+ let imageRoots = () => [];
90
+
91
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
92
+ const appDir = path.join(__dirname, '..');
93
+
94
+ // A SIDE BUILD IS STILL AGENTBOX, AND IT HAS TO BE, OR TWO SUPERVISORS RUN.
95
+ // `scripts/pack-side.mjs` packs this branch as its own bundle, "the app Rest",
96
+ // so a build she is trying out for a few days sits beside the real one in
97
+ // Finder instead of overwriting it. The bundle is separate; the DATA FOLDER
98
+ // must not be, and shared/side-build.mjs is where that rule and its reasons
99
+ // live. This has to happen before ANYTHING reads a path, because everything
100
+ // downstream keys off app.getName, the single-instance lock at the foot of
101
+ // this file included.
102
+ if (dataFolderName(app.getName()) !== app.getName()) app.setName(dataFolderName(app.getName()));
103
+
104
+ // "ASTRAL NEW USER" IS A STRANGER EVERY TIME IT OPENS.
105
+ //
106
+ // So this build does to itself what the ⌘K row does to the real one: it opens a
107
+ // second copy inside a brand new throwaway home, with Claude Code and its login
108
+ // linked through and nothing else of hers, then gets out of the way. Every
109
+ // launch is a person who has never opened Agentbox, on a bundle whose
110
+ // permission history is its own (`ac.agentbox.newuser`), which is what makes
111
+ // the first-run permission panels testable at all. Quitting it and opening it
112
+ // again starts over from zero on purpose.
113
+ //
114
+ // BEFORE ANY PATH IS READ, like the rename above: the copy that hands over must
115
+ // never take a lock, write a crash folder or read a config in her real home.
116
+ if (isNewUserBuild(app.getName()) && !runningAsAFreshUser()) {
117
+ const opened = openFreshUser({ withAgents: true, packaged: app.isPackaged });
118
+ if (!opened.ok) dialog.showErrorBox(`${Name} new user could not start`, opened.error ?? 'No reason was given.');
119
+ app.exit(0);
120
+ }
121
+
122
+ // THE APP IS CALLED POWERUP NOW, AND CHROMIUM NAMES ITS DATA FOLDER AFTER THE
123
+ // APP. So the rename alone would have pointed this machine at an empty
124
+ // ~/Library/Application Support/Powerup and lost every renderer setting she has
125
+ // ever chosen: her look (theme and skin), her zoom, her project order. Nothing
126
+ // is moved here, because moving 1.3 GB of somebody's folder is a way to lose
127
+ // it. A copy that already has an older folder and no the app one keeps reading
128
+ // the older one, forever; a fresh install has neither and gets Powerup.
129
+ //
130
+ // THE LIST IS NEWEST FIRST AND IT GREW BY ONE ON 2026-09-04. This was written
131
+ // for the app -> the app and would have silently failed the second rename: every
132
+ // existing user's folder is called the app, not the app, so a bare the app check would
133
+ // have found nothing and handed them an empty Powerup. Whoever renames this app
134
+ // a third time adds one entry here and nothing else.
135
+ try {
136
+ const named = path.join(app.getPath('appData'), app.getName());
137
+ if (!fs.existsSync(named)) {
138
+ // THESE ARE HISTORY AND NOT COPY. They are the folder names this app has
139
+ // had, and a rename sweep that "helpfully" moves one of them to the current
140
+ // name deletes the only reason this loop exists. scripts/rename-to-powerup
141
+ // protects this line by name for exactly that reason; it rewrote it once.
142
+ for (const was of WAS) {
143
+ const legacy = path.join(app.getPath('appData'), was);
144
+ if (legacy !== named && fs.existsSync(legacy)) { app.setPath('userData', legacy); break; }
145
+ }
146
+ }
147
+ } catch {}
148
+
149
+ // WHERE THE APP'S OWN WRITABLE STATE LIVES. Running from source it is the
150
+ // checkout, which is what every session and every test already assumes. Inside
151
+ // a packaged .app it cannot be: the bundle is signed, read-only in practice,
152
+ // and a write into it breaks the signature that notarisation just bought. So
153
+ // zero.config.json moves to the user's data folder when packaged, and nothing
154
+ // else does — the briefs we ship, the worker permissions and the renderer are
155
+ // read-only and stay in the bundle.
156
+ //
157
+ // AND A THROWAWAY COPY GETS THE USER DATA FOLDER WHATEVER IT WAS STARTED FROM,
158
+ // because a throwaway home moves that folder and does not move the checkout.
159
+ // `configDir` in main/config.mjs is the whole rule and says what she saw when
160
+ // it was missing.
161
+ const dataDir = configDir({ appDir, userData: app.getPath('userData'), packaged: app.isPackaged });
162
+
163
+ // AND WHERE WHAT SHE TYPES LIVES, WHICH IS NEVER THE CHECKOUT (w-3dc46f3a67,
164
+ // 2026-09-21). Her standing instructions and her writing rules used to be
165
+ // `dataDir/briefs`, so on her own machine, running from source, they were files
166
+ // in the git repository the whole fleet works in. Her report: ⌘K could not find
167
+ // the box by the word she types, and when we went looking the file's entire
168
+ // history was four agent commits with nothing of hers in any of them. A rules
169
+ // file inside the folder every worker may `git checkout` is a rules file an
170
+ // agent can undo by accident, silently, and she has no way to see it happen.
171
+ //
172
+ // This is the same folder a packaged build already used, so there is now ONE
173
+ // place her instructions live whichever way the app was started, and the copy
174
+ // below carries the text there once. What we SHIP still comes from appDir: the
175
+ // worker brief and the finishing rules are read from her folder first and the
176
+ // bundle second, so a release's new default still reaches her unless she has
177
+ // written over it herself.
178
+ const userDir = app.getPath('userData');
179
+ try {
180
+ const carried = carryHerBriefsAcross(appDir, userDir);
181
+ if (carried.length) console.log(`zero: carried ${carried.join(', ')} into ${userDir}`);
182
+ // AND THE TWO OLD MESSAGE FILES BECOME ONE, ONCE (w-3dc46f3a67). It runs
183
+ // after the carry above, so it finds whatever that just moved, and before the
184
+ // supervisor exists, so nothing has read the new name yet.
185
+ const joined = joinMessageRules(appDir, userDir);
186
+ if (joined) console.log(`zero: joined the message rules into one file, ${joined.total} characters`);
187
+ } catch (err) {
188
+ console.warn(`zero: could not carry her instructions into ${userDir}: ${err.message}`);
189
+ }
190
+
191
+ // Before anything else can throw. A crash during boot is the one a stranger
192
+ // actually meets, and a reporter installed after the store and the supervisor
193
+ // have loaded is a reporter that cannot see it. It writes to disk here and
194
+ // NOTHING leaves yet: the transport is handed to it inside createWindow, once
195
+ // her config has been read and only if diagnostics are on and a key exists.
196
+ const crashDir = path.join(app.getPath('userData'), 'crash-reports');
197
+ installCrashReports({
198
+ app,
199
+ appDir,
200
+ crashReporter,
201
+ version: app.getVersion(),
202
+ dir: crashDir,
203
+ });
204
+
205
+ let window = null;
206
+ // Set by ⌘R and read once by the renderer as it comes back up, so a reload can
207
+ // say so. A reload that lands on an identical screen and a chord that did
208
+ // nothing at all look exactly alike, and telling them apart is the whole
209
+ // complaint (2026-08-06). The build time comes with it: if it has not moved,
210
+ // nobody rebuilt the renderer, which is the other way ⌘R "does nothing".
211
+ let reloadRequested = false;
212
+
213
+ function builtAt() {
214
+ try {
215
+ return fs.statSync(path.join(appDir, 'renderer', 'dist', 'index.html')).mtimeMs;
216
+ } catch {
217
+ return 0;
218
+ }
219
+ }
220
+
221
+ let lastReloadAt = 0;
222
+ // The wake sweep's one line, and whether there is a page to say it to yet.
223
+ let pendingRecovery = null;
224
+ let pageReady = false;
225
+
226
+ function reloadRenderer() {
227
+ // The chord can now arrive twice, from the menu accelerator and from
228
+ // before-input-event, depending on what holds focus. Reloading twice is
229
+ // harmless but reads as a flicker, so the second one is dropped.
230
+ const now = Date.now();
231
+ if (now - lastReloadAt < 500) return;
232
+ lastReloadAt = now;
233
+ // Logged because "⌘R does nothing" has now been reported twice, and a chord
234
+ // that never arrived is indistinguishable from one that reloaded an
235
+ // identical build unless something records that it arrived at all.
236
+ console.log(`zero: reload requested, renderer built ${new Date(builtAt()).toLocaleTimeString()}`);
237
+ if (!window || window.isDestroyed()) return;
238
+ reloadRequested = true;
239
+ window.webContents.reloadIgnoringCache();
240
+ }
241
+
242
+ async function createWindow() {
243
+ const config = loadConfig(dataDir);
244
+ // The store modules read the app's own home variable at call time; setting it
245
+ // here points auth session files, sync, and every store path at its root.
246
+ // Written under every name this app has had, because a store server or a
247
+ // script from before a rename is still out there reading the old one.
248
+ Object.assign(process.env, storeRootEnv(config.storeRoot));
249
+
250
+ // Menu needs the config (approval hotkeys answer the oldest pending card),
251
+ // so it installs here rather than at whenReady. The spool watcher in ipc
252
+ // pushes the change to the renderer; no direct renderer wiring needed.
253
+ //
254
+ // AND ITS ANSWER GOES THROUGH THE IPC LAYER'S ONE DOOR, LATE-BOUND.
255
+ //
256
+ // `installMenu` needs only the config, so it runs here; `registerIpc` needs
257
+ // the window, the store and the supervisor, so it runs two hundred lines
258
+ // below. That ordering is why these menu items used to write the answer FILE
259
+ // themselves with `approvals.answer`, and on a Codex card a file is not an
260
+ // answer at all: the thing waiting is Agentbox itself, in this process, so
261
+ // nothing on that path ever reads one. Her click on Allow landed as a
262
+ // FORGERY -- the file appeared for a request still waiting, `sweepForgery`
263
+ // deleted it inside two seconds, the log said "an answer file appeared for
264
+ // approval <id> that the founder did not write", her card came back and the
265
+ // worker was denied fifteen minutes later by the deadline. It also spent the
266
+ // one warning that is supposed to mean a worker attacked the spool.
267
+ //
268
+ // The variable is filled in the moment `registerIpc` returns, a few hundred
269
+ // lines down. Until then there is nothing to answer with and nothing that
270
+ // could have been pressed, and the stub says so rather than pretending.
271
+ let answerApproval = (id, allow) => {
272
+ console.warn(`zero: an approval (${id}) was answered ${allow ? 'yes' : 'no'} before the app had finished starting; nothing was decided`);
273
+ return false;
274
+ };
275
+ installMenu({
276
+ onApproval: (allow) => {
277
+ const oldest = approvals.listPending(config.storeRoot)[0];
278
+ if (oldest) answerApproval(oldest.id, allow);
279
+ },
280
+ onReload: reloadRenderer,
281
+ // A click in the View menu names the window it was chosen from, so it zooms
282
+ // the inbox rather than whatever happens to hold focus. If the click ever
283
+ // arrives without one, this window is the answer, never nothing.
284
+ onZoom: (delta, target) => applyZoom(delta, target ?? window?.webContents ?? null),
285
+ });
286
+ // ANALYTICS, AND EVERYTHING IT NEEDS TO BE HONEST. Off unless her switch is
287
+ // on AND a key is configured, so a copy built from source sends nothing. The
288
+ // crash reporter has been writing to disk since the top of this file; this is
289
+ // where it is given a way out, and where the reports from the launch that
290
+ // died are finally sent, because a report is written while the process is
291
+ // dying and delivery cannot be synchronous.
292
+ const analytics = createAnalytics({ config, version: app.getVersion(), dir: crashDir });
293
+ // The seam is filled whenever there is somewhere to send to. Whether anything
294
+ // actually goes is asked again inside, at every send, so her switch stops it
295
+ // from the moment she moves it rather than at the next launch.
296
+ if (analytics.hasDestination) setTransport((payload) => analytics.crashTransport(payload));
297
+ if (analytics.enabled) {
298
+ const drained = analytics.drainCrashes(pendingCrashes());
299
+ if (drained) console.log(`zero: sent ${drained} crash report(s) from a previous launch`);
300
+ analytics.track('app_opened');
301
+ } else {
302
+ console.log(`zero: diagnostics is ${analytics.reason}, nothing is sent`);
303
+ }
304
+
305
+ const store = await new Store(config).init();
306
+ // Where the window's own pictures may come from (main/img-scheme.mjs). The
307
+ // account root holds every product's docs, attachments, designs and the
308
+ // `.staging` folder a pasted screenshot lands in; a product's repo is where a
309
+ // worker writes `shots/…` and then names it on a card. Read fresh on every
310
+ // request so a project made after launch is not a folder of broken images.
311
+ imageRoots = () => [config.accountRoot, ...store.listProducts().map((p) => p.repoPath).filter(Boolean)];
312
+ const supervisor = new Supervisor(config, store, appDir, dataDir, userDir);
313
+ app.on('before-quit', () => supervisor.killAll());
314
+ // HER CODEX CONVERSATIONS ASK TO COME IN. Once a minute: a new one in a
315
+ // folder a project points at gets a row asking yes or no, and the ones she
316
+ // said yes to follow Codex's latest answer.
317
+ const codexWatch = startCodexWatch({ store });
318
+ app.on('before-quit', () => codexWatch.stop());
319
+ // Whatever is still queued goes with the app rather than dying in memory.
320
+ app.on('will-quit', () => { analytics.flush(); });
321
+
322
+ // Right-click. Electron ships no context menu at all, so links could not be
323
+ // copied or opened by hand anywhere in the app. Built per click from what is
324
+ // actually under the cursor. MUST register before the window exists:
325
+ // web-contents-created only fires for contents created after it (the first
326
+ // fix missed the main window for exactly that reason, 2026-08-04).
327
+ const openLink = (url) => {
328
+ if (url.startsWith('file://')) shell.openPath(decodeURIComponent(url.replace('file://', '')));
329
+ else shell.openExternal(url);
330
+ };
331
+
332
+ // A PICTURE GOES ON THE CLIPBOARD AS A PICTURE AND AS NOTHING ELSE.
333
+ //
334
+ // What her email showed was a raw `file:///Users/you/Zero/accounts/...` line
335
+ // where the picture should be.
336
+ //
337
+ // MEASURED, scripts/measure-copy-and-link-colour.mjs. `copyImageAt` alone
338
+ // leaves TWO flavours on the clipboard:
339
+ //
340
+ // formats ["text/html", "image/png"]
341
+ // text/html <img src="astral-img://file/tmp/one.png" alt="pasted-48629.png"/>
342
+ //
343
+ // Agentbox pastes the html and resolves that scheme, so inside the app it looks
344
+ // perfect. Gmail also prefers the html, and `astral-img://` means nothing
345
+ // outside this window, so it lands as a dead url. One copy, two answers, and
346
+ // that is the whole of the inconsistency she is describing.
347
+ //
348
+ // So the picture is read off disk and written as the only thing on the
349
+ // clipboard. `writeImage` clears what was there, which is what drops the html
350
+ // flavour and leaves every app one unambiguous thing to paste. Reading the
351
+ // file also skips `copyImageAt`'s round trip to the renderer, so there is no
352
+ // window in which the clipboard holds half an answer.
353
+ const copyPicture = (contents, params) => {
354
+ // The same two rules the scheme itself enforces (main/img-scheme.mjs): it is
355
+ // a picture, and it is inside a root the app owns. A context menu is not a
356
+ // reason to widen them.
357
+ const file = imgPath(params.srcURL);
358
+ if (file && servable(file, imageRoots())) {
359
+ const picture = nativeImage.createFromPath(file);
360
+ if (!picture.isEmpty()) { clipboard.writeImage(picture); return; }
361
+ }
362
+ // Anything else — an http picture, a data: uri, a canvas — is
363
+ // not ours to read off disk, so it goes the way it always did.
364
+ contents.copyImageAt(params.x, params.y);
365
+ };
366
+
367
+ // AND A FILE GOES ON THE CLIPBOARD AS THE FILE.
368
+ //
369
+ // She was right, and the picture fix above does not touch it. Until now the
370
+ // only file-shaped thing this menu could give her was "Copy Link Address" on
371
+ // a worker's `file://` link, which hands over
372
+ // `file:///Users/you/Zero/accounts/00000000-…/personas/one.md` — thirteen
373
+ // folders of her home directory, and the exact line her Gmail showed in the
374
+ // screenshot on this row. The chips under a message could not be copied at
375
+ // all: they are buttons, so the menu had nothing to offer.
376
+ //
377
+ // MEASURED on this Mac, scripts/measure-copy-and-link-colour.mjs: writing
378
+ // `public.file-url` puts a real file reference on the pasteboard —
379
+ // `osascript -e 'the clipboard as record'` reads back «class furl»:file
380
+ // Macintosh HD:… — which is what Finder, Mail and a browser's paste read as
381
+ // a file rather than as words. `writeBuffer` clears everything else, and
382
+ // that is wanted: the same rule the picture follows, one unambiguous thing
383
+ // to paste, because one copy leaving two answers is the whole of the bug on
384
+ // this row.
385
+ const copyFile = (file) => {
386
+ // The same rule the picture and the two schemes enforce: inside a root the
387
+ // app owns. A right click is not a reason to widen it.
388
+ if (!file || !servable(file, imageRoots())) return false;
389
+ try {
390
+ if (!fs.statSync(file).isFile()) return false;
391
+ clipboard.writeBuffer('public.file-url', Buffer.from(pathToFileURL(file).toString(), 'utf8'));
392
+ return true;
393
+ } catch { return false; }
394
+ };
395
+
396
+ // The path under her pointer, when what is under it is a chip rather than a
397
+ // link. `params` carries an href and an image src and nothing else, so the
398
+ // window is asked. Tiny, guarded, and answered before the menu is drawn.
399
+ const fileUnderPointer = async (contents, params) => {
400
+ if (params.linkURL.startsWith('file://')) {
401
+ try { return fileURLToPath(params.linkURL.split('#')[0]); } catch { return null; }
402
+ }
403
+ try {
404
+ const found = await contents.executeJavaScript(
405
+ `(() => { const el = document.elementFromPoint(${Number(params.x)}, ${Number(params.y)}); `
406
+ + `const hit = el && el.closest && el.closest('[data-copy-file]'); `
407
+ + `return hit ? hit.getAttribute('data-copy-file') : null; })()`, true);
408
+ return typeof found === 'string' && found.startsWith('/') ? found : null;
409
+ } catch { return null; }
410
+ };
411
+ app.on('web-contents-created', (_event, contents) => {
412
+ contents.on('context-menu', async (_e, params) => {
413
+ const items = [];
414
+ const file = await fileUnderPointer(contents, params);
415
+ // WHAT SHE SELECTED COMES FIRST, ABOVE ANYTHING ABOUT A LINK.
416
+ //
417
+ // — and what it had pasted was the single line
418
+ // `mailto:hello@example.com`.
419
+ //
420
+ // That is this menu's own doing. `params.linkURL` is set whenever the
421
+ // POINTER is over a link, whatever is selected, and the draft she was
422
+ // copying has `hello@example.com` in the middle of it. So the menu she
423
+ // got read "Open Link / Copy Link / --- / Copy": the first copy-shaped
424
+ // word in it copied the href and threw her selection away. She took it,
425
+ // and got the mailto.
426
+ //
427
+ // A selection is the more specific thing to have asked for and it now
428
+ // sits at the top. The link keeps its items, under it, and "Copy Link
429
+ // Address" says which of the two it is rather than leaving her to guess.
430
+ //
431
+ // AND IT COPIES FROM THE CONTENTS THIS MENU WAS RAISED ON, BY NAME.
432
+ //
433
+ // The founder, 2026-09-22 (w-b108b1d596): "Clicking copy doesnt seem to
434
+ // actually copy it (links)", with a picture of this menu open over a
435
+ // `mailto:` in a draft — "Copy / --- / Open Link / Copy Link Address",
436
+ // so both branches below were taken and she took the top item.
437
+ //
438
+ // This item used to be `{ role: 'copy' }`, and a role is not a call. Read
439
+ // out of the shipped Electron framework this session, lib/browser/api/
440
+ // menu-item-roles.ts:
441
+ //
442
+ // execute(role, window, contents) {
443
+ // if (!(hasRole(role) && (!isDarwin || roleList[role].nonNativeMacOSRole))) return false;
444
+ // ... if (webContentsMethod && contents != null) { webContentsMethod(contents); return true }
445
+ // }
446
+ //
447
+ // and `copy` carries no `nonNativeMacOSRole`, so on this Mac that guard
448
+ // fails on the first line and `webContents.copy()` is NEVER REACHED.
449
+ // What ships instead is a bare Cocoa `copy:` with no target, sent up the
450
+ // responder chain, holding no reference to the window she right-clicked
451
+ // in. Every other item in this menu carries its own closure over
452
+ // `params` and works whatever the responder chain is doing; "Copy" was
453
+ // the only one that did not, and "Copy" is the one she says does
454
+ // nothing. When that selector finds nobody, nothing is written and the
455
+ // clipboard keeps whatever it held, which is exactly what she describes.
456
+ //
457
+ // MEASURED, scripts/measure-native-copy-role.mjs: on the same window and
458
+ // the same selection, `Menu.sendActionToFirstResponder('copy:')` left a
459
+ // sentinel untouched on the clipboard while `contents.copy()` put the
460
+ // whole selection on it, both with a picture and with a `mailto:` under
461
+ // the pointer. The accelerator is kept so the item still reads "⌘C" the
462
+ // way her picture shows it, and `registerAccelerator: false` so drawing
463
+ // that word does not also claim the key.
464
+ if (!params.isEditable && params.selectionText.trim()) {
465
+ items.push(
466
+ // Through copySelection, because `contents.copy()` alone was measured
467
+ // landing nothing in the real app (main/copy-selection.mjs).
468
+ { label: 'Copy', accelerator: 'CommandOrControl+C', registerAccelerator: false, click: () => copySelection(contents, params.selectionText, clipboard) },
469
+ { type: 'separator' },
470
+ );
471
+ }
472
+ // A FILE COMES BEFORE ANYTHING ABOUT ITS ADDRESS, for the same reason the
473
+ // selection does: it is what she meant by "copy" and the address is not.
474
+ if (file && servable(file, imageRoots())) {
475
+ items.push({ label: 'Copy File', click: () => copyFile(file) }, { type: 'separator' });
476
+ }
477
+ if (params.linkURL) {
478
+ items.push(
479
+ { label: 'Open Link', click: () => openLink(params.linkURL) },
480
+ { label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) },
481
+ { type: 'separator' },
482
+ );
483
+ }
484
+ if (params.hasImageContents) {
485
+ items.push(
486
+ { label: 'Copy Image', click: () => copyPicture(contents, params) },
487
+ { type: 'separator' },
488
+ );
489
+ }
490
+ // The reply composer, and the same rule for the same reason: cut, copy,
491
+ // paste and select all are all native macOS roles, so all four were
492
+ // bare selectors up the responder chain. They are named calls on the
493
+ // contents she right-clicked in now, like everything else in this menu.
494
+ if (params.isEditable) {
495
+ items.push(
496
+ { label: 'Cut', accelerator: 'CommandOrControl+X', registerAccelerator: false, click: () => contents.cut() },
497
+ { label: 'Copy', accelerator: 'CommandOrControl+C', registerAccelerator: false, click: () => copySelection(contents, params.selectionText, clipboard) },
498
+ { label: 'Paste', accelerator: 'CommandOrControl+V', registerAccelerator: false, click: () => contents.paste() },
499
+ { label: 'Select All', accelerator: 'CommandOrControl+A', registerAccelerator: false, click: () => contents.selectAll() },
500
+ );
501
+ }
502
+ while (items.length && items[items.length - 1].type === 'separator') items.pop();
503
+ if (!items.length) return;
504
+ Menu.buildFromTemplate(items).popup({ window: BrowserWindow.fromWebContents(contents) ?? window });
505
+ });
506
+ });
507
+
508
+ window = new BrowserWindow({
509
+ width: 1440,
510
+ height: 900,
511
+ minWidth: 980,
512
+ minHeight: 600,
513
+ title: NAME,
514
+ backgroundColor: '#1a1a1c',
515
+ titleBarStyle: 'hiddenInset',
516
+ webPreferences: {
517
+ preload: path.join(appDir, 'preload.cjs'),
518
+ contextIsolation: true,
519
+ nodeIntegration: false,
520
+ webviewTag: true, // the inbox-zero browser
521
+ },
522
+ });
523
+
524
+ // WHICH PICTURE SET THIS WINDOW IS ON, and a shout when it changes.
525
+ // main/screen-detail.mjs has the rule and the reasoning; this is the wiring.
526
+ // The initial answer rides bootInfo rather than being pushed, for the same
527
+ // reason everything else there does: a push races the renderer's first
528
+ // effects and would be dropped by the page it is for. After that, the two
529
+ // things that can change the answer are the window moving to another screen
530
+ // and a screen changing under it, and both are watched here. Nothing is sent
531
+ // unless the answer actually flips, so dragging a window around does not
532
+ // spray IPC at the page.
533
+ const currentScreenDetail = () => {
534
+ try { return screenDetailFor(electronScreen.getDisplayMatching(window.getBounds())); }
535
+ catch { return 'sharp'; }
536
+ };
537
+ let lastScreenDetail = currentScreenDetail();
538
+ const tellScreenDetail = () => {
539
+ if (!window || window.isDestroyed()) return;
540
+ const next = currentScreenDetail();
541
+ if (next === lastScreenDetail) return;
542
+ lastScreenDetail = next;
543
+ window.webContents.send('zero:screen-detail', { detail: next });
544
+ };
545
+ window.on('move', tellScreenDetail);
546
+ window.on('moved', tellScreenDetail);
547
+ electronScreen.on('display-metrics-changed', tellScreenDetail);
548
+ electronScreen.on('display-added', tellScreenDetail);
549
+ electronScreen.on('display-removed', tellScreenDetail);
550
+ window.on('closed', () => {
551
+ electronScreen.removeListener('display-metrics-changed', tellScreenDetail);
552
+ electronScreen.removeListener('display-added', tellScreenDetail);
553
+ electronScreen.removeListener('display-removed', tellScreenDetail);
554
+ });
555
+
556
+ // The junk browser's session presents as plain Chrome. Electron's default
557
+ // UA carries "Electron/x Zero/x", which makes Google-family logins refuse
558
+ // even though a human is typing. This is identity cleanup for an embedded
559
+ // browser a human drives, not automation.
560
+ const junk = session.fromPartition('persist:junk');
561
+ junk.setUserAgent(junk.getUserAgent().replace(/\s?(zero|Electron)\/\S+/gi, ''));
562
+
563
+ // THE APP KEEPS ITSELF CURRENT (main/updater.mjs). It checks a few seconds
564
+ // from now, downloads anything newer in the background, and then waits: the
565
+ // restart is a button she presses, never something that happens under her.
566
+ // `pushUpdate` is filled in on the next line, because the updater has to
567
+ // exist before the IPC surface that reports it and the IPC surface is what
568
+ // knows how to reach the window.
569
+ let pushUpdate = () => {};
570
+ const updater = createUpdater({
571
+ app,
572
+ onChanged: () => pushUpdate(),
573
+ });
574
+
575
+ // The desktop door. `main/serve.mjs` is the other one and hands over plain
576
+ // Node stand-ins for these three; everything else about the call is the same.
577
+ const ipc = registerIpc({
578
+ store, supervisor, config, window, analytics, docGrants, updater,
579
+ host: { ipcMain, app, dialog },
580
+ });
581
+ pushUpdate = ipc.push;
582
+ // The Agents menu's Allow/Deny, bound to the real door now that there is one.
583
+ // See the `installMenu` call above for what writing the file instead did to a
584
+ // Codex card.
585
+ answerApproval = ipc.answerApproval;
586
+ updater.start();
587
+ app.on('before-quit', () => updater.stop());
588
+
589
+ // Cmd+Y / Cmd+N answer the oldest approval card from ANYWHERE, including
590
+ // while a child web contents owns the keyboard (menu accelerators turned out
591
+ // not to fire there, 2026-08-06). before-input-event runs in the main
592
+ // process ahead of any page dispatch, so nothing on the page can swallow
593
+ // it. These two chords, and nothing else, outrank whatever is in front (her
594
+ // rule).
595
+ window.webContents.on('before-input-event', (event, input) => {
596
+ if (input.type !== 'keyDown') return;
597
+ // ESCAPE HAS TO REACH THE APP FROM INSIDE AN OPEN FILE.
598
+ //
599
+ // She was inside the document pane, and escape is the app's one way back
600
+ // out of it.
601
+ //
602
+ // A keydown does not cross a document boundary. The page in the pane is its
603
+ // own document under its own origin (main/doc-scheme.mjs), so the moment her
604
+ // click lands inside it the app's own window listener never hears the key
605
+ // again. This runs below the page and hears it either way.
606
+ //
607
+ // IT ONLY FORWARDS FROM A FRAME, so the app's own escape is untouched: with
608
+ // the keyboard on the app's page the renderer's own handler is the only one
609
+ // that answers, exactly as before. Measured on Electron 43, 2026-08-21:
610
+ // `focusedFrame` reads the document's frame while the file has the keyboard
611
+ // and the main frame the moment the app takes it back. The renderer checks
612
+ // the same fact again from its own side (focusIsInTheFile, doc-pane.ts).
613
+ //
614
+ // ⌘K COMES UP THE SAME WAY, and for the same reason. Measured on the real
615
+ // renderer that day: with the keyboard inside a page in the pane, ⌘K was
616
+ // dead. Escape was the only key this branch had ever forwarded, so it was
617
+ // the only one that worked. The rule for which keys are the app's is
618
+ // written once, in shared/artifact-keys.mjs, and read here and by the pane.
619
+ //
620
+ // HER LETTERS COME UP TOO, FROM A PAGE ONLY.E is not ⌘K: measured that day,
621
+ // it archives the card perfectly well while her keyboard is on the message,
622
+ // with a file open beside it or without one. It dies only inside the
623
+ // artifact, and only in a PAGE is that wrong — in a markdown file and in a
624
+ // line of a change she is typing, and there a letter has to stay a letter.
625
+ // A page has nothing to type in, so nothing in it is text, and the single
626
+ // reason E was dead is that this list was Escape and chords.
627
+ //
628
+ // EXCEPT WHEN THE PAGE ITSELF IS TAKING TEXT. Her pages are her own html
629
+ // and some of them carry a search box or a field in a mockup (15 of the 406
630
+ // under this product, counted 08-24). Only the frame can see its own focus,
631
+ // so the frame is asked, and the answer comes back a few milliseconds later
632
+ // — which is fine, because nothing here ever preventDefaults and the page
633
+ // has already had the key regardless.
634
+ const aLetterFromAPage = !theAppKeepsThisInput(input) && theAppKeepsThisLetterInAPage(input);
635
+ if (theAppKeepsThisInput(input) || aLetterFromAPage) {
636
+ let theFrame = null;
637
+ try {
638
+ const frame = window.webContents.focusedFrame;
639
+ if (frame && frame !== window.webContents.mainFrame) theFrame = frame;
640
+ } catch { theFrame = null; /* an older Electron cannot say; leave it alone */ }
641
+ const sendItUp = () => window.webContents.send('zero:key-in-the-file', {
642
+ key: input.key, meta: !!input.meta, ctrl: !!input.control, alt: !!input.alt, shift: !!input.shift,
643
+ });
644
+ // Never preventDefault: the page under it may want the key too, and the
645
+ // renderer decides what escape MEANS, here and everywhere else.
646
+ if (theFrame && !aLetterFromAPage) sendItUp();
647
+ else if (theFrame) {
648
+ theFrame.executeJavaScript(IS_SHE_TYPING_IN_THE_PAGE, false)
649
+ .then((typing) => { if (!typing) sendItUp(); })
650
+ .catch(() => { /* the frame went away mid-press; the key is simply the page's */ });
651
+ }
652
+ // Escape stops here whatever happens; it is not one of the chords below.
653
+ // A chord falls THROUGH, because ⌘R, ⌘=, ⌘0, ⌘Y and ⌘N are answered
654
+ // further down this same handler and forwarding one must not take that
655
+ // away. The renderer answers only Escape, ⌘K and her letters
656
+ // (whatTheFileSentUp).
657
+ if (input.key === 'Escape') return;
658
+ }
659
+ if (!input.meta) return;
660
+ // ⌘=, ⌘- AND ⌘0 BELONG HERE FOR THE SAME REASON ⌘R DOES.They were menu
661
+ // accelerators and nothing else, and this handler exists because menu
662
+ // accelerators in this app have twice turned out not to arrive. Zoom is the
663
+ // worst chord to leave on that route: Chromium stores the level against the
664
+ // page's URL and restores it on every load, so a window that is zoomed
665
+ // stays zoomed through ⌘R, through a rebuild, and through a relaunch, with
666
+ // no way back.
667
+ const zoomDelta = zoomDeltaFor(input);
668
+ if (zoomDelta !== null) {
669
+ applyZoom(zoomDelta, window.webContents);
670
+ event.preventDefault();
671
+ return;
672
+ }
673
+ const key = String(input.key).toLowerCase();
674
+ // ⌘R BELONGS HERE FOR THE SAME REASON ⌘Y AND ⌘N DO. It was a menu
675
+ // accelerator and nothing else, and the note above records what this file
676
+ // already learned about those: they do not fire while a child web contents
677
+ // owns the keyboard, which an open document does the whole time it is up.
678
+ // She has reported ⌘R never working twice now, five days apart, and the
679
+ // first fix (2026-08-06) changed WHAT the menu item does without moving it
680
+ // off the accelerator that was not arriving. before-input-event runs in the
681
+ // main process ahead of any page dispatch, so nothing on the page can
682
+ // swallow it. Menu item stays, for discoverability and for the label.
683
+ if (key === 'r') {
684
+ reloadRenderer();
685
+ event.preventDefault();
686
+ return;
687
+ }
688
+ // ⌘F AND ⌘G, HERE FOR THE SAME REASON. Nothing in the app answered ⌘F at
689
+ // all until w-cc5bc203d0 (2026-09-26), and a menu accelerator alone would
690
+ // go dead the moment her click lands in an open file. The page decides what
691
+ // the press means; this only says which one it was.
692
+ if (key === 'f') {
693
+ requestFind(0);
694
+ event.preventDefault();
695
+ return;
696
+ }
697
+ if (key === 'g') {
698
+ requestFind(input.shift ? -1 : 1);
699
+ event.preventDefault();
700
+ return;
701
+ }
702
+ if (key !== 'y' && key !== 'n') return;
703
+ const oldest = approvals.listPending(config.storeRoot)[0];
704
+ if (!oldest) return;
705
+ // Through the ipc layer's own door, so the renderer is told what was
706
+ // decided and can acknowledge it. Answering the file directly from here is
707
+ // what made the chord feel like nothing had happened (2026-08-06).
708
+ ipc.answerApproval(oldest.id, key === 'y');
709
+ event.preventDefault(); // the page never sees the chord; no double-answer
710
+ });
711
+
712
+ // ---- END OF THE CHORD WIRING ----
713
+ //
714
+ // THIS LINE IS LOAD-BEARING: two tests slice main.mjs from the input handler
715
+ // above to these words to read the chord wiring on its own, so the words at
716
+ // the start of it are not free to change. It used to read "// The arcade.",
717
+ // because what stood here was the emulator, its save vault and the shelf that
718
+ // reached them. All of that is deleted; see decisions.md.
719
+
720
+ store.watch();
721
+ // ZERO_NO_SUPERVISOR: run the inbox without spawning any sessions (UI work,
722
+ // screenshots, cautious first runs).
723
+ if (!process.env.ZERO_NO_SUPERVISOR) {
724
+ // COMING BACK AFTER THE LID (her pick A on, her option 1 on). The app had no
725
+ // idea the machine could sleep: `powerMonitor` appeared nowhere in this
726
+ // process, and startup looked only for fresh work, never at the wreckage
727
+ // the last run left. Measured: 34 mass die-offs in 12 days, 134 sessions
728
+ // the app went away without reaping.
729
+ //
730
+ // The startup half lives inside supervisor.start, ahead of its first tick,
731
+ // because the tick is what would otherwise put a stranger on those rows.
732
+ // This is the other half: the lid.
733
+ supervisor.onRecovered = (r) => {
734
+ const line = recoveryToast(r);
735
+ console.log(`zero: ${r.reason} recovery — resumed ${r.resumed}, waiting on her ${r.waiting}, no slot yet ${r.queued}`);
736
+ if (!line) return;
737
+ // The startup sweep runs BEFORE the page exists, so its line is held
738
+ // rather than shouted at nobody: the renderer collects it on mount with
739
+ // the rest of its boot info. A wake sweep goes straight out.
740
+ pendingRecovery = line;
741
+ if (pageReady && window && !window.isDestroyed()) {
742
+ window.webContents.send('zero:recovered', { text: line });
743
+ pendingRecovery = null;
744
+ }
745
+ };
746
+ supervisor.start();
747
+ powerMonitor.on('resume', () => {
748
+ setTimeout(() => {
749
+ try { supervisor.recoverInterrupted('wake'); } catch (e) { console.warn('zero: wake recovery:', e.message); }
750
+ }, WAKE_SETTLE_MS);
751
+ });
752
+ }
753
+
754
+ // Unread badge + notification on new inbox arrivals are driven by the
755
+ // renderer (it owns the definition of "inbox"), through the badge API.
756
+ ipcMain.handle('zero:badge', (_e, count) => {
757
+ if (process.platform === 'darwin') app.dock.setBadge(count > 0 ? String(count) : '');
758
+ });
759
+ // WHETHER to speak, and what to say, is not the renderer's call: only this
760
+ // process can see whether the window has focus, and a page cannot be trusted
761
+ // to know she is looking at it (a webview owning the keyboard, another Space,
762
+ // a locked screen all read the same from in there). The renderer says what
763
+ // arrived; main/notify.mjs decides. tell me when I am in another app, never
764
+ // while I am in Agentbox.
765
+ installNotifier({ app, window, Notification, powerMonitor, ipcMain });
766
+ // Asked for once by the renderer as it mounts, rather than pushed on
767
+ // did-finish-load, which races the first effects and would drop the very
768
+ // message it exists to deliver.
769
+ ipcMain.handle('zero:boot-info', () => {
770
+ const info = {
771
+ reloaded: reloadRequested, builtAt: builtAt(), recovered: pendingRecovery,
772
+ screenDetail: currentScreenDetail(),
773
+ };
774
+ reloadRequested = false;
775
+ pendingRecovery = null;
776
+ return info;
777
+ });
778
+ // The window's own uncaught errors. The renderer cannot write a report
779
+ // itself (it has no disk and should not have one), and 'render-process-gone'
780
+ // only fires when the process DIES, which a thrown React error does not.
781
+ ipcMain.handle('zero:crash', (_e, payload) => { reportFromRenderer(payload ?? {}); });
782
+ // External links from markdown open in the real browser; local artifacts
783
+ // (a demo page, a screenshot a worker saved) open in their default app, so
784
+ // "see it working" is one click from the message.
785
+ window.webContents.setWindowOpenHandler(({ url }) => {
786
+ if (/^https?:/.test(url)) shell.openExternal(url);
787
+ else if (url.startsWith('file://')) shell.openPath(decodeURIComponent(url.replace('file://', '')));
788
+ return { action: 'deny' };
789
+ });
790
+
791
+ // NO PAGE IN AN EMBEDDED BROWSER STARTS SOUND BEFORE SHE HAS CLICKED IN IT.
792
+ //
793
+ // Electron's default is `no-user-gesture-required`, so any page may start
794
+ // media the moment it loads, and the rest browser reloads her last tab at
795
+ // every launch, out of sight. Measured 2026-09-10 on Electron 43 in a hidden
796
+ // webview on a YouTube watch page: default, the video ran from 3s to 15s;
797
+ // with this rule it sat paused at 0 for all 16s. A click anywhere in the page
798
+ // is activation, so pressing play, or picking the next video inside YouTube,
799
+ // works as before. Every webview this window attaches gets it, the junk
800
+ // browser included, which is what "never" asks for.
801
+ window.webContents.on('will-attach-webview', (_e, webPreferences) => {
802
+ webPreferences.autoplayPolicy = 'document-user-activation-required';
803
+ });
804
+
805
+ // Escape must ALWAYS leave the junk browser, even when the guest page has
806
+ // focus and would otherwise swallow the key. Intercept it below the page.
807
+ app.on('web-contents-created', (_event, contents) => {
808
+ if (contents.getType() !== 'webview') return;
809
+ contents.on('before-input-event', (e, input) => {
810
+ if (input.type === 'keyDown' && input.key === 'Escape' && window && !window.isDestroyed()) {
811
+ window.webContents.send('zero:escape-browser');
812
+ window.webContents.focus();
813
+ return;
814
+ }
815
+ // The guest page owns the keyboard while she is in the junk browser, so
816
+ // the window's handler never hears the zoom chord. Zoom the guest, not
817
+ // the inbox: a webview carries its own level.
818
+ const delta = zoomDeltaFor(input);
819
+ if (delta !== null) {
820
+ applyZoom(delta, contents);
821
+ e.preventDefault();
822
+ }
823
+ });
824
+ });
825
+
826
+ // ZERO_DEBUG_CONSOLE: surface renderer and iframe console output in the
827
+ // terminal, which is the only way to see inside a child frame on an
828
+ // unattended run.
829
+ if (process.env.ZERO_DEBUG_CONSOLE) {
830
+ app.on('web-contents-created', (_e, contents) => {
831
+ contents.on('console-message', (_ev, level, message, line, source) => {
832
+ console.log(`[renderer:${level}] ${message} (${String(source).split('/').pop()}:${line})`);
833
+ });
834
+ });
835
+ window.webContents.on('console-message', (_ev, level, message, line, source) => {
836
+ console.log(`[renderer:${level}] ${message} (${String(source).split('/').pop()}:${line})`);
837
+ });
838
+ }
839
+
840
+ const devUrl = process.env.ZERO_DEV_URL;
841
+ const parts = [];
842
+ if (process.env.ZERO_FIXTURES) parts.push(`fixtures=${process.env.ZERO_FIXTURES === 'empty' ? 'empty' : '1'}`);
843
+ if (process.env.ZERO_FOCUS) parts.push('focus=1');
844
+ const search = parts.length ? parts.join('&') : undefined;
845
+ // A HOT-RELOADING WINDOW MUST NOT BE THE ONE SHE WORKS IN. Every dev window
846
+ // that can ever exist comes through here, which is why the guard is here and
847
+ // not in the npm script: the one that cost her an afternoon was started by
848
+ // hand. See main/dev-window.mjs for what it allows and why.
849
+ const verdict = hotWindowVerdict({
850
+ devUrl,
851
+ fixtures: !!process.env.ZERO_FIXTURES,
852
+ forced: process.env.ZERO_DEV_ON_REAL_STORE === '1',
853
+ storeRoot: config.storeRoot,
854
+ storeHasWork: storeHasWork(fs, path, config.storeRoot),
855
+ });
856
+ const built = path.join(appDir, 'renderer', 'dist', 'index.html');
857
+ if (devUrl && !verdict.allow) {
858
+ console.error(verdict.message);
859
+ // AND IT STILL OPENS A WINDOW. Refusing the dev server and then showing
860
+ // nothing would trade her bug for a dead app. The built renderer is the
861
+ // right answer when there is one; when there is not, the dev server is
862
+ // better than a blank screen and the line above already said so.
863
+ if (fs.existsSync(built)) await window.loadFile(built, search ? { search } : undefined);
864
+ else {
865
+ console.error(' There is no built renderer to fall back to, so the dev server it is. Run `npm run build`.\n');
866
+ await window.loadURL(search ? `${devUrl}?${search}` : devUrl);
867
+ }
868
+ } else if (devUrl) {
869
+ await window.loadURL(search ? `${devUrl}?${search}` : devUrl);
870
+ } else {
871
+ await window.loadFile(built, search ? { search } : undefined);
872
+ }
873
+ pageReady = true;
874
+
875
+ // Dev aid: ZERO_SCREENSHOT=path captures the window after load and quits,
876
+ // so the UI can be verified without a human at the screen.
877
+ const shotPath = process.env.ZERO_SCREENSHOT;
878
+ if (shotPath) {
879
+ console.log(`loaded: ${window.webContents.getURL()}`);
880
+ setTimeout(async () => {
881
+ try {
882
+ if (process.env.ZERO_DEBUG_CONSOLE) {
883
+ const dom = await window.webContents.executeJavaScript(
884
+ 'JSON.stringify({ text: document.body.innerText.slice(0, 240), classes: [...document.querySelectorAll("[class]")].slice(0, 12).map(e => e.className) })',
885
+ );
886
+ console.log(`dom: ${dom}`);
887
+ }
888
+ const image = await window.webContents.capturePage();
889
+ const fs = await import('node:fs');
890
+ fs.writeFileSync(shotPath, image.toPNG());
891
+ console.log(`screenshot: ${shotPath}`);
892
+ } catch (err) {
893
+ console.error('screenshot failed:', err.message);
894
+ }
895
+ app.quit();
896
+ }, Number(process.env.ZERO_SCREENSHOT_DELAY_MS ?? 2500));
897
+ }
898
+
899
+ window.on('closed', () => {
900
+ supervisor.stop();
901
+ store.unwatch();
902
+ window = null;
903
+ });
904
+ }
905
+
906
+ // ONE the app, ever. Two instances mean two supervisors, and two supervisors
907
+ // spawn twin workers for every answered item (observed 2026-08-04: nine items
908
+ // double-spawned one second apart, four agents told the founder the same
909
+ // thing). A second launch just focuses the first.
910
+ if (!app.requestSingleInstanceLock()) {
911
+ app.quit();
912
+ } else {
913
+ app.on('second-instance', () => {
914
+ if (window) {
915
+ if (window.isMinimized()) window.restore();
916
+ window.focus();
917
+ }
918
+ });
919
+ app.whenReady().then(() => {
920
+ // Serve a granted file, and nothing else. A page under this scheme is one
921
+ // of ours, but it is written by an agent and read by her, so the check is
922
+ // on every request rather than only on the one the pane asked for.
923
+ // On every spelling this app has used, because a stored url carries the
924
+ // name the app had the day it was written.
925
+ for (const scheme of DOC_SCHEMES) protocol.handle(scheme, async (request) => {
926
+ const file = docPath(request.url);
927
+ if (!file || !docGrants.allows(file)) return new Response('Not available.', { status: 403 });
928
+ try {
929
+ // The file is streamed exactly as it is on disk, pictures and all.
930
+ // Nothing is injected into a page any more; see doc-scheme.mjs for the
931
+ // pinch zoom that briefly lived here and why she turned it down.
932
+ //
933
+ // NO-STORE, OR A REDRAWN PICTURE NEVER REACHES HER. Hers, w-3fc39983be,
934
+ // 2026-09-28: "the font in the images is STILL not changing. Seems like
935
+ // a bug". It was. An agent redrew six pictures under the same names;
936
+ // the page's html came back new and every <img> in it came back from
937
+ // the window's memory, round one's pictures under round two's captions,
938
+ // until a restart. Measured in a hidden probe of this exact setup
939
+ // (file:// window, sandboxed frame, this scheme, net.fetch): picture
940
+ // replaced on disk 2250 -> 3118 wide, page reopened, frame drew 2250.
941
+ // With this header it drew 3118. These are local files; there is no
942
+ // download to save by caching them.
943
+ return noStore(await net.fetch(pathToFileURL(file).toString()));
944
+ } catch {
945
+ return new Response('Not there.', { status: 404 });
946
+ }
947
+ });
948
+ // And her own pictures, on their own scheme, so the window can draw them
949
+ // whichever origin it was loaded from (main/img-scheme.mjs). Two rules and
950
+ // both have to pass: it is a picture, and it is inside a root the app owns.
951
+ for (const scheme of IMG_SCHEMES) protocol.handle(scheme, async (request) => {
952
+ const file = imgPath(request.url);
953
+ if (!file || !servable(file, imageRoots())) return new Response('Not available.', { status: 403 });
954
+ try {
955
+ const res = await net.fetch(pathToFileURL(file).toString());
956
+ // The window is allowed to read its own pictures back out, which is
957
+ // what a copy needs. This widens nothing: the two rules above already
958
+ // decided this file may be seen at all, and only this window can ask on
959
+ // this scheme.
960
+ // No-store here too, for the same reason as the page scheme above: a
961
+ // screenshot an agent retakes under the same name is still a new picture.
962
+ const out = noStore(res);
963
+ out.headers.set('access-control-allow-origin', '*');
964
+ return out;
965
+ } catch {
966
+ return new Response('Not there.', { status: 404 });
967
+ }
968
+ });
969
+ createWindow();
970
+ });
971
+ app.on('window-all-closed', () => app.quit());
972
+ }