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/ipc.mjs ADDED
@@ -0,0 +1,1558 @@
1
+ import { openSourceFile } from './open-source-file.mjs';
2
+ import {submitReply} from './live-replies.mjs';
3
+ // IPC: a thin, typed-by-convention surface. The renderer asks; the main
4
+ // process derives from files and answers. Pushes go one way: "state changed,
5
+ // refetch." No state is cached on either side of the bridge.
6
+
7
+ // NOTHING IN THIS FILE IMPORTS ELECTRON, AND THAT IS WHAT THE `host` ARGUMENT
8
+ // BELOW IS FOR.
9
+ //
10
+ // This is the contract between the screen and the half of the app that does
11
+ // things: 78 channels, nine lines of code each, nearly all of them wrappers
12
+ // over modules that are ordinary Node. Only three Electron things were ever
13
+ // reached for in here, `ipcMain` to register a channel, `dialog` for the
14
+ // folder picker, and `app` for two flags and the quit hook. They arrive as
15
+ // arguments now.
16
+ //
17
+ // What that buys is a second front door. `main/main.mjs` hands over the real
18
+ // Electron three and nothing about the desktop app changes. A plain Node
19
+ // process can hand over its own, and the same 78 channels then answer a
20
+ // browser over http without one line of this file being written twice.
21
+ import {terminalSenderAllowed} from './terminal-access.mjs';
22
+ import {TaskTerminals,terminalPlace} from './task-terminals.mjs';
23
+ import {SETTINGS_TERMINAL} from '../shared/settings-terminal.mjs';
24
+ import {readInstruction,writeInstruction,listVersions,readVersion} from './instruction-settings.mjs';
25
+ import path from 'node:path';
26
+ import os from 'node:os';
27
+ import fsWatch from 'node:fs';
28
+ import { listFolders } from './folders.mjs';
29
+ import * as approvals from './approvals.mjs';
30
+ import { restartNeeded } from './staleness.mjs';
31
+ import { artifactRoots, resolveArtifact } from './artifact-path.mjs';
32
+ import { docUrl } from './doc-scheme.mjs';
33
+ import { readDoc, writeDoc } from './doc-file.mjs';
34
+ import { readChange } from './code-change.mjs';
35
+ import { findFile, readCodeFile, writeCodeFile } from './code-file.mjs';
36
+ import { readNote, writeNote } from './rail-note.mjs';
37
+ import { addClaudeAccount, addCodexAccount, outsideAgentsMode, permissionMode, readSettings, recheckClaude, recheckCodex, setProjectSetting, setWorkspaceSetting } from './settings.mjs';
38
+ import { ICON_KINDS, clearProjectIcon, setProjectIcon, setProjectName } from './project-identity.mjs';
39
+ import { ClaudeUsage } from './claude-usage.mjs';
40
+ import { usageEngine } from '../shared/usage.mjs';
41
+ import { DEFAULT_ENGINE } from '../shared/engines.mjs';
42
+ import * as agents from './agents.mjs';
43
+ import { findAgentFolders, readAgentFiles, readFolderAgents } from './agent-files.mjs';
44
+ import { readSessionThreads } from './agent-sessions.mjs';
45
+ import { readCodexThreads } from './codex-threads.mjs';
46
+ import { codexIdOf, importChoice, isCodexImportRow, isNotImportedRow } from '../shared/codex-import.mjs';
47
+ import { checkProjectFolder } from '../shared/project-folder-check.mjs';
48
+ import { openFreshUser } from './fresh-user.mjs';
49
+ import { openDemo } from './demo.mjs';
50
+ import { AgentSchedule } from './agent-schedule.mjs';
51
+ import { agentKey } from '../shared/agents.mjs';
52
+ import { machineryPath } from './store/home.mjs';
53
+ import { EVENT_NAMES } from '../shared/analytics-events.mjs';
54
+ import { Name } from '../shared/product-name.mjs';
55
+
56
+ // WHAT A WORKER IS TOLD WHEN HER DECISION COULD NOT BE RECORDED.
57
+ const SPOOL_REFUSED = `${Name} could not record the founder's decision, so nothing was allowed. Finish what your grants cover and file the rest as a review.`;
58
+
59
+ // AND WHAT A CODEX WORKER IS TOLD WHEN THE CARD SHE READ IS NOT THE QUESTION IT
60
+ // IS PARKED ON. The request file lives in the spool a Claude Code worker is
61
+ // granted, so a card can be rewritten under its own id between being drawn and
62
+ // being answered; her Allow was for the other one. Same reading as above, for
63
+ // the same reason: this is a no.
64
+ const CARD_CHANGED = 'The approval request changed between being drawn and being answered, so the founder\'s decision was for a different action and nothing was allowed. Finish what your grants cover and file the rest as a review with the exact commands.';
65
+
66
+ // WHAT COUNTS AS SENT WHEN NOTHING IS. Analytics is optional here on purpose:
67
+ // every test and every copy without a key gets an object that answers no to
68
+ // everything, so no call site has to ask whether it exists.
69
+ const NO_ANALYTICS = { enabled: false, track: () => false };
70
+
71
+ // A copy with no updater wired in: the tests, and any host that constructs the
72
+ // IPC surface by hand. It reports the state a build from source really is in
73
+ // rather than a cheerful "up to date" nobody checked.
74
+ const NO_UPDATER = {
75
+ state: () => ({
76
+ phase: 'unsupported',
77
+ currentVersion: null,
78
+ newVersion: null,
79
+ percent: null,
80
+ error: `${Name} only updates itself when it is installed as an app.`,
81
+ checkedAt: null,
82
+ ready: false,
83
+ }),
84
+ check: async () => NO_UPDATER.state(),
85
+ install: () => false,
86
+ };
87
+
88
+ export function registerIpc({ store, supervisor, config, window, analytics = NO_ANALYTICS, docGrants = null, updater = NO_UPDATER, host }) {
89
+ // The three Electron things, or a plain Node stand-in for them. There is no
90
+ // default: a caller that forgets says so here rather than throwing eighty
91
+ // lines further down on `ipcMain.handle` of undefined.
92
+ if (!host?.ipcMain || !host?.app || !host?.dialog) {
93
+ throw new Error('registerIpc needs host: { ipcMain, app, dialog }');
94
+ }
95
+ const { ipcMain, app, dialog } = host;
96
+ // Coalesced: the supervisor fires onChange per worker stream line, which is
97
+ // every second or two per running session. Each push makes the renderer
98
+ // refetch the whole snapshot, so unthrottled pushes had the UI visibly
99
+ // re-rendering (flashing images, shifting layout) the entire time any
100
+ // worker was streaming. One push per window is plenty for a live tail.
101
+ let pushTimer = null;
102
+ const push = () => {
103
+ if (pushTimer) return;
104
+ pushTimer = setTimeout(() => {
105
+ pushTimer = null;
106
+ if (!window.isDestroyed()) window.webContents.send('zero:changed');
107
+ }, 400);
108
+ };
109
+ store.onChange = push;
110
+ supervisor.onChange = push;
111
+
112
+ // A worker mid-approval is frozen until the founder answers, so the spool
113
+ // pushes like the store does.
114
+ try {
115
+ const spool = approvals.spoolDir(config.storeRoot);
116
+ fsWatch.mkdirSync(spool, { recursive: true });
117
+ fsWatch.watch(spool, () => push());
118
+ } catch {}
119
+
120
+ // THE ONE PLACE AN APPROVAL IS ANSWERED, and there are three ways to reach
121
+ // it: the buttons on the card, the ⌘Y/⌘N chord (which main catches itself,
122
+ // ahead of the page, so it outranks anything holding the keyboard) and
123
+ // Allow/Deny Pending Approval in the Agents menu. All three go through here
124
+ // so all three tell the renderer what was just decided: without that, the
125
+ // card only ever "disappeared" on the next store push and nothing on screen
126
+ // acknowledged the press (the founder, 2026-08-06). The card's exit is drawn
127
+ // from this.
128
+ //
129
+ // THE MENU WAS NOT ONE OF THE THREE UNTIL NOW, AND THIS PARAGRAPH SAID IT
130
+ // WAS. It wrote `<id>.answer.json` itself, which is a real answer to a Claude
131
+ // Code worker in another process and NO answer at all to a Codex worker
132
+ // waiting on a promise in this one -- so her own Allow appeared on disk for a
133
+ // request still waiting, which is the definition of a forgery on that path,
134
+ // and `sweepForgery` deleted it two seconds later while logging that the
135
+ // founder had not written it. `installMenu` runs before this function exists,
136
+ // which is why it did that; main/main.mjs late-binds the door instead, and
137
+ // tests/the-menu-answers-a-card-through-the-one-door.test.mjs holds it.
138
+ //
139
+ // AND IT ANSWERS BOTH ENGINES, in two ways rather than one. A Claude Code
140
+ // worker waits on the answer FILE, in another process, so the file is the
141
+ // whole of its answer. A Codex worker waits on a promise inside THIS process
142
+ // and is told directly: a worker that can write into the store root could
143
+ // otherwise write itself an approval, and on this engine there is a channel
144
+ // that cannot be forged, so that is the one its decision travels down
145
+ // (main/codex-approvals.mjs). The file is still written either way, because
146
+ // it is what takes the card out of `listPending` and what the audit line is
147
+ // made of.
148
+ //
149
+ // AND AN ANSWER THAT COULD NOT BE WRITTEN IS A DENY, NOT A SILENCE
150
+ // (2026-09-04). `approvals.answer` is hardened now -- it will not write
151
+ // through a link a worker planted at `<id>.answer.json`, and it removes
152
+ // whatever is squatting the name instead -- so the only way it can still come
153
+ // back false is a request that is no longer there (she answered it already)
154
+ // or a spool that will not take a write at all. That second case used to
155
+ // THROW, out of an ipc handler and out of the ⌘Y `before-input-event`
156
+ // listener, which is worse than either outcome here. A Codex worker is parked
157
+ // on a promise in this process, so it is told the refusal directly and
158
+ // unblocks; a Claude Code worker has only the file, so it denies on its own
159
+ // deadline, which is the same answer fifteen minutes later. Nothing on this
160
+ // path can turn a failure into an allow, and the deny falls out of the SAME
161
+ // two calls the yes does rather than a second door beside them: `allow` and
162
+ // `note` stop meaning what she pressed the moment the write fails, and start
163
+ // meaning what was actually recorded, which is what everything downstream of
164
+ // here has to be told.
165
+ //
166
+ // AND HER PRESS IS BOUND TO THE CARD SHE READ, NOT TO ITS ID (2026-09-05).
167
+ // The request file is worker-writable and a rewrite keeps the name and the id
168
+ // while replacing the body, so "yes to <uuid>" was an allow for whatever the
169
+ // engine happened to be holding. `approvals.cardShown` is the digest of what
170
+ // was last drawn -- `answer` signs it, so the Claude Code worker's own server
171
+ // can check it against the request IT is holding -- and `codexCardChanged` is
172
+ // the same question asked of the engine holding its request in THIS process,
173
+ // which is the only one that can be asked here. A yes on a card that moved
174
+ // becomes a no, by the same rule and in the same place as a yes the spool
175
+ // would not take: `allow` and `note` stop meaning what she pressed and start
176
+ // meaning what was actually recorded.
177
+ const answerApproval = (id, allow, note) => {
178
+ const shown = approvals.cardShown(config.storeRoot, id);
179
+ if (allow && supervisor.codexCardChanged?.(id, shown)) {
180
+ console.warn(`zero: approval ${id} was drawn from a request that has since been rewritten; the founder's Allow was for a different card and nothing has been allowed`);
181
+ allow = false;
182
+ note = CARD_CHANGED;
183
+ }
184
+ const ok = approvals.answer(config.storeRoot, id, allow, note);
185
+ if (!ok) { allow = false; note = SPOOL_REFUSED; }
186
+ const settled = supervisor.settleCodexApproval?.(id, allow, note) ?? false;
187
+ if (!ok && settled) console.warn(`zero: approval ${id} could not be recorded in the spool; the worker waiting on it has been denied`);
188
+ if ((ok || settled) && !window.isDestroyed()) {
189
+ window.webContents.send('zero:approval-answered', { id, allow: !!allow });
190
+ }
191
+ return ok || settled;
192
+ };
193
+
194
+ // THE RENDERER'S ONLY WAY TO COUNT ANYTHING, and it cannot send: it hands a
195
+ // name to the main process, which checks the name against her approved list
196
+ // and attaches the properties itself. This is the reason there is no
197
+ // analytics SDK in the renderer at all (a test fails the build if one
198
+ // appears): a browser SDK autocaptures the text of what was clicked, and in
199
+ // Agentbox that text is task titles.
200
+ ipcMain.handle('zero:track', (_e, { name } = {}) => {
201
+ if (!EVENT_NAMES.includes(name)) return false;
202
+ return analytics.track(name);
203
+ });
204
+
205
+ ipcMain.handle('zero:approve', (_e, { id, allow, note }) => {
206
+ return { ok: answerApproval(id, allow, note) };
207
+ });
208
+
209
+ // AN AGENT ROW IS NOT A WORK ITEM. Rows for her Claude Code agents are drawn
210
+ // in the same list as her work and carry the same shape, so every keystroke
211
+ // the list already understands is aimed at them too. They belong to no
212
+ // ledger, and a write against `agent:41234` would append a line to a real
213
+ // product's work-items.jsonl for an item that does not exist. The renderer
214
+ // does not offer those actions; this is the second lock, here because the
215
+ // first one is a UI and UIs get edited.
216
+ const isAgentRow = (id) => typeof id === 'string' && id.startsWith('agent:');
217
+
218
+ // EVERY CLAUDE CODE AGENT ON THE MACHINE, minus the ones the app started itself.
219
+ // It rides the snapshot because it is exactly the same kind of fact as the
220
+ // rest of it — what is happening right now — and because a second poll would
221
+ // be a second clock the two lists could disagree about. This call NEVER
222
+ // blocks: it hands back the last measurement and asks for a fresh one in the
223
+ // background (main/agents.mjs), because measuring costs a quarter of a second
224
+ // of subprocess and this is the thread the window is drawn on.
225
+ // A newly-stuck agent should reach her without waiting for the renderer's
226
+ // ten-second poll, and the reader only fires this when the list actually
227
+ // changed rather than on every measurement.
228
+ agents.setOnChange(push);
229
+
230
+ // The moments she set on agents she is not ready for. Deferring one is about
231
+ // her inbox and nothing else: no message is sent and no process is touched,
232
+ // so this is a moment kept beside the reading rather than a write into
233
+ // somebody else's terminal. See main/agent-schedule.mjs.
234
+ const agentSchedule = new AgentSchedule(config.storeRoot);
235
+
236
+ // WHAT IS LEFT OF HER LIMIT. It rides the snapshot for the same reason
237
+ // `restartNeeded` does: it changes WHILE the app is open, and the corner that
238
+ // draws it is on every screen. Reading it costs twenty-five seconds, so
239
+ // nothing here ever waits for it: the object answers instantly out of its
240
+ // last measurement and refreshes itself behind the window
241
+ // (main/claude-usage.mjs).
242
+ const usage = new ClaudeUsage({
243
+ // Asked each time rather than captured, because Claude Code can be installed
244
+ // while the app is running and the settings screen already promises that
245
+ // works without a restart.
246
+ bin: () => config.claudeBin,
247
+ onChange: push,
248
+ });
249
+
250
+ // Agents this run has already counted. In memory on purpose: a fresh launch
251
+ // seeing the same agent again is a fresh fact about the app being used.
252
+ const seenAgents = new Set();
253
+
254
+ const liveAgents = () => {
255
+ try {
256
+ const live = agentSchedule.decorate(agents.listAgents({
257
+ zeroPid: process.pid,
258
+ sessionIds: Object.values(supervisor._liveSessions ?? {}).map((s) => s?.sessionId),
259
+ products: store.listProducts(),
260
+ claudeBin: config.claudeBin,
261
+ }));
262
+ // "An agent was seen", ONCE per agent per run of the app. This list is
263
+ // rebuilt every few seconds, so counting it here without the set would
264
+ // send one event per agent per tick, all day. The keys are only ever
265
+ // compared in this process; nothing about an agent is sent with the
266
+ // count, which carries no name, no path and no title.
267
+ for (const agent of live) {
268
+ const key = agentKey(agent);
269
+ if (!key || seenAgents.has(key)) continue;
270
+ seenAgents.add(key);
271
+ analytics.track('agent_seen');
272
+ }
273
+ return live;
274
+ } catch {
275
+ // Her inbox is not allowed to fail because a `ps` did. An empty list here
276
+ // reads as "no outside agents", which is the state of most machines.
277
+ return [];
278
+ }
279
+ };
280
+
281
+ /*
282
+ * THE FOLDER THE NEXT SIGN IN BELONGS TO, for either coding agent.
283
+ Remembered in main, not passed in from the window, because a terminal's
284
+ working directory is the one thing the renderer must never get to choose:
285
+ `resolve` is the only gate between a key and a shell, and every other key
286
+ goes through a work item that has to exist. w-dc88147919.
287
+
288
+ IT IS ONE VARIABLE FOR BOTH ENGINES because there is one settings terminal,
289
+ so there is only ever one sign in going on. Adding a Claude account after a
290
+ Codex one replaces the folder, and the shell the old one was holding is
291
+ closed in the same breath. */
292
+ let pendingLogin=null;
293
+
294
+ const terminals=new TaskTerminals({resolve:key=>{
295
+ const {product,id}=JSON.parse(key);
296
+ /*
297
+ * THE SETTINGS TERMINAL, which belongs to no task. There is exactly one of
298
+ it and it opens in the folder main just made for the login; if nothing is
299
+ pending it opens at home, which is where a shell with nothing to do
300
+ belongs. */
301
+ if(product===SETTINGS_TERMINAL.product&&id===SETTINGS_TERMINAL.id){
302
+ return pendingLogin ?? config.home ?? os.homedir();
303
+ }
304
+ const agent=id.startsWith('agent:')?liveAgents().find(a=>'agent:'+a.pid===id && (a.product??'')===product):null;
305
+ return terminalPlace({store,supervisor,product,id,agent});
306
+ }});
307
+ let terminalQuitPending=false,terminalQuitReady=false;
308
+ app.on('before-quit',event=>{
309
+ if(terminalQuitReady||(!terminalQuitPending&&!terminals.sessions.size))return;
310
+ event.preventDefault();
311
+ if(!terminalQuitPending){terminalQuitPending=true;void terminals.shutdown().finally(()=>{terminalQuitReady=true;app.quit();});}
312
+ });
313
+ ipcMain.handle('zero:terminal',(_event,payload={})=>{
314
+ if(_event.senderFrame?.parent || (_event.sender && _event.sender!==window.webContents)) throw Error('Terminal is only available in the main app window.');
315
+ const {product,id,action,data,cols,rows,offset}=payload;
316
+ if(typeof product!=='string'||typeof id!=='string'||!id||id.length>300)throw Error('Invalid task.');
317
+ const key=JSON.stringify({product,id});
318
+ switch(action){
319
+ case 'open':return terminals.open(key);
320
+ case 'read':return terminals.read(key,offset);
321
+ case 'write':terminals.write(key,data);return true;
322
+ case 'resize':terminals.resize(key,cols,rows);return true;
323
+ case 'close':terminals.close(key);return true;
324
+ default:throw Error('Unknown terminal action.');
325
+ }
326
+ });
327
+
328
+ ipcMain.handle('zero:snapshot', () => {
329
+ const now = Date.now();
330
+ const items = store.listItems(now);
331
+ const running = supervisor.status();
332
+ const engines = supervisor.engineFacts(items);
333
+ // WHICH CODING AGENT THE CORNER IS ABOUT, ANSWERED ONCE, HERE.
334
+ // `usageEngine` is the rule (shared/usage.mjs) and it is the byline's own
335
+ // -- what is running beats what would run -- asked about the app instead of
336
+ // about a row.
337
+ const usageFor = usageEngine({ workspace: engines.workspace, running: running.running });
338
+ // ASKING IS THE BRANCH, WHICH IS WHY THIS IS NOT TWO READS AND A CHOICE.
339
+ // `usage.read` starts a `claude -p /usage` child whenever its figure has
340
+ // gone stale, so calling it on a snapshot whose corner is about Codex
341
+ // spends twenty-five seconds of somebody's machine on a number nothing will
342
+ // draw -- which is what a Codex-only workflow paid every five minutes until
343
+ // now. The Codex side never starts anything at all (main/codex-usage.mjs).
344
+ const reading = usageFor === DEFAULT_ENGINE ? usage.read() : supervisor.codexUsage();
345
+ return {
346
+ products: store.listProducts(),
347
+ items,
348
+ agents: liveAgents(),
349
+ approvals: approvals.listPending(config.storeRoot),
350
+ supervisor: running,
351
+ // WHETHER THERE ARE TWO CODING AGENTS TO CHOOSE BETWEEN, AND WHICH ONE
352
+ // EACH ROW RUNS ON. Answered whole by the supervisor, because the two
353
+ // rules underneath it -- the capability gate and "an August choice is not
354
+ // a choice she is making now" -- are pinned to that one file, and a screen
355
+ // that worked either of them out for itself could draw "Codex" over a row
356
+ // about to run on Claude Code.
357
+ //
358
+ // It rides the snapshot rather than the settings model for the reason
359
+ // `outsideAgents` does: the INBOX reads it, on every row, and the settings
360
+ // model is only fetched while that screen is open. On a Mac with one
361
+ // coding agent this is one entry and an empty map, and nothing anywhere
362
+ // draws anything new.
363
+ engines,
364
+ // Whether this process is still the app on disk. It rides the snapshot
365
+ // rather than boot-info because the staleness DEVELOPS while the app
366
+ // runs: the case that cost her three times was a process left up for days
367
+ // while fixes landed underneath it, and a boot-time check cannot see
368
+ // that. See main/staleness.mjs.
369
+ restartNeeded: restartNeeded(supervisor.appDir),
370
+ // HER RATE LIMIT, WHICH IS WHAT ACTUALLY CAPS THE FLEET. Null until the
371
+ // first reading lands, and the corner draws nothing at all until then
372
+ // rather than a pill full of dashes.
373
+ //
374
+ // AND IT IS ONE AGENT'S LIMIT, NAMED (2026-09-05). One corner and one
375
+ // dropdown, as before; what is new is that the reading says whose
376
+ // subscription it is about, so the screen can say so too. The name is
377
+ // still only DRAWN where this Mac has two agents to tell apart -- that
378
+ // rule is `engineWordFor`'s, in renderer/src/byline.ts.
379
+ usage: reading && { engine: usageFor, ...reading },
380
+ usageByEngine: engines.choices.map(({ id }) => {
381
+ const value = id === usageFor ? reading : id === DEFAULT_ENGINE ? usage.peek() : supervisor.codexUsage();
382
+ return value ? { engine: id, ...value } : null;
383
+ }).filter(Boolean),
384
+ // WHETHER A NEWER AGENTBOX IS ALREADY DOWNLOADED AND WAITING. It rides the
385
+ // snapshot for the same reason `restartNeeded` above does: it becomes
386
+ // true WHILE the app is open, hours after boot, and the screens that
387
+ // offer the restart are ones she may already be looking at. Settings
388
+ // reads the whole of it; ⌘K reads `ready` alone.
389
+ update: updater.state(),
390
+ config: {
391
+ // How many of her own Claude Code sessions the inbox takes. It rides
392
+ // the snapshot rather than the settings model because the INBOX reads
393
+ // it, and the settings model is only fetched while that screen is
394
+ // open.
395
+ outsideAgents: outsideAgentsMode(config),
396
+ // WHAT AGENTS ARE ALLOWED TO DO, ON THE SNAPSHOT, so the reply box can
397
+ // print it without opening the settings screen.
398
+ //
399
+ // It genuinely was not there: the only way in was a character you had
400
+ // to already know to type. Claude Code's own answer to the same problem
401
+ // is that the mode is printed in the status bar the entire time it is
402
+ // on, so ours is printed on the reply footer the entire time, and this
403
+ // is the value it prints when the message carries no opinion of its
404
+ // own. Per PROJECT, because the mode a reply will run in depends on
405
+ // which project the row belongs to: a project override wins over the
406
+ // workspace, and a personal project runs its own list entirely. The
407
+ // workspace answer is under the empty-string key, for a surface that
408
+ // has no project in hand.
409
+ permission: Object.fromEntries([
410
+ ['', permissionMode(supervisor.defaultSessionArgs())],
411
+ ...store.listProducts().map((p) => [p.slug, supervisor.effectivePermission(p.slug)]),
412
+ ]),
413
+ },
414
+ };
415
+ });
416
+
417
+ // HER WORDS, INTO A LIVE AGENT OF HERS. The only thing in the app that writes
418
+ // anywhere outside its own store, and it writes into a socket rather than to
419
+ // disk. The reader refuses a send to an agent frozen on a permission box, and
420
+ // reports back whether the agent actually moved rather than assuming it did.
421
+ ipcMain.handle('zero:agent-reply', async (_e, { pid, text }) => {
422
+ try {
423
+ const out = await agents.reply(Number(pid), text);
424
+ // AND THE ROW REMEMBERS THAT SHE SPOKE. Only on a delivered message: a
425
+ // mark written for a send that failed would move the row into In progress
426
+ // over words that never arrived, which is the failure this codebase cares
427
+ // about most.It does now, until the session stops. AND WHAT SHE TYPED,
428
+ // not only that she typed. Claude Code marks a message arriving over its
429
+ // socket as machine chatter (`isMeta`), so the transcript reader drops it
430
+ // and her own sentence never reached the card: she sent session-67 two
431
+ // messages on 2026-08-17 and the row went on saying only what she had
432
+ // started it with four days before. This is the only process that knows
433
+ // she said it, so this is where it is written.
434
+ if (out?.ok && out.key) agentSchedule.replied(out.key, out.at ?? Date.now(), Date.now(), String(text ?? ''));
435
+ push();
436
+ return out;
437
+ } catch {
438
+ return { ok: false, reason: 'The message could not be handed over.' };
439
+ }
440
+ });
441
+
442
+ // AND UNDOING ONE. Withdrawing a reply cannot un-send the message — it is in
443
+ // somebody else's terminal — so this is honest about what it does: it takes
444
+ // the row out of In progress and puts it back where it was. The session is
445
+ // not touched, exactly as with deferring or closing one.
446
+ ipcMain.handle('zero:unreply-agent', (_e, { key }) => {
447
+ const mark = agentSchedule.replied(String(key ?? ''), 0);
448
+ push();
449
+ return { ok: true, repliedAt: mark };
450
+ });
451
+
452
+ // For the agent a reply cannot reach: bring the app it is running inside to
453
+ // the front. Named honestly, because it is the app and not the pane.
454
+ ipcMain.handle('zero:agent-reveal', async (_e, { pid }) => {
455
+ try {
456
+ return await agents.reveal(Number(pid));
457
+ } catch {
458
+ return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
459
+ }
460
+ });
461
+
462
+ // HER BUTTON: what the two of them actually said, read off the transcript she
463
+ // already has, only when she presses it. No model, nothing sent, nothing
464
+ // written. It is streamed inside the reader because the biggest transcript on
465
+ // this machine is 160MB.
466
+ ipcMain.handle('zero:agent-conversation', async (_e, payload) => {
467
+ try {
468
+ // Her replies from Agentbox ride in beside the transcript for the same
469
+ // reason the card needs them: they are not IN the transcript as hers.
470
+ // The key is DERIVED here rather than sent, because what identifies an
471
+ // agent is a rule with one copy of it (shared/agents.mjs) and the
472
+ // renderer already hands over both halves it needs.
473
+ const key = agentKey({ sessionId: payload?.sessionId, pid: Number(payload?.pid), startedAt: payload?.startedAt });
474
+ return await agents.conversation({ ...(payload ?? {}), spoke: agentSchedule.spoke(key) });
475
+ } catch {
476
+ return { ok: false, reason: 'That conversation could not be read.' };
477
+ }
478
+ });
479
+
480
+ ipcMain.handle('zero:dashboard', (_e, slug) => {
481
+ try {
482
+ return store.readDashboard(slug);
483
+ } catch {
484
+ return null;
485
+ }
486
+ });
487
+
488
+ ipcMain.handle('zero:command-catalog', (_e, { product, id }) => supervisor.commandCatalog(product, id));
489
+ ipcMain.handle('zero:remote-control', (_e, {product,id,action}) => supervisor.remoteControl(product,id,action));
490
+ ipcMain.handle('zero:command', (_e, { product, id, text }) => supervisor.commandItem(product, id, text));
491
+ ipcMain.handle('zero:compact', (_e, { product, id }) => supervisor.compactItem(product, id));
492
+ ipcMain.handle('zero:compaction-status', (_e, { product, id }) => supervisor.compactionStatus(product, id));
493
+
494
+ ipcMain.handle('zero:answer', async (_e, { product, id, answer, status, priority, permissionMode, model, effort }) => {
495
+ if (isAgentRow(id)) return { ok: false };
496
+ // A CODEX CONVERSATION'S YES OR NO NEVER REACHES A WORKER. The row asking
497
+ // whether to import one is answered by the app itself: yes makes it a row
498
+ // she can read, no parks it in Closed under Not imported, and Import on a
499
+ // parked one is the same yes. Any other reply on such a row falls through
500
+ // to the ordinary path, exactly as before.
501
+ if (product && id) {
502
+ let row = null;
503
+ try { row = store.readItem(product, id); } catch { row = null; }
504
+ if (row && (isCodexImportRow(row) || isNotImportedRow(row))) {
505
+ const choice = importChoice(answer);
506
+ if (choice) {
507
+ let thread = null;
508
+ if (choice === 'yes') {
509
+ try { thread = readCodexThreads({}).threads.find((t) => t.id === codexIdOf(row)) ?? null; } catch { thread = null; }
510
+ }
511
+ const out = store.answerCodexImport(product, id, choice, { thread });
512
+ push();
513
+ return out;
514
+ }
515
+ }
516
+ }
517
+ // ARCHIVING ENDS THE WORK, not just the row. Her archive now outranks
518
+ // anything a straggler writes (shared/work-items.mjs, authorityOf), but a
519
+ // worker left running on work she has ended is still burning her plan on
520
+ // it, so it stops here rather than being merely overruled.
521
+ if (status === 'done') supervisor.stopSession(id);
522
+ // THE MODEL IS WRITTEN BEFORE THE REPLY IS SUBMITTED, deliberately. A reply
523
+ // on an idle row starts a run, and that run reads the model off the item;
524
+ // handing them over in one call and writing the model afterwards would be a
525
+ // race the run usually wins, so she would pick a model and watch the old one
526
+ // take the task. `answerItem` writes both, and it writes the model first.
527
+ const out = await submitReply(supervisor,{product,id,answer,status,permissionMode},()=>store.answerItem(product, id, { answer, status, priority, permissionMode, model, effort }));
528
+ // THAT she replied and THAT something finished. Never a word of either:
529
+ // in Agentbox the answer IS the prompt (privacy page, section 4).
530
+ if (typeof answer === 'string' && answer.trim()) analytics.track('reply_sent');
531
+ if (status === 'done') analytics.track('task_finished');
532
+ // HER REPLY IS THE OTHER HALF OF THE SAME WAIT, and it is the one she pays
533
+ // on every round trip rather than once: measured 2026-08-24, median 9.1
534
+ // seconds between pressing the key and the continuation being spawned.
535
+ // Archiving is the one answer that starts nothing, and its session was
536
+ // just killed above, so there is nothing to wake for.
537
+ if (status !== 'done') { supervisor.liftBrakeForHer?.(); supervisor.wake(); }
538
+ return out;
539
+ });
540
+
541
+ // The product order: the founder's standing running order over her
542
+ // companies, most important first. Persisted in the supervisor's state;
543
+ // agents never set this. The renderer sends the WHOLE order rather than one
544
+ // move, because a drag already knows the finished arrangement, and a move
545
+ // would need both sides to agree about indices only one of them can see.
546
+ ipcMain.handle('zero:set-product-order', (_e, { order }) => {
547
+ supervisor.setProductOrder(order);
548
+ return supervisor.status();
549
+ });
550
+
551
+ // Hidden from the composer's picker and nothing else: a hidden product still
552
+ // ranks, still spawns workers, and its work still reaches the inbox.
553
+ ipcMain.handle('zero:set-product-hidden', (_e, { product, hidden }) => {
554
+ supervisor.setProductHidden(product, hidden);
555
+ return supervisor.status();
556
+ });
557
+
558
+ ipcMain.handle('zero:compose', (_e, { product, title, body, kind, priority, runAt, labels, engine, model, effort }) => {
559
+ const out = store.composeItem(product, {
560
+ // HOW HARD IT THINKS rides through unjudged: the store keeps any word
561
+ // shaped like a level, and the spawn is the gate that knows each
562
+ // engine's real list (`Supervisor#spawnPlan` for Claude Code,
563
+ // `codexEffortRefusal` for Codex).
564
+ title, body, kind, priority, runAt, labels, effort,
565
+ // WHICH CODING AGENT SHE CHOSE, AND ONLY IF SHE COULD HAVE. `engineOffered`
566
+ // is the supervisor's own test and the door makes no judgement of its own:
567
+ // it answers null on any Mac where the picker is not drawn, so a renderer
568
+ // left open across a config change cannot write a choice she was never
569
+ // shown. This argument used to be forwarded straight through and
570
+ // `composeItem` dropped it in silence.
571
+ engine: supervisor.engineOffered(engine),
572
+ // AND THE MODEL GOES WITH IT, which it did not: `model` was forwarded raw
573
+ // beside a refused engine, so the row was filed carrying the other
574
+ // harness's slug and no engine to explain it -- a row the cross-harness
575
+ // guard downstream cannot even see. `modelOffered` says why the two
576
+ // fields cannot be corrected separately.
577
+ model: supervisor.modelOffered(engine, model),
578
+ });
579
+ // AND LOOK AT IT NOW. Composing used to write the line and stop, so the
580
+ // task sat in the store until the next fifteen second tick: measured
581
+ // 2026-08-24, median 7.0 seconds and up to 15.0 before the spawn was even
582
+ // attempted. A row scheduled for later is not woken for, because it is not
583
+ // due; the timer is what serves those.
584
+ if (!runAt || runAt <= Date.now()) supervisor.wake();
585
+ return out;
586
+ });
587
+
588
+ /* ----------------------------- the first run ---------------------------- */
589
+ // THE WALK'S EXAMPLE TASK, ANSWERED BY THE APP RATHER THAN BY A SESSION.
590
+ //
591
+ // The whole of the work is reading the readme in the folder chosen two
592
+ // screens earlier, so this returns in milliseconds. The two second pause
593
+ // before it is called is in the renderer, where the row is being watched.
594
+ ipcMain.handle('zero:first-run-answer', (_e, { product, id }) => {
595
+ return store.finishFirstRunTask(product, id);
596
+ });
597
+
598
+ // THE PRACTICE PROJECT.Made when the hand-off screen is pressed, filled with
599
+ // the three rows in the same call, and archived by the handler below it when
600
+ // the walk ends. It is a real project and it is never a project anybody works
601
+ // in: main/store.mjs and main/supervisor.mjs both say what that means.
602
+ ipcMain.handle('zero:first-run-practice', () => {
603
+ const made = store.createPractice();
604
+ let rows = { ids: [] };
605
+ // A store that will not take the three rows is not a reason to strand
606
+ // somebody mid-walk. They land in an empty practice inbox and the beat that
607
+ // clears them is skipped, exactly as beat thirteen already behaves.
608
+ try { rows = store.stagePracticeRows(made.slug); } catch { rows = { ids: [] }; }
609
+ return { ...made, examples: rows.ids };
610
+ });
611
+
612
+ // AND TAKING IT BACK OFF THE SCREEN. `sweepPractice` rather than the one
613
+ // slug, so a practice project left behind by a walk somebody quit halfway is
614
+ // cleared by the next one that finishes.
615
+ ipcMain.handle('zero:first-run-practice-end', () => store.sweepPractice());
616
+
617
+ // BEAT EIGHT'S THREE EXAMPLE ROWS, written into the project she just
618
+ // made. They are real rows, because the beat teaches clearing an inbox and
619
+ // there is no way to teach that with a picture of one.
620
+ ipcMain.handle('zero:first-run-examples', (_e, { product }) => {
621
+ return store.stageFirstRunExamples(product);
622
+ });
623
+
624
+ // AND NOTHING ELSE STARTS WHILE SHE IS BEING SHOWN AROUND.The hold is in
625
+ // memory and it expires; supervisor.firstRunWalking says why both of those
626
+ // matter.
627
+ ipcMain.handle('zero:first-run-walking', (_e, { walking }) => {
628
+ supervisor.firstRunWalking(!!walking);
629
+ return supervisor.status();
630
+ });
631
+
632
+ // BEING A BRAND NEW USER, ON THE APP SHE DOWNLOADED.The row above this one
633
+ // is the "at LEAST" half. This is the whole of it: a SECOND Agentbox, with no
634
+ // projects, no settings and no store, standing beside hers rather than
635
+ // replacing it.
636
+ //
637
+ // NOTHING OF HERS IS DELETED HERE, and there is no version of this handler
638
+ // that may delete anything. main/fresh-user.mjs says how it works and why
639
+ // her own window and every agent in it keep running.
640
+ ipcMain.handle('zero:open-fresh-user', (_e, payload) => {
641
+ return openFreshUser({
642
+ withAgents: payload?.withAgents !== false,
643
+ packaged: app.isPackaged,
644
+ });
645
+ });
646
+
647
+ // AN INBOX TO STAND IN FRONT OF A ROOM WITH.
648
+ //
649
+ // The same second Agentbox in a throwaway home as the row above, with three
650
+ // invented products and a day of invented work seeded into it first, so it
651
+ // opens on a populated inbox rather than on the welcome screen. It reads none
652
+ // of her folders and it deletes nothing (main/demo.mjs).
653
+ ipcMain.handle('zero:open-demo', () => openDemo({ packaged: app.isPackaged }));
654
+
655
+ // "Run this later", the durable half of what snooze used to pretend to be.
656
+ // A worker already running on the item is stopped: deferring work that is
657
+ // happening right now and leaving it running is not deferring it.
658
+ ipcMain.handle('zero:schedule', (_e, { product, id, runAt }) => {
659
+ if (isAgentRow(id)) return { ok: false };
660
+ if (runAt > Date.now()) supervisor.stopSession(id);
661
+ return store.scheduleItem(product, id, runAt);
662
+ });
663
+
664
+ // said about an agent instead of about a task. The row it moves stands for
665
+ // somebody's terminal, so this writes to no ledger, sends no message and
666
+ // stops no process: it records the moment her inbox should raise the agent
667
+ // again, and the reader tests it on every measurement. 0 brings it straight
668
+ // back.
669
+ //
670
+ // The KEY comes from the agent, never from the row id: `agent:<pid>` is what
671
+ // the list draws with and a pid is not an identity (shared/agents.mjs).
672
+ ipcMain.handle('zero:schedule-agent', (_e, { key, runAt }) => {
673
+ const set = agentSchedule.set(String(key ?? ''), runAt);
674
+ push();
675
+ return { ok: true, runAt: set };
676
+ });
677
+
678
+ // `through` is the agent's own last activity when she closed it, so a session
679
+ // that then does something newer is news again rather than the thing she
680
+ // dismissed; 0 puts the row straight back.
681
+ ipcMain.handle('zero:close-agent', (_e, { key, through }) => {
682
+ const mark = agentSchedule.close(String(key ?? ''), through);
683
+ push();
684
+ return { ok: true, doneThrough: mark };
685
+ });
686
+
687
+ /* ---------------------------- repeating tasks --------------------------- */
688
+ // A repeating task is a RULE, so composing one writes no work item at all.
689
+ // That ordering is the point: composeItem appends an open founder item before
690
+ // it applies her content, and another copy of the app can spawn that intermediate state,
691
+ // so a rule that turned out to be unkeepable would leave a one-shot running.
692
+ // The first run arrives on the next tick instead.
693
+ ipcMain.handle('zero:repeats', () => store.listRepeats());
694
+
695
+ // THE SAME TWO GATES `zero:compose` USES, because a rule is a standing task
696
+ // and every one of its runs is an ordinary row. `engineOffered` answers null
697
+ // on any Mac where the picker is not drawn, so a rule cannot be marked for an
698
+ // engine she was never shown, and `modelOffered` refuses the model of an
699
+ // engine that was just refused. Both were absent here because neither field
700
+ // reached this handler at all: the whole pair was dropped four times over on
701
+ // the way from her card (main/repeats.mjs says where).
702
+ ipcMain.handle('zero:compose-repeat', (_e, { product, title, body, priority, rule, engine, model }) => {
703
+ return store.composeRepeat(product, {
704
+ title,
705
+ body,
706
+ priority,
707
+ ...rule,
708
+ engine: supervisor.engineOffered(engine),
709
+ model: supervisor.modelOffered(engine, model),
710
+ });
711
+ });
712
+
713
+ // THE ONE HANDLER THAT TAKES A FREE-FORM PATCH, so it is the one that has to
714
+ // gate the two new fields by hand. `patchRule` appends whatever it is given
715
+ // and the fold now carries `engine` and `model` (shared/repeats.mjs), so
716
+ // without this the renderer could mark a rule for Codex without ever passing
717
+ // `engineOffered` -- the capability gate, bypassed by the edit screen. A patch
718
+ // that names neither is forwarded exactly as it arrived.
719
+ ipcMain.handle('zero:set-repeat', (_e, { product, id, rule }) => {
720
+ const { engine, model, ...rest } = rule ?? {};
721
+ return store.setRepeat(product, id, {
722
+ ...rest,
723
+ ...(engine === undefined ? {} : { engine: supervisor.engineOffered(engine) }),
724
+ ...(model === undefined ? {} : { model: supervisor.modelOffered(engine, model) }),
725
+ });
726
+ });
727
+
728
+ // Ending one is how she stops it, and it is a small durable fact rather than
729
+ // an archive, so it does not collide with what done means on a work item.
730
+ ipcMain.handle('zero:end-repeat', (_e, { product, id }) => store.endRepeat(product, id));
731
+
732
+ ipcMain.handle('zero:create-product', (_e, { name, repoPath }) => {
733
+ const out = store.createProduct({ name, repoPath });
734
+ // (privacy page 5.1). The count is the whole payload; the path it was
735
+ // connected to never leaves.
736
+ if (typeof repoPath === 'string' && repoPath.trim()) analytics.track('repo_connected');
737
+ return out;
738
+ });
739
+
740
+ // Returns a bare path or null. Cancelling is not an error and never clears
741
+ // what the card already had.
742
+ //
743
+ // AND IT REFUSES THE FOLDERS THAT ARE NOT PROJECTS.The picker opens on the
744
+ // home folder, so pressing Open without navigating anywhere makes the home
745
+ // folder the project, and the worker that starts there lists it within its
746
+ // first few tool calls. macOS then asks about Downloads, Music, Pictures and
747
+ // Application Support one panel at a time, with our name on every one of
748
+ // them.
749
+ //
750
+ // The refusal is HERE rather than on the screens, so the path never reaches
751
+ // the renderer at all and no screen can take it by accident. What comes back
752
+ // is the sentence, which the screens show as it stands.
753
+ ipcMain.handle('zero:choose-folder', async (_e, { startIn } = {}) => {
754
+ const r = await dialog.showOpenDialog(window, {
755
+ title: 'Where is its code?',
756
+ buttonLabel: 'Use this folder',
757
+ properties: ['openDirectory', 'createDirectory'],
758
+ ...(startIn ? { defaultPath: startIn } : {}),
759
+ });
760
+ // NO MAC DIALOG TO OPEN, WHICH IS THE BROWSER AND NOT AN ERROR. The screen
761
+ // draws its own picker instead, on `zero:list-folders` below, and comes
762
+ // back here with nothing further to ask. Saying so with a flag rather than
763
+ // a sentence is what lets one screen serve both doors.
764
+ if (r.noDialog) return { path: null, browse: true };
765
+ if (r.canceled || !r.filePaths?.length) return { path: null };
766
+ const picked = r.filePaths[0];
767
+ const verdict = checkProjectFolder(picked, { home: os.homedir() });
768
+ if (!verdict.ok) return { path: null, refused: verdict.say };
769
+ return { path: picked };
770
+ });
771
+
772
+ // THE PICKER THE APP DRAWS ITSELF, for a tab that has no Mac dialog.
773
+ //
774
+ // The tab cannot be given a path by the operating system. This process can
775
+ // read the disk perfectly well, so the screen walks it a folder at a time
776
+ // through here and sends back the one she stopped on. See main/folders.mjs
777
+ // for why that is the whole of the problem.
778
+ //
779
+ // The verdict rides along so the picker can say, on the folder she is
780
+ // standing in, whether choosing it would be turned down. The Mac's dialog
781
+ // cannot do that and this can, which makes the browser's picker the better
782
+ // of the two on the one thing that actually costs somebody an afternoon.
783
+ ipcMain.handle('zero:list-folders', (_e, { at = null, showHidden = false } = {}) => {
784
+ const home = os.homedir();
785
+ const listed = listFolders({ at, home, showHidden });
786
+ const verdict = checkProjectFolder(listed.at, { home });
787
+ return { ...listed, refused: verdict.ok ? null : verdict.say };
788
+ });
789
+
790
+ // Whether the card's sentence may say "a new folder". The card proposes
791
+ // ~/Desktop/dev/<name> from the name typed, and that folder sometimes already
792
+ // exists with somebody's code in it; calling that one new would be a lie in
793
+ // the one sentence on the card.
794
+ ipcMain.handle('zero:folder-exists', (_e, { path: p } = {}) => {
795
+ if (typeof p !== 'string' || !p.trim()) return { exists: false };
796
+ const full = p.trim().startsWith('~')
797
+ ? path.join(os.homedir(), p.trim().slice(1))
798
+ : p.trim();
799
+ try { return { exists: fsWatch.statSync(full).isDirectory() }; } catch { return { exists: false }; }
800
+ });
801
+
802
+ // HER CLAUDE CODE AGENTS, for the last screen of the first run. Both of
803
+ // Claude Code's own scopes, because both of them are in her sentence: the
804
+ // home folder set is every project on this Mac, and the one inside the
805
+ // project folder is that project alone.
806
+ //
807
+ // `chosen` comes back with them so the screen can open on what she already
808
+ // took rather than on nothing, which is what makes walking it a second time
809
+ // show her the truth instead of an empty page.
810
+ ipcMain.handle('zero:agent-files', (_e, { folder = null, product = null } = {}) => {
811
+ const found = readAgentFiles({ folder: typeof folder === 'string' && folder.trim() ? folder.trim() : null });
812
+ const map = config.projectAgents ?? {};
813
+ const chosen = product && Array.isArray(map[product]) ? map[product] : null;
814
+ return { ...found, chosen };
815
+ });
816
+
817
+ // THE FOLDERS THE CARD CANNOT REACH.
818
+ //
819
+ // So a folder's agents may only ever land in the project that points at that
820
+ // folder, which `zero:agent-files` above already guarantees by reading the
821
+ // chosen project's own `repoPath` and nothing else. The gap her answer leaves
822
+ // open is the folder no project points at: on her Mac that is
823
+ // `~/Desktop/dev/astral-desktop`, four agents that no choice on the card could
824
+ // reach. This says which folders those are so the card can offer to make the
825
+ // project rather than quietly filing them somewhere they do not work.
826
+ //
827
+ // the parent of every project folder she has already connected. A folder a
828
+ // project already owns is dropped, because the card reaches those by name in
829
+ // its own project list.
830
+ ipcMain.handle('zero:agent-folders', () => {
831
+ const products = store.listProducts();
832
+ const owned = new Set();
833
+ const roots = new Set();
834
+ for (const p of products) {
835
+ const repo = typeof p.repoPath === 'string' && p.repoPath.trim() ? path.resolve(p.repoPath.trim()) : null;
836
+ if (!repo) continue;
837
+ owned.add(repo);
838
+ roots.add(path.dirname(repo));
839
+ }
840
+ let folders = [];
841
+ try { folders = findAgentFolders({ roots: [...roots] }); } catch { folders = []; }
842
+ // AND THE AGENTS THEMSELVES, not only how many.A count can only be a clause
843
+ // in a sentence; the card now draws each folder as a heading with its own
844
+ // agents ticked underneath, which is the hierarchy, and that needs the
845
+ // files. They are a handful of small markdown files per folder and the same
846
+ // reader the project side already uses.
847
+ const out = [];
848
+ for (const f of folders) {
849
+ if (owned.has(f.folder)) continue;
850
+ let agents = [];
851
+ try { agents = readFolderAgents(f.folder); } catch { agents = []; }
852
+ out.push({ ...f, agents });
853
+ }
854
+ return { folders: out };
855
+ });
856
+
857
+ // AND THE THREADS, WHICH ARE THE AGENTS SHE MEANT.
858
+ //
859
+ // The card was reading `.md` agent definitions, four of them, last edited in
860
+ // March; these are the conversations.
861
+ //
862
+ // The reading itself is main/agent-sessions.mjs and every judgment lives
863
+ // there. This is the window onto it, plus one thing: WHAT IT LAST HANDED OUT
864
+ // IS KEPT. The import below needs the thread objects and it may not trust the
865
+ // renderer's copy of them, and the alternative is walking her whole
866
+ // ~/.claude/projects again per press. Measured on her Mac 2026-08-29: one
867
+ // walk is 1.7 seconds over 2,445 transcripts, and one press can file into
868
+ // several projects, so re-walking would cost seconds of a button that should
869
+ // feel instant. The card read them a moment before pressing, so the last read
870
+ // is the right answer and it is ours rather than the renderer's.
871
+ let lastThreads = [];
872
+ // HER CODEX CONVERSATIONS ARE ON THE SAME CARD. They carry `source:
873
+ // 'codex'`, land under the project whose folder they ran in exactly as the
874
+ // Claude Code ones do, and the ones she said not now to are here too, which
875
+ // is the way back she asked for.
876
+ const readAllThreads = () => {
877
+ let claude = [];
878
+ let codex = [];
879
+ try { ({ threads: claude } = readSessionThreads({})); } catch { claude = []; }
880
+ try { ({ threads: codex } = readCodexThreads({})); } catch { codex = []; }
881
+ return [...claude, ...codex].sort((a, b) => (b.when ?? 0) - (a.when ?? 0));
882
+ };
883
+ ipcMain.handle('zero:agent-threads', () => {
884
+ const threads = readAllThreads();
885
+ lastThreads = threads;
886
+ return { threads };
887
+ });
888
+
889
+ // So this is `zero:import-agents` with the agent taken out of it. No ticks are
890
+ // saved, because there is nothing for the card to remember: an imported thread
891
+ // is a row, and a row is what stops it being imported twice
892
+ // (`threadsNeedingRows`). It does NOT resume the conversation; she was offered
893
+ // that and picked this instead.
894
+ ipcMain.handle('zero:import-threads', (_e, { product, threads } = {}) => {
895
+ if (!product) return { ok: false, added: 0, already: 0, ids: [] };
896
+ const want = new Set(
897
+ (Array.isArray(threads) ? threads : [])
898
+ .filter((id) => typeof id === 'string' && id.trim())
899
+ .map((id) => id.trim()),
900
+ );
901
+ if (!want.size) { return { ok: true, added: 0, already: 0, ids: [] }; }
902
+ // The read is taken again only if nothing has been handed out yet, which is
903
+ // the case a test or a fresh window can reach and a person cannot.
904
+ if (!lastThreads.length) lastThreads = readAllThreads();
905
+ // Two kinds, two rows.
906
+ const chosen = lastThreads.filter((t) => want.has(String(t?.id ?? '')));
907
+ let out = { ids: [], added: 0, already: 0 };
908
+ try {
909
+ // Each store method takes its own kind off the list and leaves the other.
910
+ const claude = store.importThreadRows(product, chosen);
911
+ const codex = store.importCodexThreads(product, chosen);
912
+ out = { ids: [...claude.ids, ...codex.ids], added: claude.added + codex.added, already: claude.already + codex.already };
913
+ } catch (err) {
914
+ push();
915
+ return { ok: false, error: String(err.message), added: 0, already: 0, ids: [] };
916
+ }
917
+ push();
918
+ return { ok: true, ...out };
919
+ });
920
+
921
+ // TAKING THEM, which is the half that used to do nothing.
922
+ //
923
+ // Before this, the finish card's button wrote the ticked names into
924
+ // `projectAgents` and stopped. Three occurrences of that key in the whole
925
+ // shipped app, all of them this file and settings.mjs, and none in the
926
+ // renderer: the list was read back only so the card could remember its own
927
+ // ticks the next time somebody walked the setup. Measured on the build David
928
+ // used, the store was byte for byte identical before and after pressing it.
929
+ //
930
+ // The names are still saved, because the card still opens on what was taken
931
+ // last time. What is new is the second line: one inbox row per agent, asking
932
+ // for its first job.
933
+ //
934
+ // THE FILES ARE READ AGAIN HERE RATHER THAN SENT IN. The renderer has the
935
+ // whole list already, so it could hand the paths over, but a row that names a
936
+ // file on somebody's disk should name a file that was on the disk a moment
937
+ // ago and not one a screen was holding from earlier in the walk.
938
+ ipcMain.handle('zero:import-agents', (_e, { product, agents } = {}) => {
939
+ if (!product) return { ok: false, added: 0, ids: [] };
940
+ const names = (Array.isArray(agents) ? agents : [])
941
+ .filter((n) => typeof n === 'string' && n.trim())
942
+ .map((n) => n.trim());
943
+ try {
944
+ setProjectSetting({ config, supervisor }, { product, key: 'agents', value: names });
945
+ } catch { /* the ticks are a convenience; the rows are the point */ }
946
+ if (!names.length) { push(); return { ok: true, added: 0, ids: [] }; }
947
+ const keep = new Set(names);
948
+ const repo = store.listProducts().find((p) => p.slug === product)?.repoPath ?? null;
949
+ const found = readAgentFiles({ folder: repo });
950
+ const chosen = [...found.user, ...found.project].filter((a) => keep.has(a.name));
951
+ let out = { ids: [], added: 0, already: 0 };
952
+ try {
953
+ out = store.importAgentRows(product, chosen);
954
+ } catch (err) {
955
+ push();
956
+ return { ok: false, error: String(err.message), added: 0, ids: [] };
957
+ }
958
+ push();
959
+ return { ok: true, ...out };
960
+ });
961
+
962
+ ipcMain.handle('zero:supervisor-pause', (_e, paused) => {
963
+ supervisor.paused = !!paused;
964
+ // Taking the pause off is a thing she does expecting work to start, so it
965
+ // starts, rather than after up to another fifteen seconds of a paused-
966
+ // looking app.
967
+ if (!supervisor.paused) supervisor.wake();
968
+ return supervisor.status();
969
+ });
970
+
971
+ // Stop a running agent: kill its session and set the item blocked, so it
972
+ // is not instantly re-pulled and sits in the inbox awaiting redirection.
973
+ ipcMain.handle('zero:stop-session', (_e, { product, id }) => {
974
+ if (isAgentRow(id)) return { ok: false };
975
+ supervisor.stopSession(id);
976
+ return store.answerItem(product, id, { status: 'blocked' });
977
+ });
978
+
979
+ // Send a stopped/blocked item back to the queue; the fresh worker's brief
980
+ // carries whatever the founder wrote on the item meanwhile.
981
+ ipcMain.handle('zero:reopen', (_e, { product, id }) => {
982
+ if (isAgentRow(id)) return { ok: false };
983
+ const out = store.answerItem(product, id, { status: 'open' });
984
+ supervisor.liftBrakeForHer?.();
985
+ supervisor.wake();
986
+ return out;
987
+ });
988
+
989
+ /* -------------------------- standing instructions ------------------------ */
990
+ // Rules the founder writes once that every session reads before its own
991
+ // brief. The supervisor owns the file and the injection (standingFile,
992
+ // standingInstructions, spawnPlan); this is only the window onto it.
993
+ //
994
+ // WHAT SHE TYPES LANDS IN userDir, NEVER IN THE CHECKOUT (w-3dc46f3a67).
995
+ // These boxes wrote to `dataDir/briefs`, which running from source is the git
996
+ // checkout the fleet works in, so a save could be undone by the next
997
+ // worker's `git checkout` with nothing to show it had happened. The shipped
998
+ // defaults still come from appDir; only her own copy moved.
999
+ ipcMain.handle('zero:instruction-read', (_e, id) => {
1000
+ try { return readInstruction(supervisor.userDir, id, supervisor.appDir); }
1001
+ catch (e) { return {text:'',defaultText:'',error:e.message}; }
1002
+ });
1003
+ ipcMain.handle('zero:instruction-write', (_e, {id,text}) => {
1004
+ try { return writeInstruction(supervisor.userDir, id, text); }
1005
+ catch (e) { return {ok:false,error:e.message}; }
1006
+ });
1007
+ // The restore points behind the Restore link: her own earlier versions of one
1008
+ // box. The list carries times and sizes only, because the whole of twenty
1009
+ // versions is most of a megabyte and the page needs one of them at a time.
1010
+ ipcMain.handle('zero:instruction-history', (_e, id) => {
1011
+ try { return {versions: listVersions(supervisor.userDir, id)}; }
1012
+ catch (e) { return {versions:[], error:e.message}; }
1013
+ });
1014
+ ipcMain.handle('zero:instruction-version', (_e, {id,at}) => {
1015
+ try { return readVersion(supervisor.userDir, id, at); }
1016
+ catch (e) { return {text:'', error:e.message}; }
1017
+ });
1018
+ ipcMain.handle('zero:standing-read', () => {
1019
+ try {
1020
+ return { text: supervisor.readStanding() };
1021
+ } catch (err) {
1022
+ return { text: '', error: err.message };
1023
+ }
1024
+ });
1025
+
1026
+ ipcMain.handle('zero:standing-write', (_e, { text }) => {
1027
+ try {
1028
+ supervisor.writeStanding(text);
1029
+ return { ok: true };
1030
+ } catch (err) {
1031
+ return { ok: false, error: err.message };
1032
+ }
1033
+ });
1034
+
1035
+ /* ---------------------------- the message rules -------------------------- */
1036
+ // How agents write to her: the titles and openings during a task and the last
1037
+ // message a run sends, in one document since w-3dc46f3a67. Shipped filled in,
1038
+ // hers to change or empty. The supervisor owns the file and the injection
1039
+ // (messageRulesFile, messageRules, spawnPlan); this is only the window. The
1040
+ // two channels it replaces were zero:writing-rules-* and zero:finishing-*.
1041
+ ipcMain.handle('zero:message-rules-read', () => {
1042
+ try {
1043
+ return { text: supervisor.readMessageRules() };
1044
+ } catch (err) {
1045
+ return { text: '', error: err.message };
1046
+ }
1047
+ });
1048
+
1049
+ ipcMain.handle('zero:message-rules-write', (_e, { text }) => {
1050
+ try {
1051
+ supervisor.writeMessageRules(text);
1052
+ return { ok: true };
1053
+ } catch (err) {
1054
+ return { ok: false, error: err.message };
1055
+ }
1056
+ });
1057
+
1058
+ /* -------------------------------- settings ------------------------------ */
1059
+ // The settings screen. Reads are derived on every open (nothing is cached on
1060
+ // either side of the bridge, same as the snapshot); writes land in whichever
1061
+ // system owns the value and push, so the inbox behind the screen updates with
1062
+ // it. Every handler answers `{ ok, error }` rather than throwing across the
1063
+ // bridge, because the one thing this screen must never do is leave her
1064
+ // believing a setting took when it did not.
1065
+ ipcMain.handle('zero:settings-read', () => {
1066
+ try {
1067
+ return { ok: true, ...readSettings({ config, supervisor, store }) };
1068
+ } catch (err) {
1069
+ return { ok: false, error: String(err.message) };
1070
+ }
1071
+ });
1072
+
1073
+ // CHECK AGAIN, from the last card of the walk. The card will not open the
1074
+ // inbox on a Mac with no Claude Code on it, so the way through it is to
1075
+ // install Claude Code and press this. It throws the remembered shell answer
1076
+ // away first, because a button that says it looked again and reads a thirty
1077
+ // second old memory is the same broken promise as a key that does nothing.
1078
+ ipcMain.handle('zero:claude-recheck', () => {
1079
+ try {
1080
+ return { ok: true, workspace: recheckClaude(config) };
1081
+ } catch (err) {
1082
+ return { ok: false, error: String(err.message) };
1083
+ }
1084
+ });
1085
+
1086
+ // AND THE SAME BUTTON FOR THE SECOND CODING AGENT, from the Codex card on
1087
+ // Settings. It earns its place for a sharper reason than the walk's: what the
1088
+ // search finds lands on `config.codexBin`, which is the fact `engineChoices`
1089
+ // reads, so a press that finds Codex is also what makes the Coding agent row
1090
+ // appear -- without the restart a main-process fact usually costs.
1091
+ ipcMain.handle('zero:codex-recheck', () => {
1092
+ try {
1093
+ return { ok: true, workspace: recheckCodex(config) };
1094
+ } catch (err) {
1095
+ return { ok: false, error: String(err.message) };
1096
+ }
1097
+ });
1098
+
1099
+ /* * ADD A CODEX ACCOUNT: make the folder, register the login, hand back the one line the
1100
+ terminal has to run.
1101
+ */
1102
+ ipcMain.handle('zero:codex-add-account', () => {
1103
+ try {
1104
+ const made = addCodexAccount({ config, supervisor });
1105
+ pendingLogin = made.home;
1106
+ // The shell from a previous add is holding the previous folder, so it is
1107
+ // ended: a terminal that says one thing in its prompt and signs another
1108
+ // account in is the defect this whole row has been about.
1109
+ try { terminals.close(JSON.stringify(SETTINGS_TERMINAL)); } catch { /* none open */ }
1110
+ return { ok: true, ...made, workspace: readSettings({ config, supervisor, store }).workspace };
1111
+ } catch (err) {
1112
+ return { ok: false, error: String(err.message) };
1113
+ }
1114
+ });
1115
+
1116
+ /* * ADD A CLAUDE ACCOUNT, which is the same three steps as its Codex twin above:
1117
+ make the folder, register the login, hand back the one line the terminal has to
1118
+ run. It exists from 2026-09-22 (w-3498e0cad2), when she withdrew the rule that
1119
+ kept the Claude card from offering one.
1120
+ */
1121
+ ipcMain.handle('zero:claude-add-account', () => {
1122
+ try {
1123
+ const made = addClaudeAccount({ config, supervisor });
1124
+ pendingLogin = made.home;
1125
+ try { terminals.close(JSON.stringify(SETTINGS_TERMINAL)); } catch { /* none open */ }
1126
+ return { ok: true, ...made, workspace: readSettings({ config, supervisor, store }).workspace };
1127
+ } catch (err) {
1128
+ return { ok: false, error: String(err.message) };
1129
+ }
1130
+ });
1131
+
1132
+ ipcMain.handle('zero:settings-set-project', (_e, payload) => {
1133
+ try {
1134
+ setProjectSetting({ config, supervisor }, payload ?? {});
1135
+ push();
1136
+ return { ok: true, ...readSettings({ config, supervisor, store }) };
1137
+ } catch (err) {
1138
+ return { ok: false, error: String(err.message) };
1139
+ }
1140
+ });
1141
+
1142
+ ipcMain.handle('zero:settings-set-workspace', (_e, payload) => {
1143
+ try {
1144
+ setWorkspaceSetting({ config, supervisor }, payload ?? {});
1145
+ push();
1146
+ return { ok: true, ...readSettings({ config, supervisor, store }) };
1147
+ } catch (err) {
1148
+ return { ok: false, error: String(err.message) };
1149
+ }
1150
+ });
1151
+
1152
+ /* --------------------- what a project is called, and its mark ------------ */
1153
+ // Both of these write into the PROJECT'S OWN project.json rather than into her
1154
+ // config, and main/project-identity.mjs says at length why. They are separate
1155
+ // handlers rather than two more keys on settings-set-project because that one
1156
+ // is handed `{ config, supervisor }`, and neither of these is a preference
1157
+ // about a folder: they need the folder itself, which only the store resolves.
1158
+ //
1159
+ // THE FOLDER IS LOOKED UP HERE AND NOWHERE ELSE. A slug naming no product is
1160
+ // refused before anything touches the disk, so a stale sidebar row cannot aim
1161
+ // a write at a folder that has been deleted underneath it.
1162
+ const productDirOf = (slug) => store.listProducts().find((p) => p.slug === slug)?.dir ?? null;
1163
+
1164
+ ipcMain.handle('zero:project-rename', (_e, { product, name } = {}) => {
1165
+ try {
1166
+ const dir = productDirOf(product);
1167
+ if (!dir) return { ok: false, error: 'That project could not be found.' };
1168
+ const saved = setProjectName(dir, name);
1169
+ // PUSH, because the name is on far more than the screen she typed it on:
1170
+ // the rail, every row's byline, the composer's picker and ⌘K all print it,
1171
+ // and a rename that only redrew Settings would leave the rest of the app
1172
+ // calling it the old thing until something else happened to refresh.
1173
+ push();
1174
+ return { ok: true, name: saved, ...readSettings({ config, supervisor, store }) };
1175
+ } catch (err) {
1176
+ return { ok: false, error: String(err.message) };
1177
+ }
1178
+ });
1179
+
1180
+ // Opens the picker AND does the copy, in one round trip. Two handlers would
1181
+ // mean the renderer briefly holds a path into her Downloads folder and hands
1182
+ // it back to be trusted; this way the only path that crosses the bridge is one
1183
+ // the open panel itself produced inside this same call.
1184
+ ipcMain.handle('zero:project-icon', async (_e, { product } = {}) => {
1185
+ try {
1186
+ const dir = productDirOf(product);
1187
+ if (!dir) return { ok: false, error: 'That project could not be found.' };
1188
+ const { dialog } = await import('electron');
1189
+ const r = await dialog.showOpenDialog(window, {
1190
+ title: 'Pick a picture for this project',
1191
+ buttonLabel: 'Use this picture',
1192
+ properties: ['openFile'],
1193
+ filters: [{ name: 'Pictures', extensions: ICON_KINDS.map((e) => e.slice(1)) }],
1194
+ });
1195
+ // Cancelling is not a failure and must not draw an error. `ok` with nothing
1196
+ // changed is the honest answer: she opened the panel and closed it again.
1197
+ if (r.canceled || !r.filePaths?.length) return { ok: true, canceled: true };
1198
+ setProjectIcon(dir, r.filePaths[0]);
1199
+ push();
1200
+ return { ok: true, ...readSettings({ config, supervisor, store }) };
1201
+ } catch (err) {
1202
+ return { ok: false, error: String(err.message) };
1203
+ }
1204
+ });
1205
+
1206
+ ipcMain.handle('zero:project-icon-clear', (_e, { product } = {}) => {
1207
+ try {
1208
+ const dir = productDirOf(product);
1209
+ if (!dir) return { ok: false, error: 'That project could not be found.' };
1210
+ clearProjectIcon(dir);
1211
+ push();
1212
+ return { ok: true, ...readSettings({ config, supervisor, store }) };
1213
+ } catch (err) {
1214
+ return { ok: false, error: String(err.message) };
1215
+ }
1216
+ });
1217
+
1218
+ // Her rules for ONE project, the layer that did not exist. The supervisor
1219
+ // owns the file and the injection (projectInstructionsFile, spawnPlan); this
1220
+ // is only the window onto it, exactly as standing-read/write is.
1221
+ ipcMain.handle('zero:project-instructions-read', (_e, { product }) => {
1222
+ try {
1223
+ return { ok: true, text: supervisor.readProjectInstructions(product) };
1224
+ } catch (err) {
1225
+ return { ok: false, text: '', error: String(err.message) };
1226
+ }
1227
+ });
1228
+
1229
+ ipcMain.handle('zero:project-instructions-write', (_e, { product, text }) => {
1230
+ try {
1231
+ supervisor.writeProjectInstructions(product, text);
1232
+ return { ok: true };
1233
+ } catch (err) {
1234
+ return { ok: false, error: String(err.message) };
1235
+ }
1236
+ });
1237
+
1238
+ // Attachments: pasted images (base64) or dropped files (source paths) land
1239
+ // in the product's attachments/ dir and come back as relative paths the
1240
+ // caller embeds in the body as markdown. Relative paths keep them readable
1241
+ // by workers through the MCP and renderable in the message view.
1242
+ ipcMain.handle('zero:save-attachments', async (_e, { product, files }) => {
1243
+ const fs = await import('node:fs');
1244
+ const dir = store.productDir(product);
1245
+ const outDir = path.join(dir, 'attachments');
1246
+ fs.mkdirSync(outDir, { recursive: true });
1247
+ const saved = [];
1248
+ for (const file of files ?? []) {
1249
+ const safe = String(file.name ?? 'file').replace(/[^\w.-]+/g, '-').slice(0, 80);
1250
+ // A RANDOM SUFFIX, THE SAME ONE THE STAGING PATH ALREADY USES. The name
1251
+ // was the millisecond plus her filename, so two files saved in the same
1252
+ // millisecond with the same name overwrote each other and the message
1253
+ // ended up with two links to one picture. She sends screenshots in pairs,
1254
+ // which is exactly the shape that collides. It has never been observed
1255
+ // happening; it is one line to make impossible.
1256
+ const rel = `attachments/${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safe}`;
1257
+ const abs = path.join(dir, rel);
1258
+ if (file.dataBase64) fs.writeFileSync(abs, Buffer.from(file.dataBase64, 'base64'));
1259
+ else if (file.srcPath) fs.copyFileSync(file.srcPath, abs);
1260
+ else continue;
1261
+ saved.push({ rel, name: safe, image: /\.(png|jpe?g|gif|webp)$/i.test(safe) });
1262
+ }
1263
+ return saved;
1264
+ });
1265
+
1266
+ // A PASTED SCREENSHOT GOES TO DISK THE MOMENT IT IS PASTED.
1267
+ //
1268
+ // She was right and the constraint was ours. Pasted bytes used to ride in the
1269
+ // draft as base64, and a draft lives in localStorage, whose quota MEASURED on
1270
+ // this machine is 5,241,856 characters for the WHOLE app. Seven of the
1271
+ // ninety-one screenshots she has actually pasted are over that; the largest
1272
+ // is 6.08 MB.
1273
+ //
1274
+ // Raising the number cannot reach her bar: even the whole quota is under 4 MB
1275
+ // of image, and Claude's own limit is 10 MB base64 per image (about 7.5 MB of
1276
+ // file) at up to 8000x8000. localStorage is simply the wrong place for image
1277
+ // bytes. So they go here instead, and the draft keeps a path — which the draft
1278
+ // format ALREADY stores for free, because `srcPath` attachments cost nothing
1279
+ // and are never the ones dropped. There is no image budget left to hit.
1280
+ //
1281
+ // Staged, not saved: this is not the product's attachments dir. A pasted image
1282
+ // only becomes an attachment when she sends, and `zero:save-attachments`
1283
+ // copies it there. What she pastes and abandons is swept below.
1284
+ //
1285
+ // A PICTURE SHE DRAGS IN IS STAGED TOO, AND THAT IS `srcPath`.
1286
+ //
1287
+ // with a shot of the reply box holding a broken thumbnail labelled
1288
+ // `fun-mode.png`. A paste was already fine; a DRAG was not, and the two take
1289
+ // different routes. A dropped file arrives with a real path from
1290
+ // `webUtils.getPathForFile`, so nothing was ever copied and the thumbnail
1291
+ // asked for `astral-img://file/Users/you/…` somewhere out on the disk.
1292
+ // `servable` in main/img-scheme.mjs answers that with a 403, on purpose: the
1293
+ // window may only draw pictures inside the account root or a product's repo,
1294
+ // and widening that to the whole disk is the trade that scheme exists to
1295
+ // refuse. Measured against the real module, her account root as the only
1296
+ // root:
1297
+ //
1298
+ // /…/00000000-/.staging/…-pasted-33035.png servable true
1299
+ // /Users/you/Desktop/fun-mode.png servable false
1300
+ //
1301
+ // So the picture comes inside instead. A copy into the same `.staging` folder
1302
+ // a paste lands in makes the path servable, gets swept on the same week, and
1303
+ // costs nothing at send time because `zero:save-attachments` above already
1304
+ // copies from `srcPath`. Bytes are copied on disk rather than carried through
1305
+ // this bridge as base64, which a file picked off disk should never need.
1306
+ ipcMain.handle('zero:stage-attachment', async (_e, { name, dataBase64, srcPath }) => {
1307
+ const fs = await import('node:fs');
1308
+ const dir = path.join(config.accountRoot, '.staging');
1309
+ fs.mkdirSync(dir, { recursive: true });
1310
+ // Sweep on the way in rather than on a timer: an abandoned paste is only
1311
+ // ever discovered by the next one, and a week is longer than any draft she
1312
+ // has ever left open. Failure to sweep must never fail the paste.
1313
+ try {
1314
+ const week = Date.now() - 7 * 24 * 60 * 60 * 1000;
1315
+ for (const f of fs.readdirSync(dir)) {
1316
+ const p = path.join(dir, f);
1317
+ try { if (fs.statSync(p).mtimeMs < week) fs.unlinkSync(p); } catch { /* leave it */ }
1318
+ }
1319
+ } catch { /* a sweep that cannot run is not a paste that failed */ }
1320
+ const safe = String(name ?? 'pasted.png').replace(/[^\w.-]+/g, '-').slice(0, 80);
1321
+ const abs = path.join(dir, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safe}`);
1322
+ // A path is a drag, bytes are a paste. Already inside the folder we serve
1323
+ // from is left alone: copying it would only make a second file to sweep.
1324
+ if (typeof srcPath === 'string' && srcPath) {
1325
+ if (path.resolve(srcPath) === path.resolve(path.join(dir, path.basename(srcPath)))) return { path: srcPath };
1326
+ fs.copyFileSync(srcPath, abs);
1327
+ return { path: abs };
1328
+ }
1329
+ fs.writeFileSync(abs, Buffer.from(String(dataBase64 ?? ''), 'base64'));
1330
+ return { path: abs };
1331
+ });
1332
+
1333
+ // Unstick ONE stalled item: forget its answer's delivery mark and tick, so
1334
+ // a fresh worker picks the answer up now.
1335
+ ipcMain.handle('zero:redeliver', (_e, { product, id }) => {
1336
+ if (isAgentRow(id)) return { ok: false };
1337
+ const item = store.listItems().find((i) => i.product === product && i.id === id);
1338
+ if (!item?.answer || item.answer === '(withdrawn)') return { ok: false, error: 'no live answer to redeliver' };
1339
+ supervisor.liftBrakeForHer?.();
1340
+ supervisor.redeliverAnswer(item, item.answer);
1341
+ supervisor.tick?.().catch(() => {});
1342
+ return { ok: true };
1343
+ });
1344
+
1345
+ // Respawn what a mass die-off (usage limit, crash) left stranded. With `ids`,
1346
+ // only those: a selection in the list is her saying which of the stopped rows
1347
+ // are still worth her plan.
1348
+ ipcMain.handle('zero:resume-agents', (_e, arg) => {
1349
+ const ids = Array.isArray(arg?.ids) ? arg.ids : null;
1350
+ const cleared = supervisor.resumeStopped(ids);
1351
+ return { ok: true, cleared, ...supervisor.status() };
1352
+ });
1353
+
1354
+ // Put a worker back on named rows whatever state they are in, including the
1355
+ // finished ones no other path can reach. Ids only: this is her pointing.
1356
+ ipcMain.handle('zero:resume-items', (_e, arg) => {
1357
+ const ids = Array.isArray(arg?.ids) ? arg.ids : [];
1358
+ return { ok: true, ...supervisor.resumeItems(ids), ...supervisor.status() };
1359
+ });
1360
+
1361
+ // `zero:pause-product` stopped one project and is gone with it
1362
+ // (w-d19d6d387c, 2026-09-22). `zero:pause` still holds the whole fleet.
1363
+
1364
+ // Open a worker-written artifact path. Workers write paths relative to
1365
+ // whichever root they were thinking about (docs dir, designs/, attachments/,
1366
+ // the repo), so a single-root file:// href fails silently on the rest.
1367
+ // Images already resolve across these roots to render; links open the same
1368
+ // way. Absolute and file:// paths open as-is if they exist.
1369
+ // mode 'resolve' returns the found path without opening it, so the renderer
1370
+ // can render hrefs that are true (Copy Link must hand out a URL that works).
1371
+ //
1372
+ // EVERY GUESS STAYS INSIDE THE CARD'S OWN PRODUCT (main/artifact-path.mjs).
1373
+ // It used to fall through to every other product's folders, which is how nine
1374
+ // of eleven chips on one card opened two other products' files (her answer,
1375
+ // 2026-08-19). When it finds nothing it now hands back a sentence she can
1376
+ // read, and the renderer shows it.
1377
+ ipcMain.handle('zero:open-artifact', async (_e, { product, src, mode }) => {
1378
+ const { shell } = await import('electron');
1379
+ if (typeof src !== 'string' || !src) return { ok: false, error: 'no path' };
1380
+ if (/^https?:/.test(src)) { if (mode !== 'resolve') shell.openExternal(src); return { ok: true, opened: src } }
1381
+ const found = resolveArtifact({
1382
+ src,
1383
+ product,
1384
+ products: store.listProducts(),
1385
+ accountRoot: config.accountRoot,
1386
+ storeRoot: config.storeRoot,
1387
+ });
1388
+ if (!found.ok) return { ok: false, error: found.error };
1389
+ if (mode === 'resolve') {
1390
+ // RESOLVING FOR THE PANE IS WHAT OPENS THE DOOR. An html page is drawn
1391
+ // in a frame of its own and its pictures sit beside it on disk, so the
1392
+ // file's folder and this product's roots become readable under the
1393
+ // document scheme, and the url the frame should point at comes back with
1394
+ // the path. Nothing else on disk is reachable from that page. A markdown
1395
+ // file is read through zero:read-doc and never needs this.
1396
+ if (docGrants && /\.html?$/i.test(found.path)) {
1397
+ const prod = store.listProducts().find((p) => p.slug === product) ?? null;
1398
+ docGrants.grant(found.path, artifactRoots(prod));
1399
+ return { ok: true, opened: found.path, url: docUrl(found.path) };
1400
+ }
1401
+ return { ok: true, opened: found.path };
1402
+ }
1403
+ if (mode === 'source') {
1404
+ try { await openSourceFile(found.path); return { ok: true, opened: found.path }; }
1405
+ catch (error) { return { ok: false, error: error.message || 'The text editor could not be opened.' }; }
1406
+ }
1407
+ const err = await shell.openPath(found.path);
1408
+ return err ? { ok: false, error: err } : { ok: true, opened: found.path };
1409
+ });
1410
+
1411
+ // THE DOCUMENT PANE READS AND WRITES THE FILE ITSELF. A path goes through
1412
+ // the very same finder the links and the chips use, so the pane opens
1413
+ // exactly the file a click would have opened, and then main hands back the
1414
+ // text rather than the path. What may be written is decided in
1415
+ // main/doc-file.mjs: markdown only, inside this product's own folders only.
1416
+ ipcMain.handle('zero:read-doc', async (_e, { product, src }) => {
1417
+ const found = resolveArtifact({
1418
+ src,
1419
+ product,
1420
+ products: store.listProducts(),
1421
+ accountRoot: config.accountRoot,
1422
+ storeRoot: config.storeRoot,
1423
+ });
1424
+ if (!found.ok) return { ok: false, error: found.error };
1425
+ return readDoc({ file: found.path });
1426
+ });
1427
+
1428
+ // THE CHANGE A RUN MADE. Same finder as every other artifact, so a click on
1429
+ // the chip and a click on a path in her message open the same file.
1430
+ ipcMain.handle('zero:code-change', async (_e, { product, src }) => {
1431
+ const found = resolveArtifact({
1432
+ src,
1433
+ product,
1434
+ products: store.listProducts(),
1435
+ accountRoot: config.accountRoot,
1436
+ storeRoot: config.storeRoot,
1437
+ });
1438
+ if (!found.ok) return { ok: false, error: found.error };
1439
+ try {
1440
+ return { ok: true, change: readChange(found.path) };
1441
+ } catch (e) {
1442
+ return { ok: false, error: `that change could not be read (${e.message})` };
1443
+ }
1444
+ });
1445
+
1446
+ // ONE FILE INSIDE A CHANGE, READ AND WRITTEN.
1447
+ //
1448
+ // `src` is the change artifact and `path` is one file inside it, which is the
1449
+ // same pair the pane already has on screen. The change is re-read here rather
1450
+ // than trusted from the renderer, because it is what says which folders a
1451
+ // write may land in and that decision may not come from the window.
1452
+ const openChange = (product, src) => {
1453
+ const found = resolveArtifact({
1454
+ src,
1455
+ product,
1456
+ products: store.listProducts(),
1457
+ accountRoot: config.accountRoot,
1458
+ storeRoot: config.storeRoot,
1459
+ });
1460
+ if (!found.ok) return { ok: false, error: found.error };
1461
+ try {
1462
+ return { ok: true, change: readChange(found.path), prod: store.listProducts().find((p) => p.slug === product) ?? null };
1463
+ } catch (e) {
1464
+ return { ok: false, error: `that change could not be read (${e.message})` };
1465
+ }
1466
+ };
1467
+
1468
+ ipcMain.handle('zero:code-file', async (_e, { product, src, path: filePath }) => {
1469
+ const opened = openChange(product, src);
1470
+ if (!opened.ok) return opened;
1471
+ const found = findFile({ change: opened.change, product: opened.prod, filePath });
1472
+ if (!found.ok) return found;
1473
+ return readCodeFile({ file: found.path });
1474
+ });
1475
+
1476
+ ipcMain.handle('zero:save-code-file', async (_e, { product, src, path: filePath, text, mtime }) => {
1477
+ const opened = openChange(product, src);
1478
+ if (!opened.ok) return opened;
1479
+ const found = findFile({ change: opened.change, product: opened.prod, filePath });
1480
+ if (!found.ok) return found;
1481
+ return writeCodeFile({ file: found.path, change: opened.change, product: opened.prod, text, mtime });
1482
+ });
1483
+
1484
+ ipcMain.handle('zero:write-doc', async (_e, { product, src, text, mtime }) => {
1485
+ const prod = store.listProducts().find((p) => p.slug === product) ?? null;
1486
+ const found = resolveArtifact({
1487
+ src,
1488
+ product,
1489
+ products: store.listProducts(),
1490
+ accountRoot: config.accountRoot,
1491
+ storeRoot: config.storeRoot,
1492
+ });
1493
+ if (!found.ok) return { ok: false, error: found.error };
1494
+ return writeDoc({ file: found.path, product: prod, text, mtime });
1495
+ });
1496
+
1497
+ // The work behind a review, viewable: commits and diff of a branch against
1498
+ // main in the product's repo. Read-only, size-capped. THE SIDEBAR NOTE. It
1499
+ // is a file in the product's own folder and nothing else, so unlike the
1500
+ // document pane there is no path to resolve and no path to check: the slug
1501
+ // picks the product, the product owns the path. See main/rail-note.mjs for
1502
+ // why it is a file at all.
1503
+ ipcMain.handle('zero:rail-note', (_e, { product }) => {
1504
+ const prod = store.listProducts().find((p) => p.slug === product) ?? null;
1505
+ if (!prod) return { ok: false, error: 'That project could not be found.' };
1506
+ return readNote({ product: prod });
1507
+ });
1508
+
1509
+ ipcMain.handle('zero:save-rail-note', (_e, { product, text }) => {
1510
+ const prod = store.listProducts().find((p) => p.slug === product) ?? null;
1511
+ if (!prod) return { ok: false, error: 'That project could not be found.' };
1512
+ return writeNote({ product: prod, text });
1513
+ });
1514
+
1515
+ // The trace: what each session on this item actually did, from the logs the
1516
+ // supervisor persists under <product>/sessions/<item-id>/. Read-only.
1517
+ ipcMain.handle('zero:session-trace', async (_e, { product, id }) => {
1518
+ const fs = await import('node:fs');
1519
+ const path = await import('node:path');
1520
+ const dir = store.listProducts().find((p) => p.slug === product)?.dir;
1521
+ if (!dir || !/^[\w-]+$/.test(String(id ?? ''))) return { ok: false, sessions: [] };
1522
+ const traceDir = machineryPath(dir, path.join('sessions', id));
1523
+ let files = [];
1524
+ try {
1525
+ files = fs.readdirSync(traceDir).filter((f) => f.endsWith('.log')).sort();
1526
+ } catch { return { ok: true, sessions: [] }; }
1527
+ const sessions = files.slice(-20).map((f) => {
1528
+ let text = '';
1529
+ try { text = fs.readFileSync(path.join(traceDir, f), 'utf8'); } catch {}
1530
+ if (text.length > 120_000) text = `… truncated (${text.length} bytes total)\n\n${text.slice(-120_000)}`;
1531
+ return { startedAt: Number(f.replace('.log', '')) || 0, text };
1532
+ });
1533
+ return { ok: true, sessions };
1534
+ });
1535
+
1536
+ // The thread history behind the time under a task's title. Raw ledger lines,
1537
+ // read fresh: the renderer owns what they mean, this owns getting them across.
1538
+ ipcMain.handle('zero:item-history', async (_e, { product, id }) => {
1539
+ try {
1540
+ return { ok: true, lines: store.readHistory(product, id) };
1541
+ } catch (err) {
1542
+ return { ok: false, error: err.message, lines: [] };
1543
+ }
1544
+ });
1545
+
1546
+ // UPDATES (main/updater.mjs). Two doors and no more: look now, and install
1547
+ // what has been downloaded. There is deliberately no "download" door, because
1548
+ // the download already happened on its own; the only thing left for a person
1549
+ // to decide is when the app is allowed to disappear and come back.
1550
+ ipcMain.handle('zero:update-check', async () => updater.check({ manual: true }));
1551
+ ipcMain.handle('zero:update-install', () => ({ started: updater.install() }));
1552
+
1553
+ // Handed back so the Cmd+Y/Cmd+N chord answers through the same door the
1554
+ // buttons do, rather than reaching past it into approvals.answer.
1555
+ // `push` goes with it so the updater can make the window refetch when a
1556
+ // download finishes, without holding the window itself.
1557
+ return { answerApproval, push };
1558
+ }