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,76 @@
1
+ // IS THE RUNNING APP THE APP ON DISK?
2
+ //
3
+ // Electron reads main/*.mjs once, at boot. Every fix to the supervisor, the IPC
4
+ // surface or the preload is therefore invisible until a restart, and nothing on
5
+ // screen said which build was running. That cost real hours three times in two
6
+ // days (2026-08-07 to 08-11): the app ran for 2d22h while four main-process
7
+ // fixes landed on disk beneath it, and the last of those was a correction to a
8
+ // bug that was actively stopping her agents. Each time the code was right, the
9
+ // tests were green, it was pushed, and none of it was running.
10
+ //
11
+ // Worse than merely stale: ⌘R reloads the RENDERER only. So a reload picks up
12
+ // new UI against an old main process, and a renderer that calls an IPC handler
13
+ // its main process has never heard of fails silently. That exact mismatch was
14
+ // one keystroke away on 2026-08-10 (the palette's resume-any-row calling
15
+ // zero:resume-items against a main process without the handler).
16
+ //
17
+ // So the app checks itself, continuously rather than at boot, because the
18
+ // staleness develops WHILE it runs: it rides the snapshot the renderer already
19
+ // polls, and says plainly that a restart, not a reload, is what is needed.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ // When this module was first imported, which is when the main process read its
25
+ // code. Every file below is compared against it.
26
+ export const BOOTED_AT = Date.now();
27
+
28
+ // Everything the MAIN process holds in memory from boot. The renderer is
29
+ // deliberately absent: it reloads with ⌘R, so it is never the thing a restart
30
+ // is needed for, and listing it here would cry wolf on every rebuild.
31
+ const WATCHED_DIRS = ['main', 'shared'];
32
+ const WATCHED_FILES = ['preload.cjs'];
33
+
34
+ // PURE, and the whole rule: a file written after the process read it is code
35
+ // that exists and is not running.
36
+ export function changedSinceBoot(bootedAt, files) {
37
+ return files
38
+ .filter((f) => Number.isFinite(f.mtimeMs) && f.mtimeMs > bootedAt)
39
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
40
+ .map((f) => f.name);
41
+ }
42
+
43
+ function listWatched(appDir) {
44
+ const found = [];
45
+ for (const rel of WATCHED_FILES) {
46
+ try { found.push({ name: rel, mtimeMs: fs.statSync(path.join(appDir, rel)).mtimeMs }); } catch {}
47
+ }
48
+ for (const dir of WATCHED_DIRS) {
49
+ let entries = [];
50
+ try { entries = fs.readdirSync(path.join(appDir, dir), { withFileTypes: true }); } catch { continue; }
51
+ for (const e of entries) {
52
+ if (!e.isFile() || !/\.(mjs|cjs|js)$/.test(e.name)) continue;
53
+ try {
54
+ found.push({ name: `${dir}/${e.name}`, mtimeMs: fs.statSync(path.join(appDir, dir, e.name)).mtimeMs });
55
+ } catch {}
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+
61
+ // Cached: the snapshot is polled every ten seconds and pushed on every store
62
+ // change, and this is a disk walk. Staleness is not urgent to the second.
63
+ let cache = { at: 0, value: null };
64
+ const TTL_MS = 15_000;
65
+
66
+ export function restartNeeded(appDir, bootedAt = BOOTED_AT, now = Date.now()) {
67
+ if (cache.value !== undefined && now - cache.at < TTL_MS) return cache.value;
68
+ const files = changedSinceBoot(bootedAt, listWatched(appDir));
69
+ const value = files.length ? { files, since: bootedAt } : null;
70
+ cache = { at: now, value };
71
+ return value;
72
+ }
73
+
74
+ export function _resetCacheForTests() {
75
+ cache = { at: 0, value: null };
76
+ }
@@ -0,0 +1,188 @@
1
+ // Chat store: per-product conversations under <project>/chats/, one JSONL log
2
+ // per chat plus a registry (chats/index.json: id, title, createdAt, updatedAt,
3
+ // lastSessionId). Same rules as the rest of the store: single writer (main),
4
+ // atomic index writes, crash-safe appends, and self-repair on read (an entry
5
+ // whose file vanished drops out instead of wedging the UI). A message is
6
+ // UPDATED by appending a line with the same id; reading folds last-line-wins.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import crypto from 'node:crypto';
11
+ import { atomicWriteJson, readJson } from './project.mjs';
12
+
13
+ const iso = () => new Date().toISOString();
14
+ const chatsDir = (projectDir) => path.join(projectDir, 'chats');
15
+ const indexFile = (projectDir) => path.join(chatsDir(projectDir), 'index.json');
16
+
17
+ export function sanitizeChatId(id) {
18
+ const s = String(id ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
19
+ if (!s) throw new Error(`bad chat id: ${id}`);
20
+ return s;
21
+ }
22
+
23
+ export function chatFile(projectDir, chatId) {
24
+ return path.join(chatsDir(projectDir), `${sanitizeChatId(chatId)}.jsonl`);
25
+ }
26
+
27
+ function mutateIndex(projectDir, mutate) {
28
+ const idx = readJson(indexFile(projectDir), { chats: [] });
29
+ mutate(idx);
30
+ atomicWriteJson(indexFile(projectDir), idx);
31
+ return idx;
32
+ }
33
+
34
+ // Idempotent bootstrap: creates chats/, migrates the legacy single chat.jsonl
35
+ // ONCE (copy to chats/main.jsonl, rename the original to .migrated so a failed
36
+ // first-open never strands history), carries project.lastSessionId over, and
37
+ // guarantees at least one chat exists.
38
+ export function ensureChats(projectDir, project = {}) {
39
+ fs.mkdirSync(chatsDir(projectDir), { recursive: true });
40
+ if (!fs.existsSync(indexFile(projectDir))) {
41
+ const chats = [];
42
+ const legacy = path.join(projectDir, 'chat.jsonl');
43
+ if (fs.existsSync(legacy)) {
44
+ fs.copyFileSync(legacy, chatFile(projectDir, 'main'));
45
+ fs.renameSync(legacy, `${legacy}.migrated`);
46
+ chats.push({ id: 'main', title: 'First conversation', createdAt: iso(), updatedAt: iso(), lastSessionId: project.lastSessionId ?? null });
47
+ }
48
+ atomicWriteJson(indexFile(projectDir), { chats });
49
+ }
50
+ if (listChats(projectDir).length === 0) createChat(projectDir);
51
+ return listChats(projectDir);
52
+ }
53
+
54
+ // Registry with self-repair: entries whose log file is gone are dropped
55
+ // (and pruned from the index so they stay gone), newest first.
56
+ export function listChats(projectDir) {
57
+ const idx = readJson(indexFile(projectDir), { chats: [] });
58
+ const alive = (idx.chats ?? []).filter((c) => {
59
+ try { return c?.id && fs.existsSync(chatFile(projectDir, c.id)); } catch { return false; }
60
+ });
61
+ if (alive.length !== (idx.chats ?? []).length) {
62
+ mutateIndex(projectDir, (i) => { i.chats = alive; });
63
+ }
64
+ return [...alive].sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
65
+ }
66
+
67
+ export function getChat(projectDir, chatId) {
68
+ return listChats(projectDir).find((c) => c.id === chatId) ?? null;
69
+ }
70
+
71
+ export function createChat(projectDir, title = 'New chat', { modelId } = {}) {
72
+ fs.mkdirSync(chatsDir(projectDir), { recursive: true });
73
+ const id = `c-${crypto.randomBytes(4).toString('hex')}`;
74
+ fs.writeFileSync(chatFile(projectDir, id), '');
75
+ // modelId pins THIS conversation's model at birth (the caller passes the
76
+ // workspace's most recent choice). Undefined = follow the global default,
77
+ // which is what every pre-existing chat does.
78
+ const chat = { id, title, createdAt: iso(), updatedAt: iso(), lastSessionId: null, ...(modelId ? { modelId } : {}) };
79
+ mutateIndex(projectDir, (idx) => { idx.chats = [...(idx.chats ?? []), chat]; });
80
+ return chat;
81
+ }
82
+
83
+ export function patchChat(projectDir, chatId, patch) {
84
+ let out = null;
85
+ mutateIndex(projectDir, (idx) => {
86
+ idx.chats = (idx.chats ?? []).map((c) => {
87
+ if (c.id !== chatId) return c;
88
+ out = { ...c, ...patch };
89
+ return out;
90
+ });
91
+ });
92
+ return out;
93
+ }
94
+
95
+ export function renameChat(projectDir, chatId, title) {
96
+ const t = String(title ?? '').trim();
97
+ return t ? patchChat(projectDir, chatId, { title: t }) : null;
98
+ }
99
+
100
+ // Delete = move the log into chats/.trash/ (never rm) and drop the entry.
101
+ // The caller must close the chat's session FIRST; a trashed chat takes no
102
+ // further writes (appendChatMessage refuses unregistered chats).
103
+ export function trashChat(projectDir, chatId) {
104
+ const entry = getChat(projectDir, chatId);
105
+ if (!entry) return false;
106
+ const trash = path.join(chatsDir(projectDir), '.trash');
107
+ fs.mkdirSync(trash, { recursive: true });
108
+ fs.renameSync(chatFile(projectDir, chatId), path.join(trash, `${sanitizeChatId(chatId)}-${Date.now()}.jsonl`));
109
+ // Drop this chat's attachment assets (chats/<chatId>/assets/, written on send):
110
+ // they belong to the conversation, so a trashed chat takes them with it and the
111
+ // storage does not silt up with orphaned images. force so a chat with no assets
112
+ // is a clean no-op.
113
+ fs.rmSync(path.join(chatsDir(projectDir), sanitizeChatId(chatId)), { recursive: true, force: true });
114
+ mutateIndex(projectDir, (idx) => { idx.chats = (idx.chats ?? []).filter((c) => c.id !== chatId); });
115
+ return true;
116
+ }
117
+
118
+ // Auto-scrub abandoned empty chats. createChat writes an empty log the moment
119
+ // a chat is born, so a founder who opens "New chat" and walks away silts the
120
+ // sidebar up forever. An empty chat (zero messages, the same last-line-wins fold
121
+ // used everywhere) carries no history worth keeping, so we trash it (recoverable,
122
+ // never rm) UNLESS it is the active chat or its id is in keepIds (a chat with
123
+ // unsent composer draft text). The active chat is always protected, which is what
124
+ // keeps a scrub-then-ensure cycle from churning: ensureChats only recreates when
125
+ // zero chats remain, and the one you are looking at never gets scrubbed.
126
+ export function scrubEmptyChats(projectDir, { keepIds = [], activeId } = {}) {
127
+ const keep = new Set([...(keepIds ?? []), ...(activeId ? [activeId] : [])]);
128
+ const removed = [];
129
+ for (const c of listChats(projectDir)) {
130
+ if (keep.has(c.id)) continue;
131
+ if (readChatLog(projectDir, c.id).length > 0) continue;
132
+ if (trashChat(projectDir, c.id)) removed.push(c.id);
133
+ }
134
+ return { removed };
135
+ }
136
+
137
+ export function appendChatMessage(projectDir, chatId, message) {
138
+ if (!getChat(projectDir, chatId)) throw new Error(`no such chat: ${chatId}`);
139
+ fs.appendFileSync(chatFile(projectDir, chatId), JSON.stringify(message) + '\n');
140
+ patchChat(projectDir, chatId, { updatedAt: iso() });
141
+ }
142
+
143
+ // Resolve a pending ask directly on the chat log: for a question whose
144
+ // owning session died (an app restart), no live asks registry can answer it,
145
+ // but the question must still be answerable, or it sits pending forever
146
+ // (eating tapped answers and suppressing the chat's thinking indicator).
147
+ // Folds any tapped answers into the card, persists it as answered
148
+ // (last-line-wins), and returns the resolved message, or null when there is
149
+ // no matching pending ask.
150
+ export function resolvePendingAsk(projectDir, chatId, requestId, answers = []) {
151
+ const card = readChatLog(projectDir, chatId)
152
+ .find((m) => m.ask?.requestId === requestId && m.ask.status === 'pending');
153
+ if (!card) return null;
154
+ const list = Array.isArray(answers) ? answers : [];
155
+ const questions = (card.ask.questions ?? []).map((q) => {
156
+ const a = list.find((x) => x?.id === q.id);
157
+ return a
158
+ ? {
159
+ ...q,
160
+ answer: {
161
+ choiceId: a.choiceId,
162
+ // "pick all that apply": every pill she tapped, not just the first
163
+ ...(Array.isArray(a.choiceIds) ? { choiceIds: a.choiceIds } : {}),
164
+ freeText: a.freeText,
165
+ skipped: !!a.skipped,
166
+ },
167
+ }
168
+ : q;
169
+ });
170
+ const resolved = { ...card, ask: { ...card.ask, questions, status: 'answered' } };
171
+ appendChatMessage(projectDir, chatId, resolved);
172
+ return resolved;
173
+ }
174
+
175
+ export function readChatLog(projectDir, chatId) {
176
+ let lines;
177
+ try {
178
+ lines = fs.readFileSync(chatFile(projectDir, chatId), 'utf8').split('\n').filter(Boolean);
179
+ } catch { return []; }
180
+ const order = []; const byId = new Map();
181
+ for (const l of lines) {
182
+ let m; try { m = JSON.parse(l); } catch { continue; } // torn tail line: skip
183
+ if (!m?.id) continue;
184
+ if (!byId.has(m.id)) order.push(m.id);
185
+ byId.set(m.id, { ...(byId.get(m.id) ?? {}), ...m });
186
+ }
187
+ return order.map((id) => byId.get(id));
188
+ }
@@ -0,0 +1,383 @@
1
+ // THE APP'S OWN HOME, so nothing Agentbox generates lands in the folder a worker
2
+ // is standing in.
3
+ //
4
+ // WHY THIS FILE EXISTS. A new user uploaded a file into her product folder,
5
+ // watched a worker create a directory and delete things next to it, and asked
6
+ // why it needed to.
7
+ //
8
+ // MEASURED on her Mac 2026-08-24 and again 2026-08-26: Claude Code writes
9
+ // nothing into a working directory. Per-project state goes to
10
+ // `~/.claude/projects/<encoded path>/`, pasted files to `~/.claude/paste-cache`,
11
+ // edit history to `~/.claude/file-history`. Codex does the same under `~/.codex`.
12
+ // Agentbox did the opposite: a product with no code repo registered makes the
13
+ // product docs directory the worker's own cwd (main/supervisor.mjs), which is
14
+ // every new user's first product, and our ledger, session traces, run records
15
+ // and repeat rules all sat in it.
16
+ //
17
+ // So this is the same convention, one name over: a dot-folder in her home
18
+ // named after this app.
19
+ //
20
+ // WHAT MOVES AND WHAT DOES NOT, because the line is not "everything of ours".
21
+ // The app's own RECORDS move: the work item ledger, session traces, run
22
+ // records, repeat rules, parked writes. What DESCRIBES THE FOLDER ITSELF stays
23
+ // in it: `project.json` says this directory is a product at all, `index.json`
24
+ // catalogs the founder's own documents by their paths inside it, and `.lock`
25
+ // guards that catalog. A folder you could copy to another Mac and still open is
26
+ // worth more than four fewer files, and none of those three is what a worker
27
+ // scribbles.
28
+ //
29
+ // WHAT THIS DOES NOT DO, said out loud so nobody reads it as more than it is.
30
+ // It does not make the ledger unforgeable. A worker with Bash reaches that
31
+ // folder exactly as easily as it reaches its own cwd. Claude Code has the
32
+ // identical property and does not pretend otherwise. This moves our files out
33
+ // of her folder; the grant that used to ride on the ledger is dealt with
34
+ // separately, by not keeping it on the ledger at all (main/answer-modes.mjs).
35
+
36
+ import fs from 'node:fs';
37
+ import os from 'node:os';
38
+ import path from 'node:path';
39
+ import crypto from 'node:crypto';
40
+ import { WAS, envNames, nameSlug, readEnv } from '../../shared/product-name.mjs';
41
+
42
+ const isDir = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
43
+
44
+ /**
45
+ * THE STORE ROOT AS ENVIRONMENT, FOR EVERY PROCESS WE START, UNDER EVERY NAME
46
+ * THIS APP HAS HAD.
47
+ *
48
+ * `appHome` above reads the old names, so anything shipped in this repo finds
49
+ * the root whichever spelling it is given. What is NOT in this repo does not:
50
+ * the store MCP server of an older install, her own launchd jobs, a script she
51
+ * wrote. Those read the one variable they were written against, and a worker
52
+ * whose store root is missing writes its whole session to nowhere, which is the
53
+ * 2026-08-05 failure exactly.
54
+ *
55
+ * So both are exported. It costs one string per spawn and it makes the rename
56
+ * invisible to everything outside this checkout.
57
+ */
58
+ export function storeRootEnv(storeRoot) {
59
+ const env = {};
60
+ for (const key of envNames('HOME')) env[key] = storeRoot;
61
+ return env;
62
+ }
63
+
64
+ // One env var, for tests and for anyone who keeps their home somewhere else.
65
+ // Read at call time rather than captured, because a test sets it per case.
66
+ //
67
+ // THE NAME IS READ HERE AND THE OLD NAMES STILL ANSWER. This was a literal
68
+ // `ASTRAL_HOME` and a literal `.astral`, two renames after the app stopped
69
+ // being called that, so both go through the name module now. The fallbacks are
70
+ // not politeness: a launchd job, a login shell and any process started before a
71
+ // rename still export the old variable, and a dot-folder written under an old
72
+ // name holds a real store. The current name wins when both are there;
73
+ // otherwise the newest old name that is actually on disk does, so nobody's
74
+ // store goes missing on the release that renames the app.
75
+ // `homeDir` is injected so a test can ask what this answers on a machine with
76
+ // an old dot-folder on it, without one having to exist on the machine running
77
+ // the test. Production never passes it.
78
+ export function appHome(homeDir = os.homedir()) {
79
+ const override = readEnv('HOME');
80
+ if (override) return override;
81
+ const here = path.join(homeDir, `.${nameSlug}`);
82
+ if (isDir(here)) return here;
83
+ for (const was of WAS) {
84
+ const older = path.join(homeDir, `.${was.toLowerCase()}`);
85
+ if (isDir(older)) return older;
86
+ }
87
+ return here;
88
+ }
89
+
90
+ // Claude Code's own encoding, checked against a real folder on her Mac:
91
+ // `/Users/you/Zero/accounts/b482f.../agentbox` becomes
92
+ // `-Users-you-Zero-accounts-b482f...-agentbox`. Every character that is not
93
+ // a letter or a digit becomes a dash, which is readable at a glance and safe on
94
+ // every filesystem.
95
+ export function encodeProjectPath(dir) {
96
+ return String(dir).replace(/[^A-Za-z0-9]/g, '-');
97
+ }
98
+
99
+ // The one thing that encoding cannot do: `/a/b.c` and `/a/b/c` encode the same.
100
+ // Claude Code lives with that. We do not, because two products sharing one
101
+ // ledger would silently merge two founders' inboxes, so the folder carries the
102
+ // path it belongs to and a mismatch takes a short hash suffix instead.
103
+ const ORIGIN = '.origin';
104
+
105
+ function readOrigin(dir) {
106
+ try { return fs.readFileSync(path.join(dir, ORIGIN), 'utf8').trim(); } catch { return null; }
107
+ }
108
+
109
+ /**
110
+ * A MACHINERY DIRECTORY IS NOT A PROJECT DIRECTORY, and everything below has to
111
+ * know the difference before it moves a byte.
112
+ *
113
+ * MEASURED on her Mac 2026-09-23 at 15:31:26, because this is not a hypothesis.
114
+ * A session wrote a throwaway script to count stranded rows, listed
115
+ * `<home>/projects`, and handed each of those directories to `readWorkItems` as
116
+ * if it were a product folder. `readWorkItems` asks for the ledger, the ledger
117
+ * asks for the machinery directory, and the machinery directory of a machinery
118
+ * directory is a SECOND one nested inside the home. Then the sweep below did
119
+ * what it is for and carried the ledger, the session traces, the run records,
120
+ * the repeat rules and the parked writes one level down into it. Thirty eight
121
+ * products in two seconds, and her inbox read empty until the folders were
122
+ * carried back at 15:58.
123
+ *
124
+ * Nothing about that was exotic. Any agent with a shell can write that script,
125
+ * and reading is supposed to be the safe thing to do. So the refusal lives
126
+ * HERE, in the one place every caller passes through, rather than in a rule
127
+ * about how to write scripts that nobody reading this will have been told.
128
+ *
129
+ * The test is the path itself: anything inside `<home>/projects` is ours
130
+ * already. A product folder can never be one, because products live under the
131
+ * account root and the home is a separate tree.
132
+ */
133
+ export function isMachineryDir(dir, home = appHome()) {
134
+ const root = path.resolve(path.join(home, 'projects'));
135
+ return path.resolve(dir).startsWith(root + path.sep);
136
+ }
137
+
138
+ /** `<home>/projects/<encoded>` for this project directory. Creates nothing. */
139
+ export function machineryDir(projectDir, home = appHome()) {
140
+ const real = path.resolve(projectDir);
141
+ // Already ours: it is its own answer. Encoding it a second time is the
142
+ // 2026-09-23 fold, and returning it unchanged makes the mistake harmless
143
+ // rather than silent, because a caller that guessed this path still gets the
144
+ // right ledger back.
145
+ if (isMachineryDir(real, home)) return real;
146
+ const base = path.join(home, 'projects', encodeProjectPath(real));
147
+ const claimed = readOrigin(base);
148
+ if (claimed === null || claimed === real) return base;
149
+ const tag = crypto.createHash('sha256').update(real).digest('hex').slice(0, 8);
150
+ return `${base}-${tag}`;
151
+ }
152
+
153
+ // EVERY NAME THAT IS OURS, so the move is ONE event rather than a slow leak.
154
+ //
155
+ // A path-by-path migration was the first shape and it is wrong for what this is
156
+ // FOR. It moves a file the first time something asks for it, so the ledger goes
157
+ // the moment the inbox opens and `sessions/` goes one item at a time, whenever a
158
+ // trace happens to be read, which for a row nobody opens again is never.
159
+ // Measured on her folder 2026-08-26: 1,785 of the 1,830 files that leave are
160
+ // session traces. Lazily she would still be looking at nearly all of it. The
161
+ // point is that her folder is clean, so all of it goes at once, the first time
162
+ // this process touches the product.
163
+ export const MACHINERY = ['work-items.jsonl', 'repeats.jsonl', 'sessions', 'runs', 'pending-writes'];
164
+
165
+ // THE TWO OF OUR NAMES THAT ARE APPEND-ONLY LOGS. A stray one of these holds
166
+ // lines the real file never got, so it is folded ONTO THE END of the real file
167
+ // rather than moved over it. Everything else here is a whole file that stands
168
+ // on its own.
169
+ const LOGS = new Set(['work-items.jsonl', 'repeats.jsonl']);
170
+
171
+ /**
172
+ * The same directory, existing, with its origin marker written once, and every
173
+ * record of ours carried in.
174
+ *
175
+ * THE SWEEP RUNS ON EVERY CALL, NOT ONCE PER PROCESS. It used to run once,
176
+ * behind a `Set`, and `machineryPath` below only adopted a name when there was
177
+ * NOTHING at the new place. Between them that meant the move happened exactly
178
+ * once, and one of our files that appeared in her folder afterwards was never
179
+ * picked up again: nothing read it, nothing moved it, and it sat there.
180
+ *
181
+ * MEASURED on her real folder the day the move landed, not reasoned about: two
182
+ * release lines for two different cards written into a stray `work-items.jsonl`
183
+ * at 14:10 and never folded in, and two run records stranded under `runs/`. So
184
+ * this is not a theoretical window, it is where her folder actually was.
185
+ *
186
+ * The cost of running it every time is one stat per name against a path that is
187
+ * normally absent. Five syscalls that find nothing is cheaper than being wrong,
188
+ * and being wrong here is silent.
189
+ */
190
+ export function ensureMachineryDir(projectDir, home = appHome()) {
191
+ const dir = machineryDir(projectDir, home);
192
+ fs.mkdirSync(dir, { recursive: true });
193
+ // THE SWEEP IS WHAT DID THE DAMAGE, so it does not run on a directory that is
194
+ // already ours. `machineryDir` hands back its own argument in that one case,
195
+ // and there is nothing to carry across from a folder into itself. The origin
196
+ // marker is left alone too: it names the product this machinery belongs to,
197
+ // and overwriting it with the machinery's own path is how the folded copies
198
+ // came to claim themselves.
199
+ if (dir === path.resolve(projectDir)) return dir;
200
+ if (readOrigin(dir) === null) {
201
+ try { fs.writeFileSync(path.join(dir, ORIGIN), `${path.resolve(projectDir)}\n`); } catch { /* best effort */ }
202
+ }
203
+ for (const rel of MACHINERY) foldStray(path.join(projectDir, rel), path.join(dir, rel), rel);
204
+ return dir;
205
+ }
206
+
207
+ /**
208
+ * One of our names, wherever it has turned up in her folder, carried across to
209
+ * where it belongs. NOTHING IS EVER DESTROYED HERE.
210
+ *
211
+ * The rules, in the order they apply:
212
+ *
213
+ * - Nothing there: return. This is the ordinary case and it costs one stat.
214
+ * - Nothing at the target yet: rename it across whole, which is what the
215
+ * first move has always done.
216
+ * - Both directories: merge them entry by entry by these same rules, then
217
+ * drop the stray directory once the merge has emptied it.
218
+ * - Both append-only logs: append the stray's bytes onto the real file and
219
+ * delete the stray. Never the other way round; the real file is the one
220
+ * with the history in it.
221
+ * - Both ordinary files: the newer takes the name and the older is kept
222
+ * beside it as `<name>.superseded-<mtime>` rather than deleted. That is the
223
+ * run record case, and it is rare on purpose, because the writer of those
224
+ * now writes straight here (main/code-change.mjs). A conflict only happens
225
+ * while a process started before the move is still running.
226
+ */
227
+ export function foldStray(from, to, rel = path.basename(to)) {
228
+ let stray;
229
+ try { stray = fs.statSync(from); } catch { return false; }
230
+
231
+ let landed;
232
+ try { landed = fs.statSync(to); } catch { return adoptLegacy(from, to); }
233
+
234
+ if (stray.isDirectory() && landed.isDirectory()) {
235
+ let entries = [];
236
+ try { entries = fs.readdirSync(from); } catch { return false; }
237
+ for (const name of entries) foldStray(path.join(from, name), path.join(to, name), name);
238
+ // Only if the merge emptied it. A directory with anything left in it holds
239
+ // something we did not understand, and it stays where it is.
240
+ try { fs.rmdirSync(from); } catch { /* not empty, or gone already */ }
241
+ return true;
242
+ }
243
+
244
+ // A directory on one side and a file on the other is not a case we have a
245
+ // right answer for, so the stray is left alone and it is said out loud.
246
+ if (stray.isDirectory() !== landed.isDirectory()) {
247
+ console.warn(`${nameSlug}: ${from} and ${to} are not the same kind of thing; leaving the first alone`);
248
+ return false;
249
+ }
250
+
251
+ if (LOGS.has(rel)) {
252
+ try {
253
+ const lines = fs.readFileSync(from);
254
+ if (lines.length) fs.appendFileSync(to, lines);
255
+ fs.rmSync(from, { force: true });
256
+ return true;
257
+ } catch (err) {
258
+ console.warn(`${nameSlug}: could not fold ${from} into ${to}: ${err.message}`);
259
+ return false;
260
+ }
261
+ }
262
+
263
+ try {
264
+ if (stray.mtimeMs > landed.mtimeMs) {
265
+ fs.renameSync(to, `${to}.superseded-${Math.round(landed.mtimeMs)}`);
266
+ fs.renameSync(from, to);
267
+ } else {
268
+ fs.renameSync(from, `${to}.superseded-${Math.round(stray.mtimeMs)}`);
269
+ }
270
+ return true;
271
+ } catch (err) {
272
+ console.warn(`${nameSlug}: could not carry ${from} across: ${err.message}`);
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Where one of our records lives for this project, and the migration that gets
279
+ * it there.
280
+ *
281
+ * THE MOVE HAPPENS ONCE, and `ensureMachineryDir` above has already done it by
282
+ * the time this returns: anything still sitting in the product folder under one
283
+ * of our names is RENAMED into the home. A rename on the same volume is atomic
284
+ * and costs nothing, whether it is a 6 MB ledger or a directory of 1,785 session
285
+ * traces, and it is undone by moving the file back.
286
+ *
287
+ * It is a move rather than a read-both-write-new because the ledger is an
288
+ * append-only log: two halves of one log in two places is not a store, it is
289
+ * two stores that disagree. Everything that reads it must therefore agree about
290
+ * where it is, which is why the MCP server ships the same file.
291
+ *
292
+ * Races are ordinary here: a dozen processes may call this at once. Every
293
+ * failure mode of the rename is benign and is swallowed — ENOENT means there
294
+ * was nothing to move, EEXIST/ENOTEMPTY means somebody else moved it first.
295
+ */
296
+ export function machineryPath(projectDir, rel, home = appHome()) {
297
+ if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
298
+ throw new Error(`unsafe machinery path: ${rel}`);
299
+ }
300
+ const dir = ensureMachineryDir(projectDir, home);
301
+ const target = path.join(dir, rel);
302
+ if (!fs.existsSync(target)) adoptLegacy(path.join(projectDir, rel), target);
303
+ return target;
304
+ }
305
+
306
+ /**
307
+ * EVERY RECORD OF OURS FOR ONE PROJECT, GONE.
308
+ *
309
+ * This exists for exactly one caller: the practice project, which the founder
310
+ * has now ruled must not survive its own tutorial. Deleting the folder in her
311
+ * store and leaving the ledger, the session traces and the run records behind
312
+ * would be a half removal, and it is the half nobody can see: MEASURED on her
313
+ * real Mac 2026-08-28, the practice folder in her account held two files and
314
+ * the machinery for it held 168 ledger lines and 29 session traces from six
315
+ * walks.
316
+ *
317
+ * IT WILL ONLY EVER DELETE A DIRECTORY THAT SAYS IT BELONGS TO THIS PROJECT.
318
+ * `.origin` is written by `ensureMachineryDir` on first use and holds the
319
+ * resolved path of the project the directory is the machinery for; this refuses
320
+ * unless it is there and it matches. A directory with no `.origin` is one we did
321
+ * not make, or one made by a version that did not write the marker, and either
322
+ * way it is somebody else's to delete. Same instinct as `foldStray` above: when
323
+ * two things are not the same kind of thing, leave the first alone and say so.
324
+ *
325
+ * Never throws. A machinery directory that will not delete is a few kilobytes
326
+ * under the app's own home; failing a launch over it would be the worse bug.
327
+ *
328
+ * Returns { removed, why } so a caller can report what really happened rather
329
+ * than assume.
330
+ */
331
+ export function removeMachinery(projectDir, home = appHome()) {
332
+ const real = path.resolve(projectDir);
333
+ const dir = machineryDir(real, home);
334
+ let there = false;
335
+ try { there = fs.statSync(dir).isDirectory(); } catch { return { removed: false, why: 'no machinery' }; }
336
+ if (!there) return { removed: false, why: 'not a directory' };
337
+ const claimed = readOrigin(dir);
338
+ if (claimed !== real) {
339
+ console.warn(`${nameSlug}: ${dir} does not claim ${real}; leaving it alone`);
340
+ return { removed: false, why: 'origin does not match' };
341
+ }
342
+ try {
343
+ fs.rmSync(dir, { recursive: true, force: true });
344
+ return { removed: true };
345
+ } catch (err) {
346
+ console.warn(`${nameSlug}: could not remove ${dir}: ${err.message}`);
347
+ return { removed: false, why: err.message };
348
+ }
349
+ }
350
+
351
+ // Move one legacy path in, if it is there. Never throws: a store that cannot be
352
+ // migrated must still open on the new path rather than fail to open at all.
353
+ export function adoptLegacy(from, to) {
354
+ let there = false;
355
+ try { there = fs.existsSync(from); } catch { return false; }
356
+ if (!there) return false;
357
+ try {
358
+ fs.mkdirSync(path.dirname(to), { recursive: true });
359
+ fs.renameSync(from, to);
360
+ return true;
361
+ } catch (err) {
362
+ if (err.code === 'EXDEV') return copyAcross(from, to);
363
+ // EEXIST / ENOTEMPTY: another process won the race, which is the good case.
364
+ if (err.code !== 'EEXIST' && err.code !== 'ENOTEMPTY' && err.code !== 'ENOENT') {
365
+ console.warn(`${nameSlug}: could not move ${from} into the app's home: ${err.message}`);
366
+ }
367
+ return false;
368
+ }
369
+ }
370
+
371
+ // The store on one volume and the home on another. Rare, and a rename cannot
372
+ // cross it, so copy and then remove — in that order, so a failure halfway
373
+ // leaves the original where it was rather than nothing anywhere.
374
+ function copyAcross(from, to) {
375
+ try {
376
+ fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
377
+ fs.rmSync(from, { recursive: true, force: true });
378
+ return true;
379
+ } catch (err) {
380
+ console.warn(`${nameSlug}: could not copy ${from} into the app's home: ${err.message}`);
381
+ return false;
382
+ }
383
+ }