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
@@ -0,0 +1,523 @@
1
+ // Project store: <store root>/<product>/ is the truth. Single writer (the main
2
+ // process). All JSON writes are atomic (tmp + rename); index.json spares
3
+ // directory walks; names are sanitized with collision handling.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import crypto from 'node:crypto';
9
+ import { SCHEMA_VERSION, validateCreation, creationText, TEXT_CREATION_KINDS } from '../../shared/contracts.mjs';
10
+ import { assertInside, assertCreationDir, deniesGitOrEnvPath } from './paths.mjs';
11
+ import { migrateIndex } from './migrate.mjs';
12
+ import { withProjectLockSync } from './project-lock.mjs';
13
+ import { Name, readEnv } from '../../shared/product-name.mjs';
14
+
15
+ // The machine-wide root: ASTRAL_HOME, else the folder named below. This is
16
+ // where device-local files live (settings.json, ui-state.json, usage.json,
17
+ // .runtime-pids, ...) and where a founder's PRE-account projects sit until they
18
+ // are migrated into an account space. It is NEVER account-scoped: those files
19
+ // stay global (Phase 1).
20
+ //
21
+ // THE FALLBACK IS THE APP'S OWN NAME NOW, where it used to be an older
22
+ // product's folder.
23
+ //
24
+ // NOTHING WAS DELETED AND NOTHING MOVED. That folder is still on disk exactly
25
+ // as it was. What changed is that this app stops reaching for it by default.
26
+ // Her live store is named in zero.config.json and handed to every worker as
27
+ // the app's own home variable, so this line was never the path she runs on; it
28
+ // was only ever
29
+ // the answer for an install carrying no config at all.
30
+ //
31
+ // An older product's HOME env var used to sit between these two. It is gone:
32
+ // nothing sets it any more now that the store server carries the app's own name.
33
+ export function legacyRoot() {
34
+ return readEnv('HOME') || path.join(os.homedir(), Name);
35
+ }
36
+
37
+ // The account currently signed in, if any. Set once at boot and re-resolved on
38
+ // every auth change (main/ipc.mjs), so dataRoot answers per-account without
39
+ // every caller having to thread a userId through. One account is active per
40
+ // running app at a time; switching accounts re-inits (see the account-scoped
41
+ // persistence spec).
42
+ let currentAccountId = null;
43
+
44
+ // A user id sanitized to a safe single path segment (Supabase ids are UUIDs;
45
+ // this is belt-and-braces so a hostile/odd id can never escape accounts/).
46
+ function sanitizeAccountId(userId) {
47
+ return String(userId ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '').slice(0, 64) || 'account';
48
+ }
49
+
50
+ export function accountRootFor(userId) {
51
+ return path.join(legacyRoot(), 'accounts', sanitizeAccountId(userId));
52
+ }
53
+
54
+ // Enter an account's data space. All later dataRoot answers resolve here.
55
+ export function setAccountScope(userId) {
56
+ currentAccountId = userId ? sanitizeAccountId(userId) : null;
57
+ return dataRoot();
58
+ }
59
+
60
+ // Leave the account space (sign-out). Signed out is DEV-ONLY (packaged builds
61
+ // always gate), and resolves back to the legacy root, exactly the pre-account
62
+ // behavior, so the founder's dev workflow is unchanged.
63
+ export function clearAccountScope() {
64
+ currentAccountId = null;
65
+ }
66
+
67
+ export function getAccountId() {
68
+ return currentAccountId;
69
+ }
70
+
71
+ // The active PROJECT data root. Account-aware: signed in as user X it is
72
+ // <legacyRoot>/accounts/<X>; signed out (dev only) it is the legacy root
73
+ // itself. Machine-wide files deliberately use legacyRoot instead, so they
74
+ // stay global across accounts.
75
+ export function dataRoot() {
76
+ const base = legacyRoot();
77
+ return currentAccountId ? path.join(base, 'accounts', currentAccountId) : base;
78
+ }
79
+
80
+ export function sanitizeName(name) {
81
+ const slug = String(name ?? '').trim().toLowerCase()
82
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
83
+ return slug || 'untitled';
84
+ }
85
+
86
+ export function atomicWriteJson(file, obj) {
87
+ const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
90
+ fs.renameSync(tmp, file);
91
+ }
92
+
93
+ export function readJson(file, fallback = null) {
94
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
95
+ }
96
+
97
+ function emptyIndex() {
98
+ return { schemaVersion: SCHEMA_VERSION, creations: [], runs: [], reports: [], updatedAt: null };
99
+ }
100
+
101
+ // Read index.json, migrating a v1 shape to v2 on the way in. The migrated
102
+ // result is written back only when it actually differs from what was on
103
+ // disk, so a project already on v2 never takes a spurious write.
104
+ export function readIndex(projectDir) {
105
+ const file = path.join(projectDir, 'index.json');
106
+ const raw = readJson(file, emptyIndex());
107
+ const idx = migrateIndex(raw);
108
+ if (idx !== raw) atomicWriteJson(file, idx);
109
+ return idx;
110
+ }
111
+
112
+ export function createProject(name, root = dataRoot()) {
113
+ fs.mkdirSync(root, { recursive: true });
114
+ let slug = sanitizeName(name);
115
+ let dir = path.join(root, slug);
116
+ for (let n = 2; fs.existsSync(dir); n++) { // collision -> suffix
117
+ slug = `${sanitizeName(name)}-${n}`;
118
+ dir = path.join(root, slug);
119
+ }
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ // `runs` CAME OUT OF THIS LIST. it holds the record of what each run changed,
122
+ // and home.mjs carries it to the app's home. Making it here put an empty
123
+ // folder of our machinery into every brand-new product, which is the thing a
124
+ // new user watched happen and asked us not to do. The rest of this list is
125
+ // hers.
126
+ for (const sub of ['personas', 'transcripts', 'reports', 'designs', 'creations']) {
127
+ fs.mkdirSync(path.join(dir, sub), { recursive: true });
128
+ }
129
+ const project = { schemaVersion: SCHEMA_VERSION, id: slug, name: String(name ?? slug), createdAt: new Date().toISOString() };
130
+ atomicWriteJson(path.join(dir, 'project.json'), project);
131
+ atomicWriteJson(path.join(dir, 'index.json'), emptyIndex());
132
+ return { ...project, dir };
133
+ }
134
+
135
+ // The notes doc a brand-new project opens with. A first-timer reads this, so
136
+ // it must be a friendly, clean starting page: NO scaffolding comments. The
137
+ // agent keeps the index in shape from its skills; the doc itself stays plain
138
+ // prose.
139
+ export function starterNotesDoc(name) {
140
+ return `# ${name}\n\nThis is your product's living map. I keep it short and current as we work: the decisions we make, where things stand, and where the real detail lives. Everything we build together shows up beside this.\n\n## Decisions\n\nNothing decided yet.\n\n## Where we are\n\nJust getting started.\n`;
141
+ }
142
+
143
+ export function listProjects(root = dataRoot()) {
144
+ if (!fs.existsSync(root)) return [];
145
+ return fs.readdirSync(root, { withFileTypes: true })
146
+ // A project may be a symlink into another store root (one physical dir
147
+ // reachable from two roots); a Dirent reports a symlink as
148
+ // not-a-directory, so resolve with stat before filtering.
149
+ .filter((e) => {
150
+ try { return fs.statSync(path.join(root, e.name)).isDirectory(); } catch { return false; }
151
+ })
152
+ .map((e) => {
153
+ const p = readJson(path.join(root, e.name, 'project.json'));
154
+ if (!p) return null;
155
+ const idx = readJson(path.join(root, e.name, 'index.json'), emptyIndex());
156
+ // cloudId and archived ride along because consumers dedupe against them:
157
+ // the app's cloud bootstrap checks `local.cloudId === row.project_id` to
158
+ // decide a registry row is already represented locally. When this list
159
+ // omitted cloudId, that check never matched, and the bootstrap adopted a
160
+ // same-NAME project instead: it stamped a stale duplicate's cloudId onto
161
+ // the real Cascade, and the next sync pull overwrote the real project's
162
+ // identity with the duplicate's (2026-08-04).
163
+ return { id: p.id, name: p.name, createdAt: p.createdAt, updatedAt: idx.updatedAt, dir: path.join(root, e.name), cloudId: p.cloudId ?? null, archived: p.archived === true };
164
+ })
165
+ .filter(Boolean)
166
+ .sort((a, b) => String(b.updatedAt ?? b.createdAt).localeCompare(String(a.updatedAt ?? a.createdAt)));
167
+ }
168
+
169
+ export function loadProject(id, root = dataRoot()) {
170
+ const dir = path.join(root, sanitizeName(id));
171
+ const p = readJson(path.join(dir, 'project.json'));
172
+ return p ? { ...p, dir } : null;
173
+ }
174
+
175
+ // Read a project by the FOLDER a caller already has, not by re-deriving one from
176
+ // an id. loadProject answers "the project in the folder named after this id",
177
+ // which is a different question whenever a product's id and its folder have
178
+ // drifted apart (two products can even carry the same id, so no id-based lookup
179
+ // can tell them apart). Anything that has already resolved a product should keep
180
+ // hold of its directory and come back through here.
181
+ export function loadProjectAt(dir) {
182
+ const p = readJson(path.join(dir, 'project.json'));
183
+ return p ? { ...p, dir } : null;
184
+ }
185
+
186
+ export function renameProject(id, newName, root = dataRoot()) {
187
+ const name = String(newName ?? '').trim();
188
+ if (!name) return null;
189
+ return patchProject(id, { name }, root);
190
+ }
191
+
192
+ // Delete a product for good: the folder is erased from disk (rm, not Trash).
193
+ // Containment is belt-and-braces: the id resolves through sanitizeName to a
194
+ // DIRECT child of the data root, and that child must actually be a project
195
+ // (project.json present), so a stray id can never point the rm anywhere else.
196
+ export function deleteProject(id, root = dataRoot()) {
197
+ const slug = sanitizeName(id);
198
+ const dir = path.join(root, slug);
199
+ if (path.dirname(path.resolve(dir)) !== path.resolve(root)) throw new Error('refusing: path escapes the data root');
200
+ if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`not a product folder: ${slug}`);
201
+ fs.rmSync(dir, { recursive: true, force: true });
202
+ return true;
203
+ }
204
+
205
+ // Async twin of deleteProject. A product that has ever been built is a git repo
206
+ // with a node_modules tree: a synchronous rmSync of it blocks the whole main
207
+ // process for seconds (the frozen "stuck on …" delete). fs.promises.rm yields
208
+ // to the event loop so the app stays responsive while the folder is erased.
209
+ // Same containment guards as the sync version, so a stray id can never point
210
+ // the rm outside the data root.
211
+ export async function deleteProjectAsync(id, root = dataRoot()) {
212
+ const slug = sanitizeName(id);
213
+ const dir = path.join(root, slug);
214
+ if (path.dirname(path.resolve(dir)) !== path.resolve(root)) throw new Error('refusing: path escapes the data root');
215
+ if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`not a product folder: ${slug}`);
216
+ await fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 });
217
+ return true;
218
+ }
219
+
220
+ // Shallow-merge fields into project.json (e.g. lastSessionId for resume).
221
+ export function patchProject(id, patch, root = dataRoot()) {
222
+ const p = loadProject(id, root);
223
+ if (!p) return null;
224
+ return patchProjectAt(p.dir, patch);
225
+ }
226
+
227
+ // The same write, aimed at a folder a caller already resolved. This is the safe
228
+ // one: a write that re-derives its folder from an id lands wherever that id
229
+ // happens to name, which is not always the product the caller looked up.
230
+ export function patchProjectAt(dir, patch) {
231
+ const p = loadProjectAt(dir);
232
+ if (!p) return null;
233
+ atomicWriteJson(path.join(dir, 'project.json'), { ...p, dir: undefined, ...patch });
234
+ return { ...p, ...patch };
235
+ }
236
+
237
+ // The ONE place index.json is read, mutated, and written back, which makes it
238
+ // the one place that has to be safe against other PROCESSES.
239
+ //
240
+ // runForProject (main/store/queues.mjs) serializes this within a single process
241
+ // and always did. That was the whole world when the app's main process was the
242
+ // only writer. It is not any more: the MCP server (mcp/) runs as many concurrent
243
+ // children against this same directory, and an in-process promise chain tells
244
+ // them nothing about each other. Two processes both read revision N, both write,
245
+ // and one of them silently loses a document from the catalog.
246
+ //
247
+ // So the read-mutate-write happens under a cross-process file lock. It is taken
248
+ // HERE, precisely, rather than around runForProject: several of that queue's
249
+ // bodies are git pushes and other network work, and holding a lock with a
250
+ // 30-second staleness timer across a slow push would get it broken mid-flight,
251
+ // which is worse than not locking at all. This section is a read, a mutate, and
252
+ // a rename, so it is over in about a millisecond.
253
+ export function updateIndex(projectDir, mutate) {
254
+ return withProjectLockSync(projectDir, () => {
255
+ const file = path.join(projectDir, 'index.json');
256
+ const idx = readIndex(projectDir);
257
+ mutate(idx);
258
+ idx.updatedAt = new Date().toISOString();
259
+ atomicWriteJson(file, idx);
260
+ return idx;
261
+ }, { label: 'updateIndex' });
262
+ }
263
+
264
+ /* --------------------------------- chat --------------------------------- */
265
+ export function appendChat(projectDir, message) {
266
+ fs.appendFileSync(path.join(projectDir, 'chat.jsonl'), JSON.stringify(message) + '\n');
267
+ }
268
+
269
+ // The log is append-only; a message is UPDATED by appending a line with the
270
+ // same id (asks do this when answered). Reading folds duplicates: last line
271
+ // wins, the message keeps its original position in the conversation.
272
+ export function readChat(projectDir) {
273
+ let lines;
274
+ try {
275
+ lines = fs.readFileSync(path.join(projectDir, 'chat.jsonl'), 'utf8').split('\n').filter(Boolean);
276
+ } catch { return []; }
277
+ const order = []; const byId = new Map();
278
+ for (const l of lines) {
279
+ let m; try { m = JSON.parse(l); } catch { continue; } // torn tail line: skip
280
+ if (!m?.id) continue;
281
+ if (!byId.has(m.id)) order.push(m.id);
282
+ byId.set(m.id, { ...(byId.get(m.id) ?? {}), ...m });
283
+ }
284
+ return order.map((id) => byId.get(id));
285
+ }
286
+
287
+ /* ------------------------------- creations ------------------------------- */
288
+ // Envelope in index.json; content in the file at envelope.path (project-relative)
289
+ // for a single-file creation, or under envelope.dir (a git-backed folder,
290
+ // see main/creations/materialize.mjs) for a folder-backed one.
291
+ export function saveCreation(projectDir, envelope, content) {
292
+ // Validate at the sink, not on trust: every catalog writer routes through
293
+ // here, so a malformed envelope (an unregistered kind, path and dir both
294
+ // set) is refused before it can land in index.json. The phantom kind 'doc'
295
+ // lived for a week because nothing enforced the registry at save time.
296
+ const problems = validateCreation(envelope);
297
+ if (problems.length) throw new Error(`saveCreation: invalid envelope: ${problems.join('; ')}`);
298
+ if (envelope.dir) {
299
+ // Folder-backed: the file itself is written by materializeCreation /
300
+ // writeCreationFile, which also commit it. This call only updates the
301
+ // catalog entry; there is no single path to put `content`, so a caller
302
+ // passing real content (instead of null) is a bug in that caller, not
303
+ // silently dropped data.
304
+ if (content != null) {
305
+ throw new Error('saveCreation: content is ignored for a folder-backed creation (dir set); write files via materializeCreation/writeCreationFile instead');
306
+ }
307
+ // A folder-backed envelope's `dir` must be a real subdirectory of
308
+ // creations/ (assertCreationDir, main/store/paths.mjs): plain containment
309
+ // would happily accept "creations" itself, a run directory, or the
310
+ // project root, and every later read/list/write/delete on this envelope
311
+ // trusts `dir` as the creation's root. Reject anything else, and do it
312
+ // BEFORE the catalog write so a bad envelope (e.g. from a compromised
313
+ // sync peer) never lands in index.json at all.
314
+ const absDir = assertCreationDir(projectDir, envelope.dir);
315
+ updateIndex(projectDir, (idx) => {
316
+ idx.creations = idx.creations.filter((c) => c.id !== envelope.id);
317
+ idx.creations.push({ ...envelope, schemaVersion: envelope.schemaVersion ?? SCHEMA_VERSION });
318
+ });
319
+ return absDir;
320
+ }
321
+ const rel = envelope.path;
322
+ if (!rel) throw new Error(`unsafe creation path: ${rel}`);
323
+ // Deny at the SINK, not just in the tool arg branch: a single-file envelope
324
+ // pointing at .git/.env (a compromised sync peer, or a legacy pre-deny
325
+ // envelope) must never write a secret path here, no matter which caller
326
+ // reached us. Folder-backed envelopes (dir set) take the branch above and are
327
+ // denied at their own file IO in materialize.mjs.
328
+ if (deniesGitOrEnvPath(rel)) throw new Error(`refusing to write a .git/.env creation path: ${rel}`);
329
+ const file = assertInside(projectDir, rel);
330
+ fs.mkdirSync(path.dirname(file), { recursive: true });
331
+ // A TEXT KIND REACHES DISK AS TEXT, whatever the caller handed us. Writing an
332
+ // editor's JSON tree here instead is invisible at the time and shows up as a
333
+ // blank panel beside a chat saying the document is open: the viewer tests for
334
+ // a string, an object fails it, and nothing downstream has a branch for that
335
+ // shape. Coerced rather than refused, because the founder's words matter more
336
+ // than the caller's mistake and throwing here would lose them.
337
+ // ...but only where the file itself is text. A text KIND can still hold a
338
+ // structured document when its path says so (a markdown creation pointed at
339
+ // a .json file is an existing, deliberate path), and the envelope's own
340
+ // extension is the declaration rather than something to infer.
341
+ const wantsText = content != null
342
+ && TEXT_CREATION_KINDS.includes(envelope.kind)
343
+ && !/\.json$/i.test(rel);
344
+ const body = wantsText ? creationText(content) : content;
345
+ if (typeof body === 'string') {
346
+ const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
347
+ fs.writeFileSync(tmp, body);
348
+ fs.renameSync(tmp, file);
349
+ } else if (body != null) {
350
+ atomicWriteJson(file, body);
351
+ }
352
+ updateIndex(projectDir, (idx) => {
353
+ idx.creations = idx.creations.filter((c) => c.id !== envelope.id);
354
+ idx.creations.push({ ...envelope, schemaVersion: envelope.schemaVersion ?? SCHEMA_VERSION });
355
+ });
356
+ return file;
357
+ }
358
+
359
+ // Company V1: mark a creation as history (kept forever, hidden from default
360
+ // reading, one click away for the founder) or bring it back to current. `status`
361
+ // and `replacedBy` are OPTIONAL, ADDITIVE envelope metadata: an envelope without
362
+ // them reads as current, so every existing index.json keeps working untouched
363
+ // and an existing user sees zero behavior change. Both fields spread straight
364
+ // through saveCreation/migrateIndex (whole-envelope copies), so no validator or
365
+ // schemaVersion change is needed.
366
+ //
367
+ // CRITICAL SYNC RULE: the write stamps `updatedAt` (NOT `ts`, which is creation
368
+ // time and must not move). The sync merge (main/store/sync.mjs mergeRegistry)
369
+ // picks the newer whole entry by `stamp = Date.parse(updatedAt) || ts`, so a
370
+ // status change MUST carry a parseable-newer updatedAt or a stale envelope from
371
+ // another machine would silently overwrite it. `stamp` runs Date.parse on
372
+ // updatedAt, so it must be an ISO 8601 STRING (a numeric Date.now parses to NaN
373
+ // and would fall back to the old `ts`, losing the merge) -> new
374
+ // Date.toISOString. Clearing back to current DELETES the keys (rather than
375
+ // storing status:'current'), so a reverted creation is byte-identical to one
376
+ // that never had the fields. The three states a piece of work can be in.
377
+ // `current` is the absence of a status (the default, so nothing needs
378
+ // migrating), and the two named ones are both "do not read this as the live
379
+ // answer", for opposite reasons:
380
+ //
381
+ // history superseded. Something replaced it, and replacedBy says what.
382
+ // exploration never adopted. An afternoon of playing around that fizzled
383
+ // without a verdict. Added 2026-07-25 after a round of throwaway
384
+ // landing-page designs kept their default authority for a day
385
+ // and got promoted to "(live answer)" in the index, which then
386
+ // outranked a live web fetch the agent performed itself and
387
+ // produced a go-to-market plan built on a page that was never
388
+ // real. Binary status was the hole: work that fizzles has no
389
+ // winner to point at, so history is the wrong word for it, and
390
+ // the burden of demoting it fell on a founder who had already
391
+ // moved on and had no reason to think about it again.
392
+ export const CREATION_STATUSES = ['current', 'history', 'exploration'];
393
+
394
+ export function setCreationStatus(projectDir, id, { status, replacedBy } = {}) {
395
+ const idx = readIndex(projectDir);
396
+ const envelope = (idx.creations ?? []).find((c) => c.id === id && !c.deleted);
397
+ if (!envelope) throw new Error(`no such creation: ${id}`);
398
+ const next = { ...envelope, updatedAt: new Date().toISOString() };
399
+ if (status === 'history' || status === 'exploration') {
400
+ next.status = status;
401
+ const note = typeof replacedBy === 'string' ? replacedBy.trim() : '';
402
+ if (note) next.replacedBy = note; else delete next.replacedBy;
403
+ } else {
404
+ delete next.status;
405
+ delete next.replacedBy;
406
+ }
407
+ saveCreation(projectDir, next, null);
408
+ return next;
409
+ }
410
+
411
+ // Tombstone delete: the catalog entry is REPLACED, not removed, with
412
+ // { id, deleted: true, ts } so sync and any open tab can tell "gone" from
413
+ // "never existed". The backing file is unlinked (containment-checked); a
414
+ // folder-backed creation's `dir` is removed recursively. A directory-shaped
415
+ // `path` (e.g. a live run) is left on disk, only the catalog entry tombstones.
416
+ // Returns the removed envelope, or null if there was no such creation.
417
+ export function deleteCreation(projectDir, id) {
418
+ const idx = readIndex(projectDir);
419
+ const envelope = (idx.creations ?? []).find((c) => c.id === id);
420
+ if (!envelope) return null;
421
+ const rel = envelope.path;
422
+ if (rel) {
423
+ try {
424
+ const file = assertInside(projectDir, rel);
425
+ // only unlink a real FILE inside the project (never a run directory)
426
+ if (fs.statSync(file).isFile()) fs.unlinkSync(file);
427
+ } catch { /* already gone, unsafe, or a dir: leave it */ }
428
+ }
429
+ if (envelope.dir) {
430
+ // assertCreationDir (not plain assertInside): a tampered/stale envelope
431
+ // with dir: "." or dir: "runs/x" must never turn this into an rm -rf of
432
+ // the project root or some unrelated subtree. Only an UNSAFE dir is
433
+ // swallowed here (disk left alone, catalog still tombstones below); a
434
+ // real removal failure on a legitimately-scoped dir is a genuine error
435
+ // and must surface, not silently report success.
436
+ let abs = null;
437
+ try { abs = assertCreationDir(projectDir, envelope.dir); } catch { /* unsafe dir: leave disk alone */ }
438
+ if (abs) fs.rmSync(abs, { recursive: true, force: true });
439
+ }
440
+ updateIndex(projectDir, (i) => {
441
+ i.creations = (i.creations ?? []).map((c) => (c.id === id ? { id, deleted: true, ts: Date.now() } : c));
442
+ });
443
+ // A deleted creation must not leave the project's product pointer dangling:
444
+ // build sessions resolve productCreationId to a cwd, and a pointer at a
445
+ // tombstone once sent every product-path build into a nonexistent directory
446
+ // (spawn ENOENT, misreported by the SDK as a libc problem). Clearing it here
447
+ // covers every deletion caller.
448
+ const projFile = path.join(projectDir, 'project.json');
449
+ const proj = readJson(projFile);
450
+ if (proj?.productCreationId === id) {
451
+ atomicWriteJson(projFile, { ...proj, productCreationId: null });
452
+ }
453
+ return envelope;
454
+ }
455
+
456
+ export function readCreationContent(projectDir, envelope) {
457
+ const rel = envelope.path;
458
+ if (!rel) throw new Error(`unsafe creation path: ${rel}`);
459
+ // A kind 'file' creation is raw bytes (video, audio, an arbitrary asset);
460
+ // reading it as utf8 would hand back garbage. Its viewer streams the bytes
461
+ // through creation-file:// instead; content is null by design.
462
+ if (envelope.kind === 'file') return null;
463
+ // Deny at the SINK: this covers ALL callers, including read_creation({id})
464
+ // and the IPC openProject snapshot map, which read the CATALOGED
465
+ // envelope.path and never pass through the tool-level args.path check. A
466
+ // tampered/legacy single-file envelope pointing at .env / .git/config must
467
+ // throw here so it cannot leak; the openProject map try/catches this into a
468
+ // null-content (empty) tab.
469
+ if (deniesGitOrEnvPath(rel)) throw new Error(`refusing to read a .git/.env creation path: ${rel}`);
470
+ const file = assertInside(projectDir, rel);
471
+ const raw = fs.readFileSync(file, 'utf8');
472
+ try { return JSON.parse(raw); } catch { return raw; }
473
+ }
474
+
475
+ // is a claim about the founder's SCREEN, and the tab strip used to be renderer
476
+ // state only: an artifact opened while nobody was listening (the web bridge
477
+ // reconnecting, a background chat finishing after a reload, the founder on
478
+ // another project) reached no client and was never written down, so the
479
+ // sentence was true when the tool ran and false when she read it. Recording the
480
+ // open here makes it durable.
481
+ //
482
+ // Append-only and idempotent on purpose. It never reorders, never closes, and
483
+ // never touches activeTab: an artifact opening while she reads something else
484
+ // must not yank her away. The live creation.opened event still focuses the tab
485
+ // for a founder who IS watching, and her own saveView still wins afterwards
486
+ // (closing a tab is her call, and last write wins). Returns the tab list.
487
+ export function noteOpenedTab(projectDir, creationId) {
488
+ if (!projectDir || typeof creationId !== 'string' || !creationId) return null;
489
+ const file = path.join(projectDir, 'view.json');
490
+ const view = readJson(file, null) ?? { openTabs: [], activeTab: null, pinnedTabs: [], browsers: [] };
491
+ const openTabs = Array.isArray(view.openTabs) ? view.openTabs : [];
492
+ if (openTabs.includes(creationId)) return openTabs;
493
+ const next = [...openTabs, creationId].slice(-200);
494
+ atomicWriteJson(file, { ...view, openTabs: next });
495
+ return next;
496
+ }
497
+
498
+ // The project:open creations map, with LAZY bodies. Shipping every creation's
499
+ // full content made opening a big project a multi-second stall (one giant
500
+ // frame over the web socket), so content rides along only for tabs that are
501
+ // actually open plus small docs (library excerpts stay populated). The
502
+ // renderer hydrates anything else through IPC.creationContent on tab open.
503
+ export const SNAPSHOT_SMALL_CONTENT_BYTES = 16 * 1024;
504
+ export function snapshotCreations(projectDir, openIds = new Set()) {
505
+ const live = (readIndex(projectDir).creations ?? []).filter((c) => !c.deleted);
506
+ return live.map((envelope) => {
507
+ // a live-run's path is a run DIRECTORY, not a content file: it has no body
508
+ // to read (the tab rebuilds its last still from meta), so skip the read
509
+ // rather than lean on a directory read throwing.
510
+ if (envelope.kind === 'live-run') return { envelope, content: null };
511
+ let content = null;
512
+ try {
513
+ // readCreationContent enforces containment and the .git/.env deny; a
514
+ // tampered index.json yields a null-content tab, never file contents.
515
+ content = readCreationContent(projectDir, envelope) ?? null;
516
+ } catch { /* unsafe path or unreadable; renderer shows the tab empty */ }
517
+ if (content != null && !openIds.has(envelope.id)) {
518
+ const size = typeof content === 'string' ? content.length : JSON.stringify(content).length;
519
+ if (size > SNAPSHOT_SMALL_CONTENT_BYTES) content = null;
520
+ }
521
+ return { envelope, content };
522
+ });
523
+ }
@@ -0,0 +1,31 @@
1
+ // main/store/queues.mjs
2
+ //
3
+ // SCOPE, since this is now half of the answer rather than all of it: this queue
4
+ // orders work WITHIN one process. Cross-PROCESS safety (the MCP server in mcp/
5
+ // runs many concurrent children against the same project directory) is handled
6
+ // where the shared mutable state actually is, not here:
7
+ //
8
+ // index.json updateIndex takes a cross-process file lock directly
9
+ // (main/store/project-lock.mjs)
10
+ // work items append-only jsonl, folded newest-wins, so there is no
11
+ // read-modify-write to lose (main/store/work-items.mjs)
12
+ //
13
+ // Deliberately NOT wrapped around this queue: several bodies below are git
14
+ // pushes and other network calls, and a cross-process lock held across a slow
15
+ // push would be broken as stale mid-flight, which is worse than not holding one.
16
+ //
17
+ // One shared per-project write queue for the whole main process. Catalog
18
+ // mutations (index.json read-modify-write) and git-spine ops (commit/restore/
19
+ // push) on the same project must never interleave across concurrent tool
20
+ // calls or IPC handlers, so every caller that touches a project's catalog or
21
+ // a creation's git repo routes through runForProject(project.id, fn) instead
22
+ // of building its own queue. Keyed by project id (makeWriteQueue keys by
23
+ // string, already serializes correctly across unrelated projects).
24
+
25
+ import { makeWriteQueue } from './write-queue.mjs';
26
+
27
+ export const projectQueue = makeWriteQueue();
28
+
29
+ export function runForProject(projectId, fn) {
30
+ return projectQueue.run(projectId, fn);
31
+ }