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/menu.mjs ADDED
@@ -0,0 +1,177 @@
1
+ // The application menu. The app is keyboard-first, so the menu's job is mostly
2
+ // to make the OS-level chords real: zoom, copy/paste, window management.
3
+ // Zoom targets the content the keystroke arrived on, which matters because an
4
+ // open document is a webview with its own zoom.
5
+
6
+ import { app, BrowserWindow, Menu, webContents } from 'electron';
7
+ import { zoomTarget } from './zoom-keys.mjs';
8
+ import { zoomPercentFor } from './zoom-readout.mjs';
9
+
10
+ // Approval hotkeys live in the MENU, not the renderer: a child web contents
11
+ // owns the keyboard whenever one is up, so a renderer listener never hears
12
+ // anything while she is inside one. Menu accelerators fire regardless of what
13
+ // has focus.
14
+
15
+ // TWO ROUTES TO ONE STEP. ⌘=, ⌘- and ⌘0 all did nothing. The menu accelerator
16
+ // was the only route, and this file already knows what that is worth: the note
17
+ // below ⌘R records that accelerators here "do not fire while a child web
18
+ // contents owns the keyboard", which is why ⌘R, ⌘Y and ⌘N were all moved into
19
+ // before-input-event in the main process. Zoom was left behind, on a chord she
20
+ // cannot work around, because Chromium persists the zoom level per page URL
21
+ // and hands it back after every reload.
22
+ //
23
+ // So the chords are now handled in main.mjs as well, and this stays for the
24
+ // menu's label and for a click. On macOS the two are exclusive (AppKit eats a
25
+ // key equivalent before the page can see it); the guard below is what keeps
26
+ // that an assumption rather than a load-bearing one, exactly like the reload
27
+ // debounce in main.mjs.
28
+ let lastZoomAt = 0;
29
+ let lastZoomDelta = null;
30
+
31
+ /**
32
+ * Move the zoom one step. `target` is the web contents the caller named: the
33
+ * one a keystroke arrived on, or the window a menu click was chosen from. When
34
+ * nobody names one it falls back to whatever is focused, and then to the app's
35
+ * own window. That last fallback is the fix for her second report: focus can be
36
+ * nowhere, and a target of null was a silent no-op.
37
+ */
38
+ export function applyZoom(delta, target = null) {
39
+ const now = Date.now();
40
+ if (delta === lastZoomDelta && now - lastZoomAt < 40) return;
41
+ lastZoomAt = now;
42
+ lastZoomDelta = delta;
43
+ const contents = zoomTarget({
44
+ named: target,
45
+ focused: webContents.getFocusedWebContents(),
46
+ appWindow: appWindowContents(),
47
+ });
48
+ if (!contents) return;
49
+ if (delta === 0) contents.setZoomLevel(0);
50
+ else contents.setZoomLevel(Math.max(-6, Math.min(6, contents.getZoomLevel() + delta)));
51
+ announceZoom(contents.getZoomLevel());
52
+ }
53
+
54
+ // The number is worked out here, where the level actually changes, and the
55
+ // page only draws what it is handed.
56
+ //
57
+ // It always goes to the APP window, even when the level that moved belongs to
58
+ // the junk browser's guest: the guest is a page inside her window, and a
59
+ // browser puts its own readout in its own corner rather than in the page.
60
+ function announceZoom(level) {
61
+ const percent = zoomPercentFor(level);
62
+ if (percent === null) return;
63
+ const target = appWindowContents();
64
+ if (!target || target.isDestroyed?.()) return;
65
+ target.send?.('zero:zoom-percent', { percent });
66
+ }
67
+
68
+ // ⌘F, ⌘G AND ⇧⌘G. The app finds its own words (renderer/src/find-in-page.ts
69
+ // says why it is not Chromium's findInPage), so all this does is tell the page
70
+ // which one was pressed: 0 opens the box, 1 and -1 walk the matches.
71
+ //
72
+ // Two routes again, the menu and before-input-event in main.mjs, for the reason
73
+ // zoom has two, and the same guard keeps one press from arriving twice.
74
+ let lastFindAt = 0;
75
+ let lastFindStep = null;
76
+
77
+ export function requestFind(step) {
78
+ const now = Date.now();
79
+ if (step === lastFindStep && now - lastFindAt < 40) return;
80
+ lastFindAt = now;
81
+ lastFindStep = step;
82
+ const target = appWindowContents();
83
+ if (!target || target.isDestroyed?.()) return;
84
+ target.send?.('zero:find', { step });
85
+ }
86
+
87
+ function appWindowContents() {
88
+ const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
89
+ return window && !window.isDestroyed() ? window.webContents : null;
90
+ }
91
+
92
+ export function installMenu({ onApproval, onReload, onZoom } = {}) {
93
+ // A CLICK NAMES ITS WINDOW.after the chords were fixed. Electron hands a menu
94
+ // click the window it was chosen from, so the click stops asking what is
95
+ // focused, which is the question that was coming back null. main.mjs decides
96
+ // the keystroke's target from where the event arrived: inside the junk
97
+ // browser it is the guest, which carries its own zoom.
98
+ const zoom = (delta, window) => {
99
+ const target = window && !window.isDestroyed() ? (window.webContents ?? null) : null;
100
+ return onZoom ? onZoom(delta, target) : applyZoom(delta, target);
101
+ };
102
+
103
+ const template = [
104
+ {
105
+ label: app.name,
106
+ submenu: [
107
+ { role: 'about' },
108
+ { type: 'separator' },
109
+ { role: 'hide' },
110
+ { role: 'hideOthers' },
111
+ { role: 'unhide' },
112
+ { type: 'separator' },
113
+ { role: 'quit' },
114
+ ],
115
+ },
116
+ {
117
+ label: 'Edit',
118
+ submenu: [
119
+ { role: 'undo' },
120
+ { role: 'redo' },
121
+ { type: 'separator' },
122
+ { role: 'cut' },
123
+ { role: 'copy' },
124
+ { role: 'paste' },
125
+ { role: 'selectAll' },
126
+ { type: 'separator' },
127
+ { label: 'Find…', accelerator: 'CommandOrControl+F', click: () => requestFind(0) },
128
+ { label: 'Find Next', accelerator: 'CommandOrControl+G', click: () => requestFind(1) },
129
+ { label: 'Find Previous', accelerator: 'Shift+CommandOrControl+G', click: () => requestFind(-1) },
130
+ ],
131
+ },
132
+ {
133
+ label: 'View',
134
+ submenu: [
135
+ { label: 'Zoom In', accelerator: 'CommandOrControl+=', click: (_item, window) => zoom(0.5, window) },
136
+ { label: 'Zoom In (numpad)', accelerator: 'CommandOrControl+Plus', click: (_item, window) => zoom(0.5, window), visible: false, acceleratorWorksWhenHidden: true },
137
+ { label: 'Zoom Out', accelerator: 'CommandOrControl+-', click: (_item, window) => zoom(-0.5, window) },
138
+ { label: 'Actual Size', accelerator: 'CommandOrControl+0', click: (_item, window) => zoom(0, window) },
139
+ { type: 'separator' },
140
+ // ⌘R PUTS THE BUILT RENDERER ON SCREEN, which is the only thing anyone
141
+ // ever presses it for here.
142
+ //
143
+ // The main document has exactly the same problem and it is worse
144
+ // there, because index.html names a CONTENT-HASHED bundle: one cached
145
+ // index.html hands back the old filename, so the old renderer loads
146
+ // and every change is invisible. ⇧⌘R was added as the workaround
147
+ // rather than the fix.
148
+ //
149
+ // Both chords now ignore the cache, and both go through the app's own
150
+ // window rather than "whatever is focused", so a webview holding focus
151
+ // cannot turn the key into a no-op either.
152
+ { label: 'Reload', accelerator: 'CommandOrControl+R', click: () => onReload?.() },
153
+ { label: 'Force Reload', accelerator: 'Shift+CommandOrControl+R', click: () => onReload?.() },
154
+ { role: 'toggleDevTools' },
155
+ { type: 'separator' },
156
+ { role: 'togglefullscreen' },
157
+ ],
158
+ },
159
+ {
160
+ label: 'Agents',
161
+ submenu: [
162
+ { label: 'Allow Pending Approval', accelerator: 'Command+Y', click: () => onApproval?.(true) },
163
+ { label: 'Deny Pending Approval', accelerator: 'Command+N', click: () => onApproval?.(false) },
164
+ ],
165
+ },
166
+ {
167
+ label: 'Window',
168
+ submenu: [
169
+ { role: 'minimize' },
170
+ { role: 'zoom' },
171
+ { type: 'separator' },
172
+ { role: 'front' },
173
+ ],
174
+ },
175
+ ];
176
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
177
+ }
@@ -0,0 +1,155 @@
1
+ // THE BANNER. Once while she is away, only while she is somewhere else, taken
2
+ // down the moment she comes back.
3
+ //
4
+ // The rule it obeys is in shared/notify-rules.mjs; this file is the live half:
5
+ // the window's focus, the machine's idle clock, and the lifetime of the one
6
+ // notification that is allowed to exist. Nothing decidable lives here, so that
7
+ // what the tests pin is the behaviour and not the wiring.
8
+ //
9
+ // Before this, the renderer called a bare `new Notification.show` per arrival,
10
+ // up to three per batch, with no idea whether she was looking at the app. tell
11
+ // me when I am elsewhere, and never when I am here.
12
+ //
13
+ // AND ONCE, NOT TWENTY TIMES. One at a time is not once. `spoke` below is the
14
+ // fix, and shared/notify-rules.mjs carries the rule.
15
+
16
+ import { atTheApp, banner, shouldSpeak } from '../shared/notify-rules.mjs';
17
+ import { NAME } from '../shared/product-name.mjs';
18
+
19
+ // A BURST IS ONE PIECE OF NEWS. Four workers finishing inside the same
20
+ // supervisor tick is the ordinary case, and it arrives as four pushes over a
21
+ // second or two. Waiting a beat before speaking turns that into one banner
22
+ // with one sound instead of four rewrites and four chimes.
23
+ const COALESCE_MS = 1500;
24
+
25
+ export function createNotifier({ window, Notification, powerMonitor, coalesceMs = COALESCE_MS }) {
26
+ // Everything she has not seen since she last had the window, keyed by id so
27
+ // a repeated push is idempotent. Insertion order is arrival order.
28
+ const unseen = new Map();
29
+ let live = null; // the one notification currently on screen, if any
30
+ let timer = null;
31
+ // HOW MANY TIMES SHE HAS BEEN INTERRUPTED SINCE SHE LAST HAD THE WINDOW.
32
+ // Both reset in seen, so a stretch away is the unit. shouldSpeak in
33
+ // shared/notify-rules.mjs is the rule these two feed; the short version is
34
+ // once, plus one more if an approval turns up afterwards.
35
+ let spoke = false;
36
+ let spokeAsk = false;
37
+
38
+ const idleMs = () => {
39
+ try { return powerMonitor.getSystemIdleTime() * 1000; } catch { return 0; }
40
+ };
41
+ const focused = () => {
42
+ try { return !!window && !window.isDestroyed() && window.isFocused(); } catch { return false; }
43
+ };
44
+
45
+ const takeDown = () => {
46
+ if (!live) return;
47
+ try { live.close(); } catch {}
48
+ live = null;
49
+ };
50
+
51
+ // She is looking at Agentbox, so everything in hand has been seen on the row
52
+ // it lives on. This is also why arrivals that land while she is here are
53
+ // dropped rather than banked: showing them later, when she steps away, would
54
+ // be telling her about work she already watched arrive.
55
+ const seen = () => {
56
+ unseen.clear();
57
+ if (timer) { clearTimeout(timer); timer = null; }
58
+ spoke = false;
59
+ spokeAsk = false;
60
+ takeDown();
61
+ };
62
+
63
+ const speak = () => {
64
+ timer = null;
65
+ if (atTheApp({ focused: focused(), idleMs: idleMs() })) return seen();
66
+ const items = [...unseen.values()];
67
+ // She has already been told once this stretch. Everything since is banked
68
+ // on its own row and she will have it the moment she is back; saying it out
69
+ // loud again is the twenty-chimes-in-a-meeting problem she asked us to fix.
70
+ if (!shouldSpeak({ spoke, spokeAsk, waiting: items })) return;
71
+ const say = banner(items);
72
+ if (!say) return;
73
+ takeDown();
74
+ let n;
75
+ try {
76
+ // No subtitle. macOS puts that field directly under the title in nearly
77
+ // the same type, and she read a lone project name there as the title
78
+ // bleeding over mid-render. The project is in the title now; see
79
+ // shared/notify-rules.mjs.
80
+ n = new Notification({
81
+ title: say.title,
82
+ body: say.body,
83
+ silent: false,
84
+ });
85
+ } catch { return; }
86
+ // A banner she can act on. Clicking it brings Agentbox forward and opens the
87
+ // row it was about, which is the difference between being told and having
88
+ // to go find it.
89
+ n.on('click', () => {
90
+ seen();
91
+ if (!window || window.isDestroyed()) return;
92
+ if (window.isMinimized()) window.restore();
93
+ window.show();
94
+ window.focus();
95
+ if (say.open) window.webContents.send('zero:open-item', { id: say.open });
96
+ });
97
+ n.on('close', () => { if (live === n) live = null; });
98
+ // A BANNER macOS REFUSED LOOKS EXACTLY LIKE NO AGENT FINISHING, which is
99
+ // the shape of failure this codebase keeps paying for. Measured on her
100
+ // machine 2026-08-20: across 107 apps registered with Notification Center,
101
+ // neither `ac.astral.app` nor `com.github. Electron` had a record at all, so
102
+ // there is no evidence a banner from here has ever been delivered. If the
103
+ // OS is dropping them, this line is what turns that from a silence into a
104
+ // two-minute diagnosis.
105
+ n.on('failed', (_e, err) => {
106
+ live = null;
107
+ console.warn(`zero: macOS refused the notification (${err}). Check ${NAME} in System Settings > Notifications.`);
108
+ });
109
+ live = n;
110
+ try {
111
+ n.show();
112
+ spoke = true;
113
+ if (items.some((i) => i.kind === 'approval')) spokeAsk = true;
114
+ } catch { live = null; }
115
+ };
116
+
117
+ return {
118
+ // Fresh arrivals from the renderer, which owns what counts as news (it is
119
+ // the only place that knows what the inbox is). Each is
120
+ // { id, title, product, productName, kind }.
121
+ add(arrivals) {
122
+ if (!Array.isArray(arrivals) || !arrivals.length) return;
123
+ // Asked before banking anything, so that a burst arriving while she is
124
+ // at the app is dropped rather than saved up for when she leaves.
125
+ if (atTheApp({ focused: focused(), idleMs: idleMs() })) return seen();
126
+ for (const a of arrivals) {
127
+ if (!a || !a.id || unseen.has(a.id)) continue;
128
+ unseen.set(a.id, a);
129
+ }
130
+ if (!unseen.size || timer) return;
131
+ timer = setTimeout(speak, coalesceMs);
132
+ },
133
+ // The window came forward. Whatever was on screen is now behind her.
134
+ seen,
135
+ // Tests, and nothing else.
136
+ _pending: () => [...unseen.values()],
137
+ _spoken: () => ({ spoke, spokeAsk }),
138
+ };
139
+ }
140
+
141
+ export function installNotifier({ app, window, Notification, powerMonitor, ipcMain }) {
142
+ if (Notification.isSupported && !Notification.isSupported()) {
143
+ return { add() {}, seen() {} };
144
+ }
145
+ const notifier = createNotifier({ window, Notification, powerMonitor });
146
+ window.on('focus', () => notifier.seen());
147
+ // Unlocking with Agentbox already the front app never fires 'focus' (it never
148
+ // lost it), so without this the banner that fired while the screen was
149
+ // locked would sit in Notification Center after she is back at the row.
150
+ try { powerMonitor.on('unlock-screen', () => { if (window.isFocused()) notifier.seen(); }); } catch {}
151
+ ipcMain.handle('zero:notify', (_e, payload = {}) => {
152
+ notifier.add(Array.isArray(payload.arrivals) ? payload.arrivals : []);
153
+ });
154
+ return notifier;
155
+ }
@@ -0,0 +1,8 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+ import path from 'node:path';
4
+ const run = promisify(execFile);
5
+ export async function openSourceFile(file, execute = run) {
6
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw Error('Resolve the source file before opening it.');
7
+ await execute('/usr/bin/open', ['-t', file], {timeout:10000});
8
+ }
@@ -0,0 +1,149 @@
1
+ // WHAT A PROJECT IS CALLED, AND THE MARK BESIDE THE NAME. Both of them hers to
2
+ // change, and both written into the project's OWN project.json rather than into
3
+ // her workspace config.
4
+ //
5
+ // WHY project.json AND NOT settings.json. Every other switch on the project
6
+ // screen is a preference about how Agentbox treats a folder, so it belongs to this
7
+ // Mac and lives in her config. A name and a mark are what the project IS. They
8
+ // travel with the folder: a project copied to another machine, or opened by the
9
+ // MCP server, or read by a worker session, is still called the same thing. The
10
+ // slug never moves, so nothing that points at a project by id breaks when the
11
+ // name changes.
12
+ //
13
+ // THE ICON IS COPIED IN, NOT POINTED AT, and that is the one place this departs
14
+ // from what she suggested.
15
+ //
16
+ // She is right that we cannot host it, and nothing here does: the file never
17
+ // leaves the Mac. But a pointer at wherever she found the picture is a pointer
18
+ // into her Downloads folder, and the mark in her rail then disappears the day she
19
+ // empties it. Copying the file into the project's own folder keeps every property
20
+ // she asked for (local, no server, no upload) and drops the one failure she did
21
+ // not ask for. It costs about 20KB per project.
22
+ //
23
+ // It also has to be copied for a second reason that only shows up later: the
24
+ // renderer cannot read an arbitrary path off her disk. Images already render
25
+ // through the product's own roots (`img-scheme.mjs`); a file inside the project
26
+ // folder is reachable by that machinery on the day it is written, and a file in
27
+ // ~/Downloads would need a new grant into a folder Agentbox has no business in.
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { patchProjectAt, loadProjectAt } from './store/project.mjs';
32
+ import { NAME } from '../shared/product-name.mjs';
33
+
34
+ /**
35
+ * What a project may be called. Long enough for a real product name, short
36
+ * enough that the rail and the settings nav still read as a list of names. */
37
+ export const NAME_MAX = 60;
38
+
39
+ // The picture kinds a mark can be. Deliberately short: these are the four that
40
+ // every Mac can produce, that Chromium draws without a plugin, and that survive
41
+ // being shrunk to a 24px burst-sized square. No svg, because an svg is a
42
+ // document with script in it and this one gets drawn inside her app.
43
+ export const ICON_KINDS = ['.png', '.jpg', '.jpeg', '.webp'];
44
+
45
+ // The one name a mark is ever stored under, plus its extension. One file per
46
+ // project, so choosing a new mark REPLACES the old rather than leaving her
47
+ // folder collecting every picture she ever tried.
48
+ const ICON_STEM = 'icon';
49
+
50
+ /**
51
+ * PURE. The name a project would end up with, or null when the rename should be
52
+ * refused.
53
+ *
54
+ * Refused rather than coerced when it is empty, because a nameless project is a
55
+ * blank row in her sidebar with no way back: the only handle left to rename it
56
+ * by is the one that just disappeared. Trimmed and collapsed otherwise, so a
57
+ * name pasted out of a document does not arrive with a newline in it and push
58
+ * every row below it down.
59
+ */
60
+ export function cleanName(value) {
61
+ const name = String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
62
+ return name || null;
63
+ }
64
+
65
+ /** PURE. Whether this file is a picture a mark can be made of. */
66
+ export function isIconKind(file) {
67
+ return ICON_KINDS.includes(path.extname(String(file ?? '')).toLowerCase());
68
+ }
69
+
70
+ /**
71
+ * Rename the project in the folder a caller has already resolved.
72
+ *
73
+ * `patchProjectAt` rather than `renameProject`, on the note that function itself
74
+ * carries: a write that re-derives its folder from an id lands wherever that id
75
+ * happens to name, and two products can hold the same id.
76
+ */
77
+ export function setProjectName(dir, value) {
78
+ const name = cleanName(value);
79
+ if (!name) throw new Error('a project needs a name');
80
+ if (!loadProjectAt(dir)) throw new Error('not a project folder');
81
+ patchProjectAt(dir, { name });
82
+ return name;
83
+ }
84
+
85
+ /**
86
+ * Copy a picture into the project folder and make it the mark.
87
+ *
88
+ * Returns the path the renderer should draw, or throws with a sentence she can
89
+ * read. `source` is whatever the open panel handed back, which is a real file on
90
+ * her disk and never anything typed.
91
+ *
92
+ * THE STORED VALUE IS RELATIVE. `icon.png`, not `/Users/.../agentbox/icon.png`,
93
+ * for the same reason the name lives here at all: a project.json that names an
94
+ * absolute path stops being true the moment the folder moves or is opened on
95
+ * another Mac. The caller joins it to the project's own dir, which it already
96
+ * has.
97
+ */
98
+ export function setProjectIcon(dir, source) {
99
+ if (!loadProjectAt(dir)) throw new Error('not a project folder');
100
+ if (!isIconKind(source)) throw new Error(`that file is not a picture ${NAME} can draw`);
101
+ let stat = null;
102
+ try { stat = fs.statSync(source); } catch { throw new Error('that file could not be read'); }
103
+ if (!stat.isFile()) throw new Error('that is not a file');
104
+
105
+ const ext = path.extname(source).toLowerCase();
106
+ const name = `${ICON_STEM}${ext}`;
107
+ // The old mark goes first, and every kind of it, or a project that had a .png
108
+ // and now has a .webp keeps both on disk and the stale one is what a future
109
+ // reader trips over.
110
+ clearIconFiles(dir);
111
+ fs.copyFileSync(source, path.join(dir, name));
112
+ patchProjectAt(dir, { logo: name });
113
+ return name;
114
+ }
115
+
116
+ /**
117
+ * Back to the drawn burst. The picture is deleted with the setting: leaving it
118
+ * behind means "no icon" and a file called icon.png sitting in her folder, which
119
+ * is the kind of thing that gets asked about a month later. */
120
+ export function clearProjectIcon(dir) {
121
+ if (!loadProjectAt(dir)) throw new Error('not a project folder');
122
+ clearIconFiles(dir);
123
+ patchProjectAt(dir, { logo: null });
124
+ return null;
125
+ }
126
+
127
+ function clearIconFiles(dir) {
128
+ for (const ext of ICON_KINDS) {
129
+ try { fs.rmSync(path.join(dir, `${ICON_STEM}${ext}`), { force: true }); } catch { /* nothing to remove */ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The mark's full path for a product the store has already listed, or null.
135
+ *
136
+ * CHECKED AGAINST DISK, not merely read off project.json. A `logo` that names a
137
+ * file which is no longer there would draw a broken image where the burst used
138
+ * to be, and the burst is a correct answer to "this project has no mark" while a
139
+ * broken image is not. This is the same rule the attachment chips follow: resolve
140
+ * before you promise.
141
+ */
142
+ export function iconPathFor(product) {
143
+ const rel = typeof product?.logo === 'string' ? product.logo.trim() : '';
144
+ // Only ever the one file this module writes. A project.json edited by hand to
145
+ // say `../../../somewhere` must not turn into a read outside the folder.
146
+ if (!rel || rel !== path.basename(rel) || !isIconKind(rel)) return null;
147
+ const full = path.join(product.dir, rel);
148
+ try { return fs.statSync(full).isFile() ? full : null; } catch { return null; }
149
+ }
@@ -0,0 +1,32 @@
1
+ // Small adapters over provider operations. The supervisor owns routing, locks,
2
+ // account selection and persistence; these operations never start a fake prompt.
3
+ import { NAME } from '../shared/product-name.mjs';
4
+ export async function runCodexCommand({server,threadId,cwd,name,args=''}) {
5
+ if(['skills','mcp','status','copy'].includes(name)&&args)throw new Error(`Run /${name} on its own. Your draft has been kept.`);
6
+ if(name==='goal') {
7
+ if(!args){const {goal}=await server.request('thread/goal/get',{threadId});return goal?`${goal.objective}\nStatus: ${goal.status}\nTokens used: ${goal.tokensUsed}`:'This conversation has no goal.';}
8
+ if(args==='clear'){await server.request('thread/goal/clear',{threadId});return 'Goal cleared.';}
9
+ if(args!=='pause')throw new Error(`Setting or resuming a Codex goal is not connected to ${NAME}’s worker lifecycle yet. Your draft has been kept.`);
10
+ await server.request('thread/goal/set',{threadId,status:'paused'});
11
+ return 'Goal paused.';
12
+ }
13
+ if(name==='skills') {
14
+ const result=await server.request('skills/list',{cwds:[cwd],forceReload:true});
15
+ return result.data?.flatMap(d=>[...(d.skills??[]).map(s=>`${s.name}${s.enabled===false?' (disabled)':''}: ${s.description??''}`),...(d.errors??[]).map(e=>`Could not read ${e.path}: ${e.message}`)]).join('\n')||'No skills found in this project.';
16
+ }
17
+ if(name==='mcp') {
18
+ const rows=[];let cursor;
19
+ do {const result=await server.request('mcpServerStatus/list',{threadId,...(cursor?{cursor}:{})});rows.push(...(result.data??[]));cursor=result.nextCursor;}while(cursor);
20
+ return rows.map(s=>`${s.name}: ${Object.keys(s.tools??{}).length} tools; authentication ${s.authStatus??'unknown'}`).join('\n')||'No MCP servers are connected to this conversation.';
21
+ }
22
+ if(name==='status'||name==='copy') {
23
+ const {thread}=await server.request('thread/read',{threadId,includeTurns:true});
24
+ if(name==='copy') {
25
+ const messages=(thread.turns??[]).filter(t=>t.status==='completed').flatMap(t=>t.items??[]).filter(i=>i.type==='agentMessage'&&i.text);
26
+ if(!messages.length)throw new Error('There is no assistant response to copy yet.');
27
+ return messages.at(-1).text;
28
+ }
29
+ return `Conversation: ${thread.id}\nStatus: ${thread.status?.type??'unknown'}\nFolder: ${thread.cwd??cwd}\nTurns: ${thread.turns?.length??0}`;
30
+ }
31
+ throw new Error(`/${name} is not available through ${NAME}’s Codex connection yet. Your draft has been kept.`);
32
+ }
@@ -0,0 +1,74 @@
1
+ // THE NOTE SHE KEEPS IN THE SIDEBAR, and the file it lives in.
2
+ //
3
+ // THE NOTE IS A REAL MARKDOWN FILE IN THE PRODUCT'S OWN FOLDER, `pinned.md`,
4
+ // and that is the whole design decision here. It could have been a row in some
5
+ // settings blob, which would have been fewer lines. It is a file because every
6
+ // agent on this product can already read the product's folder, so a priority
7
+ // she types into the sidebar is a priority a worker can be pointed at, and
8
+ // because a file survives this app being reinstalled, rebuilt, or replaced.
9
+ //
10
+ // It is NOT `notes.md`. That name is taken on at least one product by a map an
11
+ // agent wrote in round one, and loading somebody else's stale page into her
12
+ // sidebar the first time she opens it is the one failure this file could have
13
+ // that she would never think to report.
14
+ //
15
+ // ONE NOTE PER PRODUCT, because the panel it draws in is per product: the rail
16
+ // re-orients her on the thing she just opened, and a single note shared across
17
+ // twenty products would be answering a question about some other product, which
18
+ // is the exact fault the agents list had before it was narrowed (Rail.tsx).
19
+ //
20
+ // Pure of Electron on purpose, like doc-file.mjs beside it: the IPC layer hands
21
+ // in the product record, and these two functions are the whole of the disk
22
+ // access. The path is DERIVED from the product, never passed in, so there is no
23
+ // path to traverse and nothing to validate.
24
+ import fsDefault from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ export const NOTE_NAME = 'pinned.md';
28
+
29
+ // Far past anything a sidebar column can hold, and still small enough that a
30
+ // runaway paste cannot fill her disk. A note that hits this is refused rather
31
+ // than truncated: half her words written back is worse than none.
32
+ export const MAX_NOTE_BYTES = 200_000;
33
+
34
+ /** The file this product's sidebar note lives in, or null if it has no folder. */
35
+ export function notePath(prod) {
36
+ return prod?.dir ? path.join(prod.dir, NOTE_NAME) : null;
37
+ }
38
+
39
+ /**
40
+ * What she has written on this product. A note that has never been started is
41
+ * empty text, not an error: the sidebar draws its placeholder from that. */
42
+ export function readNote({ product, fs = fsDefault }) {
43
+ const file = notePath(product);
44
+ if (!file) return { ok: false, error: 'That project has no folder to keep a note in.' };
45
+ try {
46
+ const stat = fs.statSync(file);
47
+ if (stat.size > MAX_NOTE_BYTES) {
48
+ return { ok: false, error: 'That note is too long for the sidebar to open.' };
49
+ }
50
+ return { ok: true, text: fs.readFileSync(file, 'utf8'), mtime: stat.mtimeMs };
51
+ } catch (err) {
52
+ if (err?.code === 'ENOENT') return { ok: true, text: '', mtime: 0 };
53
+ return { ok: false, error: 'That note could not be read.' };
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Her note, written back. The file is CREATED on the first keystroke rather
59
+ * than at install time, so a product she never writes on never grows a file. */
60
+ export function writeNote({ product, text, fs = fsDefault }) {
61
+ const file = notePath(product);
62
+ if (!file) return { ok: false, error: 'That project has no folder to keep a note in.' };
63
+ const out = typeof text === 'string' ? text : '';
64
+ if (Buffer.byteLength(out, 'utf8') > MAX_NOTE_BYTES) {
65
+ return { ok: false, error: 'That note is too long to save.' };
66
+ }
67
+ try {
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, out, 'utf8');
70
+ return { ok: true, mtime: fs.statSync(file).mtimeMs };
71
+ } catch {
72
+ return { ok: false, error: 'That note could not be saved.' };
73
+ }
74
+ }