agentbox-app 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
package/main/store.mjs ADDED
@@ -0,0 +1,1269 @@
1
+ // The app's read/write surface over its own store. The inbox IS the state
2
+ // layer: everything rendered is derived fresh from files, and everything the
3
+ // founder does becomes an appended line through the store's own write path.
4
+ // The app holds no state an app restart would lose.
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { loadStore } from './store-modules.mjs';
10
+ import { Repeats } from './repeats.mjs';
11
+ import { answerFor, describeFor } from './first-run.mjs';
12
+ import { checkProjectFolder } from '../shared/project-folder-check.mjs';
13
+ import { EXAMPLES, stampsFor } from '../shared/first-run-examples.mjs';
14
+ import {
15
+ PRACTICE_ANSWER, PRACTICE_FLAG, PRACTICE_NAME, PRACTICE_NOTE, PRACTICE_ROWS, PRACTICE_SLUG,
16
+ PRACTICE_TASK_TRACE,
17
+ } from '../shared/first-run-practice.mjs';
18
+ import { NOTE_NAME } from './rail-note.mjs';
19
+ import { iconPathFor } from './project-identity.mjs';
20
+ import { imgUrl } from './img-scheme.mjs';
21
+ import {
22
+ agentImportRow, agentsNeedingRows, threadImportRow, threadsNeedingRows,
23
+ } from '../shared/agent-import.mjs';
24
+ import {
25
+ CODEX_IMPORT_LABEL, NOT_IMPORTED_LABEL, codexIdOf, codexImportRow, codexMirrorPatch, isCodexImportRow, isCodexMirrorRow,
26
+ productsForThread, threadsNeedingRows as codexThreadsNeedingRows,
27
+ } from '../shared/codex-import.mjs';
28
+ import { buildClaimLine, buildReleaseLine } from '../shared/work-items.mjs';
29
+ import { isEffortWord } from '../shared/effort-levels.mjs';
30
+ import { machineryPath, removeMachinery } from './store/home.mjs';
31
+ import {
32
+ answerMode as readAnswerMode, answerModes, clearAnswerMode as clearAnswerModeFile, setAnswerMode,
33
+ touchAnswerMode as touchAnswerModeFile,
34
+ } from './answer-modes.mjs';
35
+
36
+ export class Store {
37
+ constructor(config) {
38
+ this.config = config;
39
+ this.modules = null;
40
+ this.repeats = null;
41
+ this.watchers = [];
42
+ this.onChange = null; // set by ipc layer; called on any store file change
43
+ this._debounce = null;
44
+ }
45
+
46
+ async init() {
47
+ this.modules = await loadStore();
48
+ this.repeats = new Repeats(this.modules);
49
+ // AND A PRACTICE PROJECT LEFT OVER FROM LAST TIME GOES NOW.A walk that was
50
+ // quit, or a Mac that went to sleep in the middle of one, leaves the
51
+ // practice project sitting in the account root, and everything downstream
52
+ // then treats it as a real project she owns: it is in the rail, it is in
53
+ // the compose card, and it is the project the card opens on. Nothing
54
+ // resumes a walk across a restart (`firstRunNeeded` reads the project
55
+ // COUNT, and a leftover practice project is itself one of them), so a
56
+ // practice project present at launch is by definition a leftover and never
57
+ // one somebody is standing in.
58
+ //
59
+ // In `init` rather than in a launch hook because this is the one place every
60
+ // the app opens its store, and a tidy-up that has to be remembered at a new
61
+ // call site is one that eventually is not.
62
+ try { this.sweepPractice(); } catch { /* a leftover folder is not a reason to fail to open */ }
63
+ return this;
64
+ }
65
+
66
+ /* ------------------------------- products ------------------------------ */
67
+ // A product is a project directory under the account root carrying a
68
+ // project.json. Archived products (or the reserved analytics id) don't ride.
69
+ listProducts() {
70
+ const root = this.config.accountRoot;
71
+ let entries = [];
72
+ try {
73
+ entries = fs.readdirSync(root, { withFileTypes: true });
74
+ } catch {
75
+ return [];
76
+ }
77
+ const wanted = this.config.products;
78
+ const products = [];
79
+ for (const entry of entries) {
80
+ if (entry.name.startsWith('_')) continue;
81
+ if (wanted.length && !wanted.includes(entry.name)) continue;
82
+ const dir = path.join(root, entry.name);
83
+ // statSync, not the Dirent: a product may be a symlink into another
84
+ // store root (the combined-workspace pattern), and a Dirent reports a
85
+ // symlink as not-a-directory, which made a symlinked product silently
86
+ // invisible to the inbox and the supervisor.
87
+ let st = null;
88
+ try { st = fs.statSync(dir); } catch { continue; }
89
+ if (!st.isDirectory()) continue;
90
+ let project = null;
91
+ try {
92
+ project = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8'));
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (project.archived) continue;
97
+ products.push({
98
+ slug: entry.name,
99
+ dir,
100
+ name: project.name || entry.name,
101
+ oneLiner: project.oneLiner || '',
102
+ repoPath: project.repoPath || null,
103
+ // THE MARK SHE CHOSE FOR THIS PROJECT, already a url the window can
104
+ // draw, or null on every project that has never been given one, which is
105
+ // what makes the rail draw its burst instead (main/project-identity.mjs).
106
+ //
107
+ // RESOLVED HERE rather than passed on raw, because the rail, the
108
+ // composer's picker, ⌘K and the settings nav all draw a product off this
109
+ // one list, and every one of them would otherwise have to know that
110
+ // `logo` is a bare file name needing `dir` joined to it and a scheme put
111
+ // in front. It costs one stat per product that HAS a mark, on a list
112
+ // that already does a stat and a read per product.
113
+ logo: (() => { const f = iconPathFor({ dir, logo: project.logo }); return f ? imgUrl(f) : null; })(),
114
+ // THE PRACTICE PROJECT SAYS SO ABOUT ITSELF. Read off project.json
115
+ // rather than matched on a name, because "Practice" is a name somebody
116
+ // could reasonably give a real project of their own, and the things
117
+ // that turn on this flag — never spawning a worker in it, drawing a
118
+ // band over it — must never happen to real work.
119
+ practice: project[PRACTICE_FLAG] === true,
120
+ });
121
+ }
122
+ return products.sort((a, b) => a.name.localeCompare(b.name));
123
+ }
124
+
125
+ /* ------------------------------ work items ----------------------------- */
126
+ // The cross-product fold. Every item carries its product slug so the
127
+ // renderer can group, filter, and rail without a second lookup.
128
+ listItems(now = Date.now()) {
129
+ const { workItemsDisk } = this.modules;
130
+ const all = [];
131
+ // WHAT ONE REPLY MAY DO IS NOT ON THE LEDGER, so it is joined on here, once
132
+ // for the whole fold rather than a file read per row (main/answer-modes.mjs).
133
+ const modes = answerModes({ now });
134
+ for (const product of this.listProducts()) {
135
+ let items = [];
136
+ try {
137
+ items = workItemsDisk.readWorkItems(product.dir, now);
138
+ } catch (err) {
139
+ console.warn(`zero: unreadable work items for ${product.slug}: ${err.message}`);
140
+ continue;
141
+ }
142
+ for (const item of items) {
143
+ const row = { ...item, product: product.slug, productName: product.name };
144
+ const key = `${product.slug}/${item.id}`;
145
+ if (key in modes) row.answerMode = modes[key];
146
+ all.push(row);
147
+ }
148
+ }
149
+ return all;
150
+ }
151
+
152
+ // One item, folded fresh off disk. The supervisor holds a SPAWN-TIME snapshot
153
+ // for the whole life of a session, so it cannot ask what the session did to
154
+ // the row without reading it again; that question is the difference between a
155
+ // delivery and a no-op (settleDelivery, main/supervisor.mjs).
156
+ readItem(slug, id, now = Date.now()) {
157
+ const { workItemsDisk } = this.modules;
158
+ const item = workItemsDisk.readWorkItem(this.productDir(slug), id, now);
159
+ if (!item) return null;
160
+ const row = { ...item, product: slug };
161
+ const mode = readAnswerMode(slug, id, { now });
162
+ if (mode !== undefined) row.answerMode = mode;
163
+ return row;
164
+ }
165
+
166
+ // EVERYTHING THAT HAPPENED ON ONE ITEM, as the lines it actually happened as.
167
+ //
168
+ // The fold keeps the latest value per field, so it cannot answer "what
169
+ // happened here" at all: the row's first title, the reply she sent before the
170
+ // one on screen, the moment an agent picked it up are all in the ledger and
171
+ // nowhere else. Her thread history reads them (renderer/src/thread-history.ts
172
+ // turns them into sentences; this end takes no view on what they mean).
173
+ //
174
+ // The lease lines go here rather than there because they are the bulk of the
175
+ // file and none of them are news: a worker holding a task for an hour writes
176
+ // a heartbeat a minute, and's 39 lines are 15 events.
177
+ readHistory(slug, id, { max = 400 } = {}) {
178
+ const { workItemsDisk } = this.modules;
179
+ if (!/^w-[0-9a-f]{6,}$/.test(String(id ?? ''))) return [];
180
+ const file = workItemsDisk._internals.ledgerPath(this.productDir(slug));
181
+ const lines = [];
182
+ for (const raw of workItemsDisk._internals.readLines(file)) {
183
+ let line = null;
184
+ try { line = JSON.parse(raw); } catch { continue; }
185
+ if (!line || line.id !== id) continue;
186
+ if (line.heartbeat || line.release) continue;
187
+ lines.push(line);
188
+ }
189
+ return lines.slice(-max);
190
+ }
191
+
192
+ // THE CONVERSATION ON ONE ROW, oldest first, for the brief a spawning worker
193
+ // is handed (main/supervisor.mjs, buildBrief). readHistory above is the same
194
+ // ledger read for her thread view and stops at the lines; this one asks
195
+ // shared/work-items.mjs which of them was somebody SPEAKING.
196
+ //
197
+ // Uncapped on purpose. The brief does the trimming, because only the brief
198
+ // knows what else is competing for the same session's attention, and a cap
199
+ // here would silently decide it twice.
200
+ readThread(slug, id) {
201
+ const { workItemsDisk, workItemsCore } = this.modules;
202
+ if (!/^w-[0-9a-f]{6,}$/.test(String(id ?? ''))) return [];
203
+ const file = workItemsDisk._internals.ledgerPath(this.productDir(slug));
204
+ return workItemsCore.threadOf(workItemsDisk._internals.readLines(file), id);
205
+ }
206
+
207
+ // The scheduling rule, delegated rather than copied. A second "is this due
208
+ // yet" is exactly how the spawner and the inbox would come to disagree about
209
+ // the same item, which is the drift this file exists to prevent.
210
+ isDue(item, now = Date.now()) {
211
+ return this.modules.workItemsCore.isDue(item, now);
212
+ }
213
+
214
+ productDir(slug) {
215
+ const dir = path.join(this.config.accountRoot, slug);
216
+ if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`no such product: ${slug}`);
217
+ return dir;
218
+ }
219
+
220
+ // WHERE ONE OF OUR OWN RECORDS FOR A PRODUCT LIVES, and it is not in the
221
+ // product folder. The ledger, session traces and repeat rules are the app's
222
+ // bookkeeping, so they sit in the app's home the way Claude Code's do
223
+ // (main/store/home.mjs). The founder's own documents, and the two files that
224
+ // describe them, stay where she can see them.
225
+ machineryFile(slug, rel) {
226
+ return machineryPath(this.productDir(slug), rel);
227
+ }
228
+
229
+ // Founder writes. Content and lifecycle alike go in as HERS: what she says
230
+ // and what she decides an item's state is both outrank any agent line.
231
+ //
232
+ // Status used to go in as 'system', on the reasoning that a founder-authored
233
+ // status would freeze the item's lifecycle because a worker could never move
234
+ // it again. The reasoning was right abo writing her archive with machinery's
235
+ // authority let the next thing a worker wrote hand the item straight back to
236
+ // her inbox, three times in fourteen minutes.
237
+ //
238
+ // The fold now draws that line itself: her 'open' is a HANDBACK and claims no
239
+ // authority, so the claim, the blocked and the done that follow all land,
240
+ // while her terminal words hold (shared/work-items.mjs, authorityOf). Pinned
241
+ // by tests/v0-loop.test.mjs here and archive-is-final.test.mjs there.
242
+ // `permissionMode` is WHAT THE ANSWER ITSELF MAY DO, and it belongs to this
243
+ // one reply rather than to the project.It is written as a founder field
244
+ // beside the answer, read once by the run that answer starts
245
+ // (supervisor.spawnPlan) and cleared by that run, so it never leaks into the
246
+ // next one. `model` and `effort` are WHICH MODEL PICKS THE NEXT RUN UP, set
247
+ // from the drawer in the reply box.
248
+ //
249
+ // They go on the ITEM as founder fields, exactly where the new-task card
250
+ // writes them, because that is where the spawn reads them back
251
+ // (`modelForEngine`, shared/engines.mjs). Unlike `permissionMode` they are NOT
252
+ // cleared by the run that uses them: she changed the model of the
253
+ // conversation, and the conversation keeps it until she says otherwise.
254
+ //
255
+ // Undefined means she never opened the drawer and the row keeps what it had.
256
+ // Null means she cleared it, and the engine chooses again.
257
+ answerItem(slug, id, { answer, status, priority, permissionMode, model, effort }) {
258
+ const { workItemsDisk } = this.modules;
259
+ const dir = this.productDir(slug);
260
+ let item = null;
261
+ // THE MODEL GOES ON FIRST, BEFORE THE ANSWER. A reply on an idle row is
262
+ // what starts a run, and the run reads the model off the item; written
263
+ // after the answer it is a race the run can win, and she would pick a model
264
+ // and watch the old one take the task.
265
+ //
266
+ // Written as '' rather than null to clear, because that is how "no model"
267
+ // is said everywhere else here: `modelForEngine` (shared/engines.mjs) reads
268
+ // the empty string as nothing chosen, where a JSON null would have to be
269
+ // special-cased in both engines' argument builders.
270
+ if (model !== undefined) item = workItemsDisk.updateWorkItem(dir, id, { model: model ?? '' }, { source: 'founder' });
271
+ if (effort !== undefined) item = workItemsDisk.updateWorkItem(dir, id, { effort: effort ?? '' }, { source: 'founder' });
272
+ if (answer) item = workItemsDisk.updateWorkItem(dir, id, { answer }, { source: 'founder' });
273
+ // The pane may still show a running row after Stop or a provider result.
274
+ // Resolve omitted reply status against the ledger that was just written.
275
+ if (status == null && typeof answer === 'string' && answer.trim() && answer !== '(withdrawn)'
276
+ && (item?.status === 'blocked' || item?.status === 'done')) status = 'open';
277
+ // WHAT THE ANSWER ITSELF MAY DO does not go on the ledger. It is a grant,
278
+ // the ledger is an append-only file every worker writes, and every line on
279
+ // it names its own author with nothing checking the name. It goes in the
280
+ // app's own storage instead (main/answer-modes.mjs).
281
+ if (permissionMode !== undefined) {
282
+ setAnswerMode(slug, id, permissionMode ?? null);
283
+ item = this.readItem(slug, id) ?? item;
284
+ }
285
+ // The founder's priority tag, set at send time; founder source outranks
286
+ // whatever the filing agent chose.
287
+ if (priority != null) item = workItemsDisk.updateWorkItem(dir, id, { priority }, { source: 'founder' });
288
+ if (status) item = workItemsDisk.updateWorkItem(dir, id, { status }, { source: 'founder' });
289
+ return item;
290
+ }
291
+
292
+ // A personal session's final message, written by the supervisor because
293
+ // personal sessions never learn the store exists. The result is the agent's
294
+ // words (source 'agent'); the status is machinery (source 'system', the
295
+ // same split answerItem makes). 'blocked' means waiting on the founder,
296
+ // which is exactly what puts the thread back in the inbox.
297
+ //
298
+ // `status: null` writes the words and leaves the status alone. That is a
299
+ // worker holding the store tools, which set its own status before it ended
300
+ // (`closingMessageIsTheAnswer` in the supervisor).
301
+ recordSessionResult(slug, id, { result, status = 'blocked' }) {
302
+ const { workItemsDisk } = this.modules;
303
+ const dir = this.productDir(slug);
304
+ if (status === null) {
305
+ return result ? workItemsDisk.updateWorkItem(dir, id, { result }, { source: 'agent' }) : this.readItem(slug, id);
306
+ }
307
+ if (result) workItemsDisk.updateWorkItem(dir, id, { result }, { source: 'agent' });
308
+ return workItemsDisk.updateWorkItem(dir, id, { status }, { source: 'system' });
309
+ }
310
+
311
+ // THE WRITTEN NAME THE LIST DRAWS. Written by the app rather than by a
312
+ // session, on agent authority, and never onto `title`: the title is what she
313
+ // typed and the fold keeps it hers (main/row-label.mjs says why at length).
314
+ // Empty is a no-op rather than an erase, so a naming call that came back with
315
+ // nothing usable leaves the row exactly as it was.
316
+ nameItem(slug, id, label) {
317
+ const { workItemsDisk } = this.modules;
318
+ const clean = String(label ?? '').trim();
319
+ if (!clean) return null;
320
+ return workItemsDisk.updateWorkItem(this.productDir(slug), id, { label: clean }, { source: 'agent' });
321
+ }
322
+
323
+ // A one-off permission mode has been spent. Called by the supervisor at the
324
+ // moment it launches the run that mode was set for, so a mode she chose for
325
+ // one message cannot silently govern a respawn three hours later.
326
+ //
327
+ // IT CLEARS THE MODE THAT WAS SPENT, NEVER WHATEVER HAPPENS TO BE THERE NOW.
328
+ // Caught in review, 2026-08-23. The supervisor reads the mode when it builds
329
+ // the launch and clears on the child's own 'spawn' event, so there is a window
330
+ // in between, and a second answer landing inside it must survive. Clearing is
331
+ // the dangerous direction: it does not mean less, it means back to the
332
+ // project's setting, which can be the wider one. So it compares first, in
333
+ // main/answer-modes.mjs, which is where the value lives now.
334
+ clearAnswerMode(slug, id, spent) {
335
+ clearAnswerModeFile(slug, id, spent);
336
+ return this.readItem(slug, id);
337
+ }
338
+
339
+ // A run just went out under this row's mode. Nothing changes; the stored
340
+ // grant's clock is restamped so the month-long sweep in main/answer-modes.mjs
341
+ // measures from the last run on the thread rather than from the moment she
342
+ // picked the mode. This is what stands where the spawn-time clear used to.
343
+ touchAnswerMode(slug, id, used) {
344
+ touchAnswerModeFile(slug, id, used);
345
+ return this.readItem(slug, id);
346
+ }
347
+
348
+ // A session finished acting on her answer. Written by the supervisor as the
349
+ // session exits, because the worker cannot be relied on to close a thread
350
+ // that is still alive: the ordinary and correct end of the loop leaves the
351
+ // row open with her answer still on it, and that state used to be
352
+ // indistinguishable from a worker that died (shared/answers.mjs).
353
+ //
354
+ // Source 'system': it is machinery's record of what happened, not a word
355
+ // anyone said, and it must never hold the field against her. She cannot
356
+ // write it at all, and her next answer outdates it by carrying a later ts.
357
+ settleAnswer(slug, id, answerTs) {
358
+ if (!answerTs) return null;
359
+ const { workItemsDisk } = this.modules;
360
+ return workItemsDisk.updateWorkItem(
361
+ this.productDir(slug), id, { answeredThrough: answerTs }, { source: 'system' },
362
+ );
363
+ }
364
+
365
+ // "Run this later." The moment goes in the ledger as HERS, so it survives an
366
+ // app restart, a reboot, and a store restored onto another machine, and so no
367
+ // agent line can quietly move a time she set. 0 clears it.
368
+ //
369
+ // This is the one write behind both gestures: deferring an agent's proposal
370
+ // out of the inbox and scheduling her own work to start at six. They were
371
+ // separate mechanisms before, and only the first existed: the old snooze was
372
+ // a renderer localStorage map that hid a row and deferred no work at all.
373
+ scheduleItem(slug, id, runAt) {
374
+ const { workItemsDisk } = this.modules;
375
+ return workItemsDisk.updateWorkItem(
376
+ this.productDir(slug), id, { runAt: Math.max(0, Math.trunc(runAt) || 0) }, { source: 'founder' },
377
+ );
378
+ }
379
+
380
+ composeItem(slug, { title, body, kind = 'directive', priority = 0, labels, runAt, engine, model, effort }) {
381
+ const { workItemsDisk } = this.modules;
382
+ const dir = this.productDir(slug);
383
+ // The 'founder' label is the human-in-the-loop marker: only items the
384
+ // founder composed (or explicitly approved) execute; agent-filed items
385
+ // are proposals until then.
386
+ const allLabels = [...new Set(['founder', ...(labels ?? [])])];
387
+ const created = workItemsDisk.createWorkItem(
388
+ dir,
389
+ { title, kind, priority, labels: allLabels },
390
+ { source: 'system' }
391
+ );
392
+ // The founder's words carry founder authority; the lifecycle does not.
393
+ // A runAt sent with the compose is "send later": the item exists now and
394
+ // starts then, which is why it rides in with her content rather than after.
395
+ const contentPatch = {
396
+ title,
397
+ ...(body ? { body } : {}),
398
+ ...(runAt ? { runAt: Math.max(0, Math.trunc(runAt)) } : {}),
399
+ // WHICH MODEL SHE CHOSE FOR THIS ONE, on founder authority like the rest
400
+ // of her content (2026-08-26).It is a Claude Code alias, and the spawn
401
+ // hands it to `--model`.
402
+ ...(model ? { model: String(model) } : {}),
403
+ // AND HOW HARD IT THINKS, the same way. Only a word shaped like a level
404
+ // is written; which words are right depends on the engine and, on Codex,
405
+ // on the model, so that judgement is the spawn's (`Supervisor#spawnPlan`,
406
+ // `codexEffortRefusal`) and not this line's.
407
+ ...(isEffortWord(effort) ? { effort } : {}),
408
+ // AND WHICH CODING AGENT, on the same authority and for the same reason.
409
+ // This was dropped here too, for as long as the field has existed:
410
+ // `zero:compose` took an `engine` argument and this signature never named
411
+ // it, so the 08-25 picker's choice never reached the ledger at all.
412
+ //
413
+ // ON FOUNDER AUTHORITY, WRITTEN AS ITS OWN LINE, which is what makes
414
+ // `wrote.engine.ts` the moment she chose. shared/engines.mjs leans on that
415
+ // stamp to tell a choice she is making now from one made in August, so a
416
+ // path that wrote this any other way would quietly re-open the hazard the
417
+ // whole gate exists for. Whether the word may be written at all is decided
418
+ // before it gets here, by `Supervisor#engineOffered`.
419
+ ...(engine ? { engine: String(engine) } : {}),
420
+ };
421
+ return workItemsDisk.updateWorkItem(dir, created.id, contentPatch, { source: 'founder' });
422
+ }
423
+
424
+ /* ---------------------------- repeating tasks --------------------------- */
425
+ // Every product's rules, with their product on each, the way listItems does
426
+ // it, so the renderer has one list to draw. Ended rules are gone from her
427
+ // view: ending one is how she stops it.
428
+ listRepeats(now = Date.now()) {
429
+ const all = [];
430
+ for (const product of this.listProducts()) {
431
+ for (const rule of this.repeats.list(product.dir, now)) {
432
+ if (rule.endedAt) continue;
433
+ all.push({ ...rule, product: product.slug, productName: product.name });
434
+ }
435
+ }
436
+ return all;
437
+ }
438
+
439
+ // The rule is written and NO work item is created here. That ordering is the
440
+ // point: composeItem appends an open founder item before it applies her
441
+ // content, and another copy of the app can spawn that intermediate state, so a rule that
442
+ // turned out to be unkeepable would leave a one-shot running behind it. The
443
+ // first run arrives on the next tick instead. `on` travels with `every:
444
+ // 'week'` and is the whole of what makes it a Thursday. The rule is passed
445
+ // WHOLE now; `setRule` is the one place that validates it. `engine` AND
446
+ // `model` TRAVEL WITH THE REST, and they are named here for the same reason
447
+ // `on` had to be: the rule is passed WHOLE to `setRule`, so a field this
448
+ // signature does not name is a field that is dropped in silence. That is what
449
+ // happened to `on` for as long as weekly rules existed, and it is what was
450
+ // happening to both of these: she could compose "every morning", visibly pick
451
+ // Codex and a Codex model, and every occurrence ran on the then-current
452
+ // workspace default.
453
+ composeRepeat(slug, { title, body, priority, every, at, on, engine, model }) {
454
+ return this.repeats.setRule(this.productDir(slug), { title, body, priority, every, at, on, engine, model });
455
+ }
456
+
457
+ setRepeat(slug, id, patch) {
458
+ return this.repeats.patchRule(this.productDir(slug), id, patch);
459
+ }
460
+
461
+ endRepeat(slug, id) {
462
+ return this.repeats.endRule(this.productDir(slug), id);
463
+ }
464
+
465
+ // An ordinary agent-filed item: a PROPOSAL, which is what puts it in her
466
+ // inbox and keeps it there until she answers. Used for the miss alert, which
467
+ // is news about a repeating task rather than work she asked for.
468
+ fileItem(slug, { title, body, kind = 'review', priority = 5, labels = [] }) {
469
+ const { workItemsDisk } = this.modules;
470
+ return workItemsDisk.createWorkItem(
471
+ this.productDir(slug), { title, body, kind, priority, labels }, { source: 'agent' },
472
+ );
473
+ }
474
+
475
+ // New product: rare and deliberate. A directory, a project.json, and a first
476
+ // work item that seeds the drive toward launch.
477
+ //
478
+ // Until today this wrote a name and a date and nothing else, so an agent that
479
+ // landed in a new project had nothing to open: `repoPath` is what becomes a
480
+ // worker's cwd (supervisor.mjs), and it was always null here.
481
+ //
482
+ // `repoPath` is optional and stays optional. Cleared on the card, the project
483
+ // is made without a folder, exactly as it was before this change.
484
+ createProduct({ name, repoPath }) {
485
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
486
+ if (!slug) throw new Error('a product needs a name');
487
+ const dir = path.join(this.config.accountRoot, slug);
488
+ if (fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`product ${slug} already exists`);
489
+
490
+ let repo = typeof repoPath === 'string' ? repoPath.trim() : '';
491
+ if (repo) {
492
+ repo = path.resolve(repo.startsWith('~') ? path.join(os.homedir(), repo.slice(1)) : repo);
493
+ // AND IT IS NOT THE WHOLE MAC. The picker refuses these already;
494
+ // this is the backstop, because a project is also made from the
495
+ // palette, from a restored draft and from a test, and a repoPath of
496
+ // `~` is a worker whose working directory is somebody's whole home
497
+ // folder. That is where the run of macOS permission panels a tester
498
+ // saw comes from.
499
+ const verdict = checkProjectFolder(repo, { home: os.homedir() });
500
+ if (!verdict.ok) throw new Error(verdict.say);
501
+ fs.mkdirSync(repo, { recursive: true });
502
+ }
503
+
504
+ fs.mkdirSync(dir, { recursive: true });
505
+ fs.writeFileSync(path.join(dir, 'project.json'), JSON.stringify({
506
+ schemaVersion: 1,
507
+ id: slug,
508
+ name,
509
+ createdAt: new Date().toISOString(),
510
+ ...(repo ? { repoPath: repo } : {}),
511
+ }, null, 2));
512
+ // AND IT FILES NOTHING INTO HER INBOX.
513
+ //
514
+ // It began as a `directive` reading "Take X from idea toward launch", which
515
+ // `isFresh` took straight into a real session in the person's repo, and it
516
+ // was turned into a `question` so nothing would spawn. Making it ask
517
+ // instead of order fixed the runaway session and left the other half
518
+ // standing: a new project still put a row in her inbox that she had not
519
+ // asked anybody for, and the inbox is the one place she cannot afford noise
520
+ // in. Making a project is not itself a question.
521
+ //
522
+ // So the project is made and the inbox is left alone. She starts the work by
523
+ // writing a task in it, the same way she does in every project she already
524
+ // has. `idea` went with the row; it had no other reader, and neither call
525
+ // site in App.tsx ever passed one.
526
+ this.watch(); // pick up the new directory
527
+ return { slug };
528
+ }
529
+
530
+ /**
531
+ * THE PRACTICE PROJECT, MADE FOR REAL.
532
+ *
533
+ * So it is a project like any other: a directory, a project.json, real work
534
+ * items, and the whole app scoped to it while the walk is on. Three things
535
+ * make it different and all three are in the file rather than in a special
536
+ * case somewhere:
537
+ *
538
+ * 1. `practice: true`, which is what stops the supervisor ever starting a
539
+ * session in it and what puts the band on the screen.
540
+ * 2. NO REPO PATH. There is nothing on the disk it points at, so nothing
541
+ * can be edited, run or broken by anything that happens in here.
542
+ * 3. NO OPENING ROW. `createProduct` composes one (it asks what to work on
543
+ * first) and that is a real row waiting on a real answer; the practice
544
+ * inbox is written by `stagePracticeRows` instead.
545
+ *
546
+ * A practice project already there is REMADE rather than refused. Walking the
547
+ * onboarding twice is a thing she does on purpose, and a second walk that
548
+ * died on `already exists` would be a walk with no practice project in it at
549
+ * all.
550
+ *
551
+ * IT USED TO BE REUSED, AND REUSE IS WHAT KEPT SIX WALKS IN ONE LEDGER.
552
+ * MEASURED on the founder's own store: the practice project's ledger held 168
553
+ * lines and 29 session traces, four rows and one task per walk, going back to
554
+ * 08-24. Reuse also un-archived it: the old `endPractice` set `archived:
555
+ * true` in project.json and this call rewrote that same file from scratch, so
556
+ * the walk-end tidy-up of walk one was silently undone by the start of walk
557
+ * two. Her practice project.json at the moment this was written carried no
558
+ * `archived` key at all for exactly that reason, five walks after the first
559
+ * one ended.
560
+ *
561
+ * So the walk starts from nothing every time: the folder goes, its machinery
562
+ * goes, and the four rows below are the whole of what is in it. */
563
+ createPractice() {
564
+ const dir = path.join(this.config.accountRoot, PRACTICE_SLUG);
565
+ this.removePractice(PRACTICE_SLUG);
566
+ fs.mkdirSync(dir, { recursive: true });
567
+ fs.writeFileSync(path.join(dir, 'project.json'), JSON.stringify({
568
+ schemaVersion: 1,
569
+ id: PRACTICE_SLUG,
570
+ name: PRACTICE_NAME,
571
+ createdAt: new Date().toISOString(),
572
+ [PRACTICE_FLAG]: true,
573
+ }, null, 2));
574
+ // AND THE SIDEBAR NOTE IS WRITTEN WITH IT.
575
+ //
576
+ // It is `pinned.md` in the project's own folder, which is the real file
577
+ // the panel reads and writes (main/rail-note.mjs). Nothing about this is
578
+ // special-cased in the renderer: the practice project simply has a note
579
+ // already in it, the way a project somebody has been working in for a week
580
+ // would. The third introduction slab draws the same text, so what the
581
+ // introduction promises is what the practice project actually has.
582
+ fs.writeFileSync(path.join(dir, NOTE_NAME), PRACTICE_NOTE, 'utf8');
583
+ this.watch(); // pick up the new directory
584
+ return { slug: PRACTICE_SLUG };
585
+ }
586
+
587
+ /**
588
+ * AND THE PRACTICE PROJECT IS REMOVED AT THE END — the folder, not a flag.
589
+ *
590
+ * THIS USED TO ARCHIVE, AND SHE HAS OVERRULED THAT. The reasoning it replaces
591
+ * is kept here because it was not stupid: `archived` is one key in one file,
592
+ * `listProducts` already skips it, and "rm -rf on a directory in somebody's
593
+ * store is not a thing an onboarding gets to do" is a rule worth having. What
594
+ * it missed is that the folder is in HER store, next to her real projects,
595
+ * and she opens that store.
596
+ *
597
+ * SO THE DELETE IS NARROW ENOUGH THAT IT CANNOT BE POINTED AT REAL WORK, and
598
+ * every one of these is a refusal rather than a best effort:
599
+ *
600
+ * - It is the ONE path `<accountRoot>/practice` and no other. A practice
601
+ * project living under any other slug is archived the old way instead:
602
+ * the flag is the app's, the path is not, and a directory somebody named
603
+ * themselves is not ours to delete.
604
+ * - It is a real directory by `lstat`, so a SYMLINK at that name is refused
605
+ * outright. A symlinked project is a shape this store really has
606
+ * (`listProducts` stats through them on purpose for the combined-workspace
607
+ * pattern), and following one out of the account root is how a delete
608
+ * scoped to a tutorial reaches somebody's actual repository.
609
+ * - Its `project.json` must parse AND carry `practice: true`. The same fact
610
+ * `listProducts` reads, asked again at the moment of the delete rather
611
+ * than trusted from a listing taken earlier.
612
+ *
613
+ * Never throws: the walk does not stop because a practice project could not
614
+ * be tidied away, and a practice project that is not there is the ordinary
615
+ * case for anybody who walked it before this existed. */
616
+ endPractice(slug = PRACTICE_SLUG) {
617
+ const file = path.join(this.config.accountRoot, slug, 'project.json');
618
+ let project = null;
619
+ try { project = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { removed: false, why: 'no practice project' }; }
620
+ if (project[PRACTICE_FLAG] !== true) return { removed: false, why: 'not a practice project' };
621
+ if (slug !== PRACTICE_SLUG) {
622
+ // A practice-flagged project under a name we did not choose. It leaves
623
+ // the app the old way and its folder stays: the delete below is allowed
624
+ // to be this blunt only because it knows the exact path it is for.
625
+ if (project.archived) return { removed: false, why: 'archived, not ours to delete' };
626
+ try {
627
+ fs.writeFileSync(file, JSON.stringify({ ...project, archived: true }, null, 2));
628
+ this.watch();
629
+ } catch { /* it is out of the way or it is not; neither fails the walk */ }
630
+ return { removed: false, why: 'archived, not ours to delete' };
631
+ }
632
+ return this.removePractice(slug);
633
+ }
634
+
635
+ /**
636
+ * THE DELETE ITSELF, and the three questions it asks before it does anything.
637
+ *
638
+ * Split out from `endPractice` because `createPractice` needs the same
639
+ * guarded removal (a second walk starts from nothing) and two copies of a
640
+ * delete is one copy too many. It is the only code in the app that removes a
641
+ * directory from the founder's store, and everything about it is written to
642
+ * be readable at a glance rather than clever.
643
+ *
644
+ * WHAT GOES: the folder at `<accountRoot>/practice` (its `project.json` and
645
+ * its `pinned.md`), and the app's own machinery for it under
646
+ * `$ASTRAL_HOME/projects/<escaped path>/` — the work-item ledger, the session
647
+ * traces, any repeat rules. WHAT DOES NOT: anything else in the account root,
648
+ * ever, and any machinery directory whose `.origin` does not name this exact
649
+ * project (main/store/home.mjs).
650
+ *
651
+ * THE MACHINERY GOES FIRST, and on purpose. It is keyed on the project's
652
+ * path, so removing the folder first would leave the ledger addressable only
653
+ * by a path that no longer exists — which is precisely the state the
654
+ * founder's Mac was in when this was written, six walks deep. */
655
+ removePractice(slug = PRACTICE_SLUG) {
656
+ const root = path.resolve(this.config.accountRoot);
657
+ const dir = path.join(root, PRACTICE_SLUG);
658
+ if (slug !== PRACTICE_SLUG) return { removed: false, why: 'not the practice path' };
659
+ if (path.resolve(dir) !== dir || path.dirname(dir) !== root) return { removed: false, why: 'not the practice path' };
660
+
661
+ // lstat, never stat: a symlink at this name must be refused rather than
662
+ // followed. There is nothing to remove if it is not there at all, which is
663
+ // the ordinary case on every launch after the first.
664
+ let st = null;
665
+ try { st = fs.lstatSync(dir); } catch { return { removed: false, why: 'no practice project' }; }
666
+ if (st.isSymbolicLink()) return { removed: false, why: 'a symlink, not a practice project' };
667
+ if (!st.isDirectory()) return { removed: false, why: 'not a directory' };
668
+
669
+ // AND IT SAYS SO ABOUT ITSELF, asked here rather than taken on trust from a
670
+ // listing. A folder called `practice` that a person made themselves has no
671
+ // flag in it and is not touched.
672
+ let project = null;
673
+ try { project = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8')); } catch {
674
+ return { removed: false, why: 'no readable project.json' };
675
+ }
676
+ if (project[PRACTICE_FLAG] !== true) return { removed: false, why: 'not a practice project' };
677
+
678
+ const machinery = removeMachinery(dir);
679
+ try {
680
+ fs.rmSync(dir, { recursive: true, force: true });
681
+ } catch (err) {
682
+ // A folder that will not delete must not fail the walk. It is off the
683
+ // screen either way at the end of this, because the next launch sweeps
684
+ // again and because nothing draws a project it cannot read.
685
+ console.warn(`zero: could not remove the practice project: ${err.message}`);
686
+ return { removed: false, why: err.message, machinery: machinery.removed };
687
+ }
688
+ this.watch();
689
+ return { removed: true, machinery: machinery.removed };
690
+ }
691
+
692
+ /**
693
+ * EVERY PRACTICE PROJECT LYING AROUND, removed. A walk that was quit halfway
694
+ * leaves one behind, and the next launch is where that is noticed: `init`
695
+ * calls this, and `firstRunNeeded` counts projects, so a practice project
696
+ * left in the store is also a project that stops a first run ever offering
697
+ * itself again.
698
+ *
699
+ * It answers `{ removed }` where it used to answer `{ archived }`, because
700
+ * the two words mean different things to whoever reads the number and the
701
+ * old one is no longer true of what happened. */
702
+ sweepPractice() {
703
+ let n = 0;
704
+ for (const p of this.listProducts()) {
705
+ if (!p.practice) continue;
706
+ if (this.endPractice(p.slug).removed) n += 1;
707
+ }
708
+ return { removed: n };
709
+ }
710
+
711
+ /**
712
+ * THE THREE ROWS ALREADY WAITING WHEN THE PRACTICE PROJECT OPENS.
713
+ *
714
+ * The copy is in shared/first-run-practice.mjs and the two-writes-per-row
715
+ * trick is the same one `stageFirstRunExamples` uses; see
716
+ * shared/first-run-examples.mjs for why a row needs its agent half stamped
717
+ * later than its founder half before an inbox will draw it. */
718
+ stagePracticeRows(slug, { now = Date.now(), label = 'first-run' } = {}) {
719
+ const { workItemsDisk } = this.modules;
720
+ const dir = this.productDir(slug);
721
+ const ids = [];
722
+ for (const row of PRACTICE_ROWS) {
723
+ const at = stampsFor(row, now);
724
+ const created = workItemsDisk.createWorkItem(
725
+ dir,
726
+ { title: row.title, kind: row.kind, priority: 5, labels: ['founder', label] },
727
+ { source: 'system', now: at.hers },
728
+ );
729
+ // HER HALF CARRIES THE ASK. It used to carry the title a second time and
730
+ // nothing else, so the thread opened on `An agent opened this` with no
731
+ // words under it and the row read as having been born out of nothing.
732
+ // `threadEvents` in renderer/src/thread-history.ts takes the first body to
733
+ // arrive as the ask whichever line it lands on, which is this one.
734
+ workItemsDisk.updateWorkItem(
735
+ dir, created.id, { title: row.title, body: row.body }, { source: 'founder', now: at.hers },
736
+ );
737
+ // AN AGENT PICKED IT UP AND HANDED IT BACK, which is the shape a row that
738
+ // has been worked on really has, and it is what makes the run visible.
739
+ // `withRuns` (renderer/src/thread-history.ts) hangs a session's lines off
740
+ // the CLAIM event in the ledger and drops them on the floor if there
741
+ // isn't one, so without these two lines the trace below is written, read
742
+ // and then silently thrown away. Photographed once without them:
743
+ // "2 messages on this task" over an ask and a result, and not one of the
744
+ // eight things the agent did.
745
+ this.appendPracticeRun(slug, created.id, at.hers);
746
+ // THE RUN HAPPENS BETWEEN THE CLAIM AND THE ANSWER, AND THE STAMPS HAVE
747
+ // TO SAY SO. The thread is drawn in stamp order, so a run written from
748
+ // `at.hers` at nine seconds a line ran straight past a result stamped
749
+ // one second after it, and the pane printed the agent's conclusion in
750
+ // the middle of the work that produced it. Its first line also tied with
751
+ // the claim and sorted above "An agent opened this". Photographed both
752
+ // ways on 2026-08-24.
753
+ const ran = at.hers + 1_000;
754
+ const finished = ran + row.trace.length * 9_000 + 1_000;
755
+ // AND THE RUN ITSELF, so opening one of these shows what the agent did
756
+ // rather than only what it concluded.
757
+ this.writePracticeTrace(slug, created.id, row.trace, ran);
758
+ workItemsDisk.updateWorkItem(dir, created.id, { result: row.result }, { source: 'agent', now: finished });
759
+ ids.push(created.id);
760
+ }
761
+ return { ids };
762
+ }
763
+
764
+ /**
765
+ * THE CLAIM AND THE RELEASE, so the row reads as one an agent worked on.
766
+ *
767
+ * Written straight rather than through `claimWorkItem`, which takes the
768
+ * project lock and is async: these rows were created by this same call a
769
+ * microsecond ago, nothing else can be holding them, and `stagePracticeRows`
770
+ * is called from a synchronous ipc handler. The two lines are built by the
771
+ * same builders the real path uses, so the ledger is identical.
772
+ *
773
+ * THE RELEASE IS NOT OPTIONAL. A claim on its own leaves the row `claimed`,
774
+ * which puts it in In progress instead of the inbox, and beat thirteen is
775
+ * about three rows in an inbox. The release hands it back open. */
776
+ appendPracticeRun(slug, id, at) {
777
+ try {
778
+ const file = this.machineryFile(slug, 'work-items.jsonl');
779
+ const epoch = 1;
780
+ fs.appendFileSync(file, `${JSON.stringify(buildClaimLine({
781
+ id, epoch, holder: 'first-run', now: at, leaseMs: 60_000,
782
+ }))}\n${JSON.stringify(buildReleaseLine({ id, epoch, now: at + 500 }))}\n`);
783
+ } catch { /* the run simply does not draw; the ask and the result still do */ }
784
+ }
785
+
786
+ /**
787
+ * ONE PRACTICE ROW'S RUN, WRITTEN WHERE A REAL RUN'S IS.
788
+ *
789
+ * `zero:session-trace` reads `<product>/sessions/<item-id>/<startedAt>.log`
790
+ * and nothing else, so this writes exactly that file in exactly the shape
791
+ * `traceStreamLine` (main/supervisor.mjs) writes a live one: `HH:MM:SS` then
792
+ * TWO spaces then either `[Tool] argument` or the agent talking.
793
+ * renderer/src/item-thread.ts parses the two apart on that shape, so a
794
+ * single space here turns every tool call into a sentence with a clock
795
+ * printed inside it.
796
+ *
797
+ * THE TIMES ARE THE ROW'S OWN. A trace stamped `now` under a row that
798
+ * landed twenty-two minutes ago is a conversation that happened after it
799
+ * finished, and the thread sorts by these stamps.
800
+ *
801
+ * It is best effort: a practice project whose sessions folder will not take a
802
+ * file is not a reason to fail the beat that stages the rows. The thread
803
+ * simply draws the ledger, which is what it did before this existed. */
804
+ writePracticeTrace(slug, id, lines, startedAt) {
805
+ if (!Array.isArray(lines) || !lines.length) return null;
806
+ try {
807
+ const dir = this.machineryFile(slug, path.join('sessions', id));
808
+ fs.mkdirSync(dir, { recursive: true });
809
+ // ON A WHOLE SECOND, BECAUSE THE STAMP HAS NO MILLISECONDS AND THE
810
+ // READER'S ROLLOVER IS UNFORGIVING. `clockAt` writes HH:MM:SS, so a
811
+ // session that began at …:07.199 has its first line stamped …:07.000,
812
+ // which `momentOf` sees as 199ms BEFORE the session started and pushes a
813
+ // whole day forward. Measured: 86,399,199ms out, on the very first line.
814
+ // Flooring the base is the whole fix, and it has to be the same number in
815
+ // the file name as in the stamps or they disagree by that same fraction.
816
+ const began = Math.floor(startedAt / 1000) * 1000;
817
+ // A few seconds apart, in order, so the run reads as a run. Nine seconds
818
+ // is the measured pace of a real one (renderer/src/components/ItemThread.tsx).
819
+ const text = lines
820
+ .map((line, i) => `${clockAt(began + i * 9_000)} ${line}`)
821
+ .join('\n');
822
+ const file = path.join(dir, `${began}.log`);
823
+ fs.writeFileSync(file, `${text}\n`);
824
+ return file;
825
+ } catch { return null; }
826
+ }
827
+
828
+ /* ----------------------------- the first run ---------------------------- */
829
+ // THE WALK'S EXAMPLE TASK IS ANSWERED HERE, WITHOUT A SESSION.
830
+ //
831
+ // The row is real, the reading pane is real and the result is really written
832
+ // to the ledger, so nothing downstream has to know this one was different.
833
+ // What is missing is the worker: the answer is read straight out of the
834
+ // folder she chose one screen earlier, which takes milliseconds and cannot
835
+ // queue, stall or fail. Her own first task took 59.6 seconds under a sentence
836
+ // promising a few, because it was behind the directive the walk itself had
837
+ // just created.
838
+ //
839
+ // AND THE LINE GOES IN THE SIDEBAR, which is the one thing the live version
840
+ // was for. Only when a line was really found: the sentence that says a folder
841
+ // has no readme in it is a true answer and it is not a description, so it is
842
+ // never written under the project's name.
843
+ finishFirstRunTask(slug, id) {
844
+ const { workItemsDisk } = this.modules;
845
+ const product = this.listProducts().find((p) => p.slug === slug) ?? null;
846
+ // AND SINCE 2026-08-23 THE TASK IS SENT IN THE PRACTICE PROJECT, which
847
+ // points at no folder at all. Everything below reads one, so it cannot run
848
+ // here: a project that is not theirs has nothing honest to say about their
849
+ // code. The answer is written down instead, and it is a decision an agent
850
+ // made on its own, which is the shape the next beat teaches them to close.
851
+ // No one-liner is written either; a practice project has no sidebar line to
852
+ // earn.
853
+ if (product?.practice) {
854
+ const item = workItemsDisk.updateWorkItem(
855
+ this.productDir(slug), id, { result: PRACTICE_ANSWER }, { source: 'agent' },
856
+ );
857
+ // AND THE RUN IT DID, so beat eleven opens onto a conversation. What she
858
+ // opened on 2026-08-24 was the words "Nothing has been said here yet."
859
+ // under the title of the first task anybody sends in Agentbox: the row had
860
+ // a result and no thread at all. Backdated a few seconds so the run reads
861
+ // as having happened before the answer rather than after it.
862
+ this.writePracticeTrace(
863
+ slug, id, PRACTICE_TASK_TRACE, Date.now() - PRACTICE_TASK_TRACE.length * 9_000,
864
+ );
865
+ return { item, line: PRACTICE_ANSWER, oneLiner: null };
866
+ }
867
+ const folder = product?.repoPath ?? null;
868
+ const home = os.homedir().replace(/\/+$/, '');
869
+ const shortFolder = folder && folder.startsWith(home + '/') ? '~' + folder.slice(home.length) : folder;
870
+ const name = product?.name ?? slug;
871
+ // THE ANSWER IS THE PRE-WRITTEN SENTENCE, ours, with her folder's numbers in
872
+ // it. The description is a different fact and it is only ever a sidebar
873
+ // line; see main/first-run.mjs and shared/first-run-shape.mjs.
874
+ const line = answerFor({ folder, name });
875
+ const item = workItemsDisk.updateWorkItem(
876
+ this.productDir(slug), id, { result: line }, { source: 'agent' },
877
+ );
878
+ const described = describeFor({ folder, name, shortFolder });
879
+ if (described.found) this.setOneLiner(slug, described.line);
880
+ return { item, line, oneLiner: described.found ? described.line : null };
881
+ }
882
+
883
+ /**
884
+ * BEAT EIGHT'S THREE ROWS.
885
+ *
886
+ * They are real work items in her real store, because the beat teaches
887
+ * clearing an inbox and a picture of an inbox teaches nothing about the keys
888
+ * that clear one. They carry the walk's own label, so the supervisor never
889
+ * spawns anything on them, and they say "Example." in their first word.
890
+ *
891
+ * THE TWO WRITES PER ROW ARE THE WHOLE TRICK, and shared/first-run-examples
892
+ * says why: a row is in her inbox when an agent answered her after her last
893
+ * word on it, so her half is written at the moment the row claims to have
894
+ * landed and the agent's half a second later. Written by hand here rather
895
+ * than through composeItem because that one stamps everything `now`, and
896
+ * three rows all stamped now read as three rows that arrived in the same
897
+ * millisecond. */
898
+ stageFirstRunExamples(slug, { now = Date.now(), label = 'first-run' } = {}) {
899
+ const { workItemsDisk } = this.modules;
900
+ const dir = this.productDir(slug);
901
+ const ids = [];
902
+ for (const example of EXAMPLES) {
903
+ const at = stampsFor(example, now);
904
+ const created = workItemsDisk.createWorkItem(
905
+ dir,
906
+ { title: example.title, kind: example.kind, priority: 5, labels: ['founder', label] },
907
+ { source: 'system', now: at.hers },
908
+ );
909
+ workItemsDisk.updateWorkItem(dir, created.id, { title: example.title }, { source: 'founder', now: at.hers });
910
+ workItemsDisk.updateWorkItem(dir, created.id, { result: example.result }, { source: 'agent', now: at.agent });
911
+ ids.push(created.id);
912
+ }
913
+ return { ids };
914
+ }
915
+
916
+ /**
917
+ * THE AGENT IMPORT, ARRIVING.
918
+ *
919
+ * One row per agent ticked on the finish card, in the project the walk just
920
+ * made. They are ordinary work items and they are questions, which is what
921
+ * makes them wait: the supervisor never spawns fresh work on a question
922
+ * (`isFresh`, main/supervisor.mjs), so nothing starts until the person says
923
+ * what the agent should do, and their reply spawns a worker the ordinary way
924
+ * (`isContinuation`). No new mechanism anywhere.
925
+ *
926
+ * THE STAGGER IS THE SAME TRICK AS THE WALK'S EXAMPLES. Rows all stamped
927
+ * the same millisecond read as rows with no order, and these have one: the
928
+ * list the finish card showed. A second apart, oldest first, so the top of
929
+ * the inbox is the first agent on the card.
930
+ *
931
+ * Written by hand rather than through composeItem because that one stamps
932
+ * everything now and marks it the founder's own. These are Agentbox asking,
933
+ * which is what lets a later session reword one.
934
+ */
935
+ importAgentRows(slug, chosen, { now = Date.now(), home = os.homedir() } = {}) {
936
+ const { workItemsDisk } = this.modules;
937
+ const dir = this.productDir(slug);
938
+ const product = this.listProducts().find((p) => p.slug === slug) ?? null;
939
+ let existing = [];
940
+ try { existing = workItemsDisk.readWorkItems(dir, now); } catch { /* a fresh project has none */ }
941
+ const wanted = agentsNeedingRows(chosen, existing);
942
+ const ids = [];
943
+ wanted.forEach((agent, i) => {
944
+ const at = now - (wanted.length - 1 - i) * 1000;
945
+ const row = agentImportRow(agent, { projectName: product?.name ?? null, home });
946
+ const created = workItemsDisk.createWorkItem(
947
+ dir,
948
+ { title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
949
+ { source: 'agent', now: at },
950
+ );
951
+ workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now: at });
952
+ ids.push(created.id);
953
+ });
954
+ const names = new Set((chosen ?? []).map((a) => String(a?.name ?? '').trim()).filter(Boolean));
955
+ return { ids, added: ids.length, already: names.size - ids.length };
956
+ }
957
+
958
+ /**
959
+ * HER OWN THREADS, ARRIVING THE SAME WAY.
960
+ *
961
+ * Deliberately the twin of `importAgentRows` above rather than a mechanism of
962
+ * its own: an ordinary work item, a question so the supervisor waits
963
+ * (`isFresh`, main/supervisor.mjs), and her reply spawns a worker the way any
964
+ * reply does. That is the whole specification and there is nothing under it.
965
+ *
966
+ * Stamped a second apart, oldest last, so the newest thread on the card is
967
+ * the newest row in the inbox. `chosen` arrives newest first from
968
+ * `readSessionThreads`. */
969
+ importThreadRows(slug, chosen, { now = Date.now() } = {}) {
970
+ const { workItemsDisk } = this.modules;
971
+ const dir = this.productDir(slug);
972
+ const product = this.listProducts().find((p) => p.slug === slug) ?? null;
973
+ let existing = [];
974
+ try { existing = workItemsDisk.readWorkItems(dir, now); } catch { /* a fresh project has none */ }
975
+ // Codex conversations on the same card are importCodexThreads's, below.
976
+ const claude = (chosen ?? []).filter((t) => t?.source !== 'codex');
977
+ const wanted = threadsNeedingRows(claude, existing);
978
+ const ids = [];
979
+ wanted.forEach((thread, i) => {
980
+ const at = now - i * 1000;
981
+ const row = threadImportRow(thread, { projectName: product?.name ?? null, now });
982
+ const created = workItemsDisk.createWorkItem(
983
+ dir,
984
+ { title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
985
+ { source: 'agent', now: at },
986
+ );
987
+ workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now: at });
988
+ ids.push(created.id);
989
+ });
990
+ const seen = new Set(claude.map((t) => String(t?.id ?? '').trim()).filter(Boolean));
991
+ return { ids, added: ids.length, already: seen.size - ids.length };
992
+ }
993
+
994
+ /* ---------------------------- codex conversations ----------------------- */
995
+ // HER CODEX CONVERSATIONS ASK TO COME IN, AND HER YES OR NO DECIDES. The
996
+ // words and the rules are in shared/codex-import.mjs;
997
+ // main/codex-watch.mjs calls the first two every minute; the bridge calls
998
+ // answerCodexImport when she answers.
999
+
1000
+ /** Every row in every product, each stamped with its product slug. */
1001
+ listAllWorkItems(now = Date.now()) {
1002
+ const { workItemsDisk } = this.modules;
1003
+ const out = [];
1004
+ for (const p of this.listProducts()) {
1005
+ let items = [];
1006
+ try { items = workItemsDisk.readWorkItems(this.productDir(p.slug), now); } catch { items = []; }
1007
+ for (const item of items) out.push({ ...item, product: p.slug });
1008
+ }
1009
+ return out;
1010
+ }
1011
+
1012
+ /**
1013
+ * WHICH PROJECT, WHEN SEVERAL POINT AT THE SAME FOLDER. Her store on
1014
+ * 2026-09-15 had seven projects on ~/Desktop/dev/zero, six of them empty test
1015
+ * projects, and the first build filed into whichever the directory listing
1016
+ * returned first ("Test Proj"). So: the one with the most work of its own,
1017
+ * then the one she made first (project.json's createdAt), then the name. */
1018
+ homeForCodexThread(thread, products, existing = []) {
1019
+ const matches = productsForThread(thread, products);
1020
+ if (matches.length <= 1) return matches[0] ?? null;
1021
+ const own = new Map();
1022
+ for (const item of existing) if (!codexIdOf(item)) own.set(item.product, (own.get(item.product) ?? 0) + 1);
1023
+ const made = (p) => {
1024
+ try { return Date.parse(JSON.parse(fs.readFileSync(path.join(p.dir ?? this.productDir(p.slug), 'project.json'), 'utf8')).createdAt ?? '') || Infinity; } catch { return Infinity; }
1025
+ };
1026
+ return [...matches].sort((a, b) => (own.get(b.slug) ?? 0) - (own.get(a.slug) ?? 0) || made(a) - made(b) || String(a.name).localeCompare(String(b.name)))[0];
1027
+ }
1028
+
1029
+ /**
1030
+ * FILE THE ASKING ROW for each conversation that has none yet, in the
1031
+ * project whose folder it ran in. A conversation nobody's project points at
1032
+ * gets nothing here; the ⌘K card offers it under a new project instead. */
1033
+ askAboutCodexThreads(threads, { now = Date.now() } = {}) {
1034
+ const { workItemsDisk } = this.modules;
1035
+ const products = this.listProducts();
1036
+ const existing = this.listAllWorkItems(now);
1037
+ let asked = 0;
1038
+ for (const thread of codexThreadsNeedingRows(threads, existing)) {
1039
+ const product = this.homeForCodexThread(thread, products, existing);
1040
+ if (!product) continue;
1041
+ const row = codexImportRow(thread, { projectName: product.name, now });
1042
+ const dir = this.productDir(product.slug);
1043
+ const created = workItemsDisk.createWorkItem(
1044
+ dir,
1045
+ { title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
1046
+ { source: 'agent', now },
1047
+ );
1048
+ workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now });
1049
+ asked += 1;
1050
+ }
1051
+ return asked;
1052
+ }
1053
+
1054
+ /**
1055
+ * What the asking row already knows about its conversation, for a yes that
1056
+ * arrives when the transcript cannot be read again. */
1057
+ codexThreadFromRow(item) {
1058
+ const body = String(item?.body ?? '');
1059
+ const grab = (lead) => {
1060
+ const at = body.indexOf(lead);
1061
+ if (at < 0) return '';
1062
+ const rest = body.slice(at + lead.length);
1063
+ const end = rest.indexOf('\n\n');
1064
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
1065
+ };
1066
+ return { id: codexIdOf(item), title: item?.title ?? '', prompt: grab('You asked: '), last: grab('Codex last said: ') };
1067
+ }
1068
+
1069
+ /**
1070
+ * HER ANSWER. Yes turns the asking row into one she can read: her prompt as
1071
+ * the body, Codex's last message as the result, open, in her inbox. No closes
1072
+ * it under `not-imported`, which is where Closed finds it to offer it back.
1073
+ * Yes on a row she once said no to is the same yes. */
1074
+ answerCodexImport(slug, id, choice, { thread = null, now = Date.now() } = {}) {
1075
+ const { workItemsDisk } = this.modules;
1076
+ const dir = this.productDir(slug);
1077
+ const item = workItemsDisk.readWorkItem(dir, id, now);
1078
+ if (!item) return null;
1079
+ const rest = (item.labels ?? []).filter((l) => l !== CODEX_IMPORT_LABEL && l !== NOT_IMPORTED_LABEL);
1080
+ if (choice === 'no') {
1081
+ workItemsDisk.updateWorkItem(dir, id, { labels: [...rest, NOT_IMPORTED_LABEL] }, { source: 'agent', now });
1082
+ return workItemsDisk.updateWorkItem(dir, id, { status: 'done' }, { source: 'founder', now });
1083
+ }
1084
+ const t = { ...this.codexThreadFromRow(item), ...(thread ?? {}) };
1085
+ const patch = codexMirrorPatch(t);
1086
+ // it draws under You, not under an agent's name.
1087
+ workItemsDisk.updateWorkItem(dir, id, { body: patch.body }, { source: 'founder', now });
1088
+ workItemsDisk.updateWorkItem(dir, id, { kind: patch.kind, labels: patch.labels, result: patch.result }, { source: 'agent', now: now + 1 });
1089
+ return workItemsDisk.updateWorkItem(dir, id, { status: 'open' }, { source: 'founder', now: now + 1 });
1090
+ }
1091
+
1092
+ /**
1093
+ * THE ⌘K CARD'S PRESS on Codex conversations: each becomes a readable row at
1094
+ * once, whether it had an asking row, a declined one, or none. */
1095
+ importCodexThreads(slug, chosen, { now = Date.now() } = {}) {
1096
+ const { workItemsDisk } = this.modules;
1097
+ const dir = this.productDir(slug);
1098
+ const product = this.listProducts().find((p) => p.slug === slug) ?? null;
1099
+ let existing = [];
1100
+ try { existing = workItemsDisk.readWorkItems(dir, now); } catch { existing = []; }
1101
+ const ids = [];
1102
+ let already = 0;
1103
+ for (const thread of chosen ?? []) {
1104
+ if (thread?.source !== 'codex') continue; // a Claude Code thread is importThreadRows's
1105
+ const id = String(thread?.id ?? '').trim();
1106
+ if (!id) continue;
1107
+ const had = existing.find((i) => codexIdOf(i) === id);
1108
+ if (had && isCodexMirrorRow(had) && had.status !== 'done') { already += 1; continue; }
1109
+ let rowId = had?.id ?? null;
1110
+ if (!rowId) {
1111
+ const row = codexImportRow(thread, { projectName: product?.name ?? null, now });
1112
+ const created = workItemsDisk.createWorkItem(
1113
+ dir,
1114
+ { title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
1115
+ { source: 'agent', now },
1116
+ );
1117
+ workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now });
1118
+ rowId = created.id;
1119
+ }
1120
+ this.answerCodexImport(slug, rowId, 'yes', { thread, now: now + 2 });
1121
+ ids.push(rowId);
1122
+ }
1123
+ return { ids, added: ids.length, already };
1124
+ }
1125
+
1126
+ /**
1127
+ * KEEP THE ROWS SHE SAID YES TO CURRENT: when Codex writes another answer,
1128
+ * the row's result follows it, which is what puts the news in her inbox. */
1129
+ /**
1130
+ * KEEP THE ASKING ROWS TRUE TOO: when the conversation has moved on, or a
1131
+ * better read of the transcript says more than the row does, the body is
1132
+ * rewritten. The body is the app's own words (agent source), so the fold
1133
+ * takes it. This is also what repairs the rows the first build filed with
1134
+ * "Codex has not answered yet." on every one of them (2026-09-15). */
1135
+ refreshCodexAsks(threads, { now = Date.now() } = {}) {
1136
+ const { workItemsDisk } = this.modules;
1137
+ const byId = new Map((threads ?? []).map((t) => [String(t?.id ?? ''), t]));
1138
+ const names = new Map(this.listProducts().map((p) => [p.slug, p.name]));
1139
+ let refreshed = 0;
1140
+ for (const item of this.listAllWorkItems(now)) {
1141
+ if (!isCodexImportRow(item)) continue;
1142
+ const t = byId.get(codexIdOf(item));
1143
+ if (!t) continue;
1144
+ const row = codexImportRow(t, { projectName: names.get(item.product) ?? null, now });
1145
+ if (row.body === item.body) continue;
1146
+ workItemsDisk.updateWorkItem(this.productDir(item.product), item.id, { body: row.body }, { source: 'agent', now });
1147
+ refreshed += 1;
1148
+ }
1149
+ return refreshed;
1150
+ }
1151
+
1152
+ refreshCodexMirrors(threads, { now = Date.now() } = {}) {
1153
+ const { workItemsDisk } = this.modules;
1154
+ const byId = new Map((threads ?? []).map((t) => [String(t?.id ?? ''), t]));
1155
+ let refreshed = 0;
1156
+ for (const item of this.listAllWorkItems(now)) {
1157
+ if (!isCodexMirrorRow(item) || item.status === 'done') continue;
1158
+ const t = byId.get(codexIdOf(item));
1159
+ if (!t) continue;
1160
+ const patch = codexMirrorPatch(t);
1161
+ if (patch.result === item.result) continue;
1162
+ workItemsDisk.updateWorkItem(this.productDir(item.product), item.id, { result: patch.result }, { source: 'agent', now });
1163
+ refreshed += 1;
1164
+ }
1165
+ return refreshed;
1166
+ }
1167
+
1168
+ /**
1169
+ * The line under the project's name in the rail. Written in place, so a
1170
+ * project.json that has grown other fields keeps them. */
1171
+ setOneLiner(slug, oneLiner) {
1172
+ const file = path.join(this.productDir(slug), 'project.json');
1173
+ try {
1174
+ const project = JSON.parse(fs.readFileSync(file, 'utf8'));
1175
+ project.oneLiner = oneLiner;
1176
+ fs.writeFileSync(file, JSON.stringify(project, null, 2));
1177
+ return true;
1178
+ } catch {
1179
+ return false; // a sidebar line is not worth failing a first run over
1180
+ }
1181
+ }
1182
+
1183
+ /* ------------------------------- dashboard ------------------------------ */
1184
+ // The rail's substance: the product's dashboard picture, folded by the store's
1185
+ // own rules so every reader agrees about state.
1186
+ readDashboard(slug, now = Date.now()) {
1187
+ const { dashboard } = this.modules;
1188
+ const file = path.join(this.productDir(slug), 'dashboard.jsonl');
1189
+ let lines = [];
1190
+ try {
1191
+ lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
1192
+ } catch {}
1193
+ const picture = lines.length ? dashboard.foldLedger(lines, now) : dashboard.defaultDashboard(now);
1194
+
1195
+ // Flatten that picture into what the rail renders: a headline, a few
1196
+ // rows, and the current intent. Unmeasured stays visibly unmeasured; it
1197
+ // never becomes a plausible figure (dashboard law).
1198
+ const asRow = (m) => ({
1199
+ label: m?.label ?? '',
1200
+ value: m?.value == null ? 'unmeasured' : String(m.value),
1201
+ unit: m?.unit ?? '',
1202
+ unmeasured: m?.value == null,
1203
+ });
1204
+ let headline = null;
1205
+ let rows = [];
1206
+ try {
1207
+ const { key, others } = dashboard.metricRow(picture, { others: 4 });
1208
+ headline = key ? asRow(key) : null;
1209
+ rows = others.map(asRow);
1210
+ } catch {}
1211
+ if (picture.bottleneck) rows.push({ label: 'Bottleneck', value: picture.bottleneck, unit: '', unmeasured: false });
1212
+ return {
1213
+ stage: picture.stage,
1214
+ oneLiner: picture.oneLiner,
1215
+ headline,
1216
+ rows,
1217
+ intent: picture.nextMove ?? null,
1218
+ };
1219
+ }
1220
+
1221
+ /* ------------------------------- watching ------------------------------ */
1222
+ // Watch every product dir for ledger changes; debounce into one onChange.
1223
+ // fs.watch on dirs is coarse but cheap, and the renderer refetches folds
1224
+ // rather than patching state, so a spurious event costs one re-read.
1225
+ watch() {
1226
+ this.unwatch();
1227
+ const dirs = [this.config.accountRoot, ...this.listProducts().map((p) => p.dir)];
1228
+ for (const dir of dirs) {
1229
+ try {
1230
+ const watcher = fs.watch(dir, (event, file) => {
1231
+ if (file && !/work-items\.jsonl|dashboard\.jsonl|project\.json/.test(file)) return;
1232
+ this._notify();
1233
+ });
1234
+ this.watchers.push(watcher);
1235
+ } catch {}
1236
+ }
1237
+ }
1238
+
1239
+ _notify() {
1240
+ clearTimeout(this._debounce);
1241
+ this._debounce = setTimeout(() => this.onChange?.(), 150);
1242
+ }
1243
+
1244
+ unwatch() {
1245
+ for (const w of this.watchers) try { w.close(); } catch {}
1246
+ this.watchers = [];
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * `HH:MM:SS` in local time, which is the clock a session trace is written in.
1252
+ * IT IS UTC, THE SAME AS `traceStreamLine` IN main/supervisor.mjs, AND THAT
1253
+ * IS NOT A DETAIL. A trace carries a clock and no date, so the READER puts the
1254
+ * date back: `momentOf` (renderer/src/notes.ts) takes the session's own
1255
+ * startedAt, replaces its UTC hours and minutes with the ones on the line, and
1256
+ * adds a whole day if the result lands before the session began.
1257
+ *
1258
+ * So a line stamped in LOCAL time is read as if those digits were UTC, and on
1259
+ * a Mac in California that is wrong twice over. Shot on 2026-08-24 at 22:38
1260
+ * Pacific: the run wrote "22:38:07", the reader read 22:38 UTC, made it 15:38
1261
+ * Pacific, found that was before the session started and added a day, and the
1262
+ * pane printed "Mon, Aug 24 3:38pm" under a result stamped 10:37pm. Seventeen
1263
+ * hours out and the work appeared to happen after the answer.
1264
+ *
1265
+ * Pinned by tests/the-practice-project-is-the-whole-app.test.mjs, which writes
1266
+ * a trace and reads it back through the renderer's own parser. */
1267
+ function clockAt(ms) {
1268
+ return new Date(ms).toISOString().slice(11, 19);
1269
+ }