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,422 @@
1
+ // HER CLAUDE CODE AGENTS, THE FILES.
2
+ //
3
+ // names three different things on a Mac, and this module is one of them: the
4
+ // `.md` agent files Claude Code loads as subagents. It is the literal reading
5
+ // of her sentence, because Claude Code's own two scopes ARE all-projects and
6
+ // this-project:
7
+ // ~/.claude/agents/*.md every project on the Mac
8
+ // <project>/.claude/agents/*.md that project only
9
+ //
10
+ // Measured on her Mac 08-21: four in the first, four in `astral-desktop`,
11
+ // which is the only one of about thirty dev folders that has any.
12
+ //
13
+ // WHAT THIS DOES NOT CLAIM. Claude Code reads the home folder set itself in
14
+ // every session it starts, whatever directory it is in, so nothing here makes
15
+ // an agent work that was not working already. What Agentbox gains is that it can
16
+ // finally SAY what she has, which it could not do at all: no file under main/
17
+ // or renderer/src mentioned `.claude/agents` before this one.
18
+
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { GUARDED } from '../shared/project-folder-check.mjs';
23
+ import { RECENT_DAYS, isScratchFolder } from './agent-sessions.mjs';
24
+
25
+ /**
26
+ * A file bigger than this is not an agent definition, and reading a gigabyte
27
+ * of something because it was dropped in the folder is not worth the risk. */
28
+ const MAX_BYTES = 256 * 1024;
29
+
30
+ /**
31
+ * AND A FILE OLDER THAN THIS IS NOT AN AGENT SHE IS IMPORTING.
32
+ *
33
+ * The four rows in that shot are the whole of `~/.claude/agents` on her Mac and
34
+ * they were last written on 23 and 30 March, five months before the card that
35
+ * offered them.
36
+ *
37
+ * NOTHING STOPS WORKING WHEN A FILE FALLS OUT OF THE WINDOW. Claude Code loads
38
+ * `~/.claude/agents` itself in every session it starts, which the header of
39
+ * this file has said since it was written. Dropping a March file off the import
40
+ * card takes it off a list; it does not take it off the Mac. */
41
+ const RECENT_MS = RECENT_DAYS * 24 * 60 * 60 * 1000;
42
+
43
+ /**
44
+ * THE FRONT MATTER, and only the parts an agent screen can show. Claude Code
45
+ * writes `name`, `description` and `tools` between two `---` fences. A file
46
+ * with no fence is still an agent as far as Claude Code is concerned, so it
47
+ * is kept and named after itself rather than dropped. */
48
+ export function parseAgentFile(text, file = '') {
49
+ const fallback = path.basename(file, '.md');
50
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text ?? '');
51
+ if (!m) return { name: fallback, description: firstFromBody(text, fallback) };
52
+ const out = {};
53
+ let key = null;
54
+ for (const raw of m[1].split(/\r?\n/)) {
55
+ // A description can run onto its own indented lines, which is how Claude
56
+ // Code writes the long ones. An indented line with no key continues the
57
+ // key above it rather than starting a new one.
58
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(raw);
59
+ if (kv && !/^\s/.test(raw)) {
60
+ key = kv[1];
61
+ out[key] = kv[2].trim();
62
+ } else if (key && raw.trim()) {
63
+ out[key] = `${out[key]} ${raw.trim()}`.trim();
64
+ }
65
+ }
66
+ const name = strip(out.name) || fallback;
67
+ // TAKE WHAT WE CAN GET.
68
+ //
69
+ // A `description` in the front matter is what Claude Code writes and what
70
+ // her own eight all have. It is not guaranteed: an agent file is a markdown
71
+ // file and a person can write one by hand with nothing but a heading in it.
72
+ // So when the front matter has no description, the body is read for the next
73
+ // best thing rather than the row being left blank.
74
+ const description = strip(out.description) || firstFromBody(text.slice(m[0].length), name);
75
+ return { name, description };
76
+ }
77
+
78
+ /**
79
+ * Two strings that are the same thing said two ways: the file name, the front
80
+ * matter name and a heading all reach the screen and are often one word apart.
81
+ * Letters and digits only, so `Docs Reviewer`, `docs-reviewer` and
82
+ * `Docs reviewer.` are one string. */
83
+ function sameThing(a, b) {
84
+ const k = (v) => (v ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
85
+ return !!k(a) && k(a) === k(b);
86
+ }
87
+
88
+ /**
89
+ * THE NEXT BEST THING AFTER THE DESCRIPTION, out of the markdown body: the
90
+ * first heading, and failing that the first line of prose. Both are what a
91
+ * person wrote for a person, which is the whole reason they beat nothing.
92
+ *
93
+ * `avoid` is the agent's own name. Most hand written agent files open
94
+ * `# Docs Reviewer`, and a heading that is the name again is not information,
95
+ * so in that one case the prose under it is taken instead of the heading.
96
+ *
97
+ * What is skipped on the way: blank lines, code fences and anything inside
98
+ * one, list bullets, quotes and horizontal rules. A row reading "```bash" or
99
+ * "---" says less than an empty row does. */
100
+ export function firstFromBody(text, avoid = '') {
101
+ const lines = (text ?? '').split(/\r?\n/);
102
+ let heading = '';
103
+ let prose = '';
104
+ let fenced = false;
105
+ for (const raw of lines) {
106
+ const t = raw.trim();
107
+ if (/^(```|~~~)/.test(t)) { fenced = !fenced; continue; }
108
+ if (fenced || !t) continue;
109
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) continue;
110
+ const h = /^#{1,6}\s+(.*)$/.exec(t);
111
+ if (h) {
112
+ // The first heading wins, and it is usually the agent's own name written
113
+ // out. It is still better than a blank row, so it is taken and the
114
+ // screen's own de-duplication decides whether to show it.
115
+ if (!heading) heading = h[1].trim();
116
+ continue;
117
+ }
118
+ if (/^([-*+]\s|>\s|\d+[.)]\s|<)/.test(t)) continue;
119
+ if (!prose) prose = t;
120
+ if (heading) break;
121
+ }
122
+ const pick = sameThing(heading, avoid) ? (prose || heading) : (heading || prose);
123
+ return strip(pick).replace(/[*_`#]/g, '').trim();
124
+ }
125
+
126
+ function strip(v) {
127
+ const s = (v ?? '').trim();
128
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
129
+ return s.slice(1, -1).trim();
130
+ }
131
+ return s;
132
+ }
133
+
134
+ /**
135
+ * ONE SENTENCE OF IT, and not the instruction to Claude at the front of it.
136
+ *
137
+ * Her descriptions are written to be read by an agent deciding whether to run,
138
+ * so five of her eight open "Use this agent to ...". Four of those in a column
139
+ * is four rows that start with the same three words and say nothing until the
140
+ * fourth. The opening is cut ONLY in the form that leaves a whole sentence
141
+ * behind it, so "Use this skill AFTER completing ..." is left exactly as its
142
+ * author wrote it rather than beheaded into nonsense.
143
+ *
144
+ * The width of the row is the real limit and CSS does that cut; `max` here is
145
+ * only a stop on a sentence long enough to be worth cutting in the string. */
146
+ export function oneLine(description, max = 160) {
147
+ const d = (description ?? '')
148
+ .replace(/\s+/g, ' ')
149
+ .trim()
150
+ .replace(/^use th(?:is|e) (?:agent|skill|subagent) to (\w)/i, (_m, c) => c.toUpperCase());
151
+ if (!d) return '';
152
+ const stop = d.search(/[.!?](\s|$)/);
153
+ const first = stop > 0 ? d.slice(0, stop + 1) : d;
154
+ if (first.length <= max) return first;
155
+ const cut = first.slice(0, max);
156
+ const sp = cut.lastIndexOf(' ');
157
+ return `${(sp > 40 ? cut.slice(0, sp) : cut).replace(/[,;:]$/, '')}...`;
158
+ }
159
+
160
+ /**
161
+ * THE TITLE ON THE SCREEN. A file is named `leon-okafor-qa` and a person is
162
+ * not, so the hyphens come out and the first letters go up. Anything already
163
+ * carrying a capital or a space is left exactly as its author wrote it.
164
+ *
165
+ * The short words stay shouted, because "Leon Okafor Qa" is not what she
166
+ * called it and a screen that renames her own agents is worse than one that
167
+ * shows the file name raw. */
168
+ const SHOUTED = new Set(['qa', 'ui', 'ux', 'api', 'cli', 'ai', 'db', 'pr', 'seo', 'css', 'html', 'sql', 'mcp']);
169
+
170
+ export function agentTitle(name) {
171
+ const n = (name ?? '').trim();
172
+ if (!n || /[A-Z\s]/.test(n)) return n;
173
+ return n
174
+ .replace(/[-_]+/g, ' ')
175
+ .split(' ')
176
+ .map((w) => (SHOUTED.has(w) ? w.toUpperCase() : w.replace(/^\w/, (c) => c.toUpperCase())))
177
+ .join(' ');
178
+ }
179
+
180
+ function readDir(dir, scope, { now = Date.now() } = {}) {
181
+ let names = [];
182
+ try { names = fs.readdirSync(dir); } catch { return []; }
183
+ const out = [];
184
+ for (const f of names.sort()) {
185
+ if (!f.endsWith('.md') || f.startsWith('.')) continue;
186
+ const full = path.join(dir, f);
187
+ let text = '';
188
+ try {
189
+ const st = fs.statSync(full);
190
+ if (st.size > MAX_BYTES) continue;
191
+ if (now - st.mtimeMs > RECENT_MS) continue;
192
+ text = fs.readFileSync(full, 'utf8');
193
+ } catch { continue; }
194
+ const { name, description } = parseAgentFile(text, full);
195
+ if (!name) continue;
196
+ const title = agentTitle(name);
197
+ // A HEADING THAT IS JUST THE NAME AGAIN IS NOT INFORMATION. Most hand
198
+ // written agent files open `# Docs Reviewer`, so the fallback above would
199
+ // put the row's own title underneath it in smaller grey type. When what we
200
+ // could get turns out to be the name, the row simply has no second line.
201
+ const line = oneLine(description);
202
+ out.push({ name, title, line: sameThing(line, title) ? '' : line, scope, path: full });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * BOTH SCOPES, in the order the screen shows them. `folder` is the project's
209
+ * own directory and may be null, which is the case where she has not pointed
210
+ * Agentbox at any code yet and only the home folder set exists. */
211
+ export function readAgentFiles({ home = os.homedir(), folder = null, now = Date.now() } = {}) {
212
+ const user = readDir(path.join(home, '.claude', 'agents'), 'all', { now });
213
+ const project = folder
214
+ ? readDir(path.join(folder, '.claude', 'agents'), 'project', { now })
215
+ : [];
216
+ return { user, project };
217
+ }
218
+
219
+ /* ---------------------- THE FOLDERS SHE HAS NOT NAMED ---------------------- */
220
+
221
+ /**
222
+ * WHERE ELSE ON THIS MAC AN AGENT LIVES.
223
+ *
224
+ * That settles the scope question and it exposes the gap underneath it. A
225
+ * folder agent only works inside its own folder, so it can never be carried
226
+ * into some other project's inbox and nothing here tries to. What measured on
227
+ * her Mac 2026-08-25, four agents in `~/.claude/agents`, four in
228
+ * `~/Desktop/dev/astral-desktop/.claude/agents`, and no project of hers points
229
+ * at that folder. `readAgentFiles` can only ever look in the folder it is
230
+ * handed, so no choice on the card reached those four.
231
+ *
232
+ * This looks for the rest, so the card can offer to make the project that
233
+ * keeps them where they started.
234
+ *
235
+ * A DOT FOLDER IS NEVER A PROJECT, and that is not a guess about tidiness. The
236
+ * same scan over her home folder finds
237
+ * `~/.cursor/extensions/esbenp.prettier-vscode-12.4.0-universal/.claude/agents`
238
+ * with two files in it, which is an editor extension shipping its own and not
239
+ * a folder she works in. Skipping names that begin with a dot is what keeps it
240
+ * off the card. */
241
+
242
+ /** Folder names that are never a project and are expensive to walk. */
243
+ const NOT_A_PROJECT = new Set([
244
+ 'node_modules', 'Library', 'Applications', 'Movies', 'Music', 'Pictures',
245
+ 'Public', 'Photos Library.photoslibrary',
246
+ ]);
247
+
248
+ /**
249
+ * THE FOLDERS macOS GUARDS, AS FULL PATHS, for this walk to refuse to enter.
250
+ *
251
+ * The walk below used to queue every directory under her home folder, and
252
+ * measured on her Mac 2026-08-30 that is 122 of them, three of which are
253
+ * `Desktop`, `Documents` and `Downloads`. macOS answers each with a consent
254
+ * panel carrying our name, which is the panel in her screenshot. It returned
255
+ * nothing for the two panels it cost: no folder on her Mac outside her home
256
+ * set has agents of its own today.
257
+ *
258
+ * `main/first-run.mjs` learned this on 2026-08-25 and `shared/project-folder-
259
+ * check.mjs` holds the list, and this module simply never got the guard.
260
+ *
261
+ * MATCHED AS WHOLE PATHS DIRECTLY UNDER HOME, never by name, which is the rule
262
+ * first-run.mjs states: a folder called `Documents` inside somebody's code
263
+ * folder is their folder, and `~/Documents` is not. A project INSIDE a guarded
264
+ * folder is still reachable, by the door below rather than by a walk. */
265
+ function guardedPaths(home) {
266
+ const here = path.resolve(String(home ?? ''));
267
+ return here ? new Set(GUARDED.map((n) => path.join(here, n))) : new Set();
268
+ }
269
+
270
+ /**
271
+ * WHERE CLAUDE CODE ITSELF SAYS SHE WORKS.
272
+ *
273
+ * It is not a place on the disk, it is a file. Claude Code keeps one key per
274
+ * directory it has ever run in under `projects` in `~/.claude.json`, absolute
275
+ * paths, and reading it needs no permission from anybody: it sits at the top of
276
+ * the home folder, outside every guarded one. Measured on her Mac 2026-08-30:
277
+ * 46 keys, and they name her dev folders exactly, including the ones the walk
278
+ * above can no longer reach because they are under `~/Desktop`.
279
+ *
280
+ * So a folder inside a guarded folder is still offered, on the strength of
281
+ * Claude Code having run there rather than on the strength of us looking. That
282
+ * is the difference she is asking for: Agentbox reaches for one folder she has
283
+ * worked in, not for the whole of Documents.
284
+ *
285
+ * A temp directory is not a project and neither is a worktree Claude made for
286
+ * itself, so `isScratchFolder` and the dot-folder rule throw those out here the
287
+ * same way the walk throws them out below. */
288
+ export function claudeProjectFolders({ home = os.homedir() } = {}) {
289
+ const here = path.resolve(String(home ?? ''));
290
+ let parsed = null;
291
+ try {
292
+ parsed = JSON.parse(fs.readFileSync(path.join(here, '.claude.json'), 'utf8'));
293
+ } catch { return []; }
294
+ const keys = Object.keys(parsed?.projects ?? {});
295
+ const out = [];
296
+ for (const key of keys) {
297
+ if (typeof key !== 'string' || !path.isAbsolute(key)) continue;
298
+ const full = path.resolve(key);
299
+ if (full === here) continue;
300
+ if (isScratchFolder(full, here)) continue;
301
+ // `~/Desktop/claude/.claude/worktrees/upbeat-matsumoto-588a8d` is a real key
302
+ // and it is Claude Code's own scratch checkout, not a folder she opens.
303
+ if (full.split(path.sep).some((seg) => seg.startsWith('.'))) continue;
304
+ out.push(full);
305
+ }
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * How many agent files sit in one `.claude/agents`, without reading any of
311
+ * them. The card needs the count and nothing else until she picks a folder.
312
+ *
313
+ * The age window is applied here too, because a count and the list underneath
314
+ * it are the same fact said twice and a heading reading "four" over an empty
315
+ * list is worse than either. */
316
+ function countAgentFiles(dir, now = Date.now()) {
317
+ try {
318
+ return fs.readdirSync(dir).filter((f) => {
319
+ if (!f.endsWith('.md') || f.startsWith('.')) return false;
320
+ try { return now - fs.statSync(path.join(dir, f)).mtimeMs <= RECENT_MS; } catch { return false; }
321
+ }).length;
322
+ } catch { return 0; }
323
+ }
324
+
325
+ /**
326
+ * Every folder under `roots` that has agents of its own.
327
+ *
328
+ * `roots` is whatever the app can honestly derive: the parent of each project
329
+ * folder she has already pointed Agentbox at, which is how her own dev folder is
330
+ * found without this module guessing at Mac folder conventions. Home and
331
+ * `~/Desktop` are always included, because a first run has no projects yet and
332
+ * a scan that finds nothing is worse than one that looks in two obvious places.
333
+ *
334
+ * HOME ITSELF IS NEVER RETURNED. `~/.claude/agents` is Claude Code's every
335
+ * project scope and the card already has it on the other side of the switch;
336
+ * returning it here would draw it twice under two different promises.
337
+ *
338
+ * Bounded on purpose: two levels below each root and a hard stop at `max`
339
+ * directories, so a Mac with a deep folder tree cannot turn opening a card
340
+ * into a disk walk. */
341
+ export function findAgentFolders({
342
+ home = os.homedir(), roots = [], depth = 2, max = 4000, now = Date.now(),
343
+ named = null,
344
+ } = {}) {
345
+ const here = path.resolve(home);
346
+ const guarded = guardedPaths(here);
347
+ // `~/Desktop` WAS A SEED AND IS NOT ANY MORE. It was here because a first run
348
+ // has no projects to take roots from, and the price of that guess was a
349
+ // consent panel on a folder she had not mentioned. What replaces it is the
350
+ // list Claude Code keeps of the folders it has actually run in, which reaches
351
+ // `~/Desktop/dev/astral-desktop` by name and asks about nothing else.
352
+ const seeds = [here, ...(Array.isArray(roots) ? roots : [])]
353
+ .filter((r) => typeof r === 'string' && r.trim())
354
+ .map((r) => path.resolve(r))
355
+ // A project sitting directly in `~/Desktop` hands this walk `~/Desktop` as
356
+ // its root, which is the guarded folder again by another door.
357
+ .filter((r) => !guarded.has(r));
358
+ const found = new Map();
359
+ const seen = new Set();
360
+ const queue = seeds.map((dir) => ({ dir, left: depth }));
361
+ let looked = 0;
362
+ // THE FOLDERS CLAUDE CODE NAMES ARE PROBED, NEVER ENUMERATED. One `readdir`
363
+ // of one `.claude/agents` inside a folder she works in, and no listing of the
364
+ // folder it is in or of anything above it.
365
+ const byName = Array.isArray(named) ? named : claudeProjectFolders({ home: here });
366
+ for (const dir of byName) {
367
+ const full = path.resolve(dir);
368
+ if (full === here || seen.has(full)) continue;
369
+ seen.add(full);
370
+ const n = countAgentFiles(path.join(full, '.claude', 'agents'), now);
371
+ if (n > 0) found.set(full, n);
372
+ }
373
+ while (queue.length) {
374
+ const { dir, left } = queue.shift();
375
+ if (seen.has(dir)) continue;
376
+ seen.add(dir);
377
+ if (++looked > max) break;
378
+ if (dir !== here) {
379
+ const n = countAgentFiles(path.join(dir, '.claude', 'agents'), now);
380
+ if (n > 0) found.set(dir, n);
381
+ }
382
+ if (left <= 0) continue;
383
+ let entries = [];
384
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
385
+ for (const e of entries) {
386
+ // isDirectory is false for a symlink, which is also how this cannot
387
+ // walk in a circle.
388
+ if (!e.isDirectory()) continue;
389
+ if (e.name.startsWith('.') || NOT_A_PROJECT.has(e.name)) continue;
390
+ const child = path.join(dir, e.name);
391
+ // The guard. macOS asks about each of these separately and this walk has
392
+ // no business in any of them.
393
+ if (guarded.has(child)) continue;
394
+ queue.push({ dir: child, left: left - 1 });
395
+ }
396
+ }
397
+ return [...found]
398
+ .map(([folder, count]) => ({ folder, count, short: shortPath(folder, here), name: path.basename(folder) }))
399
+ .sort((a, b) => a.folder.localeCompare(b.folder));
400
+ }
401
+
402
+ /**
403
+ * `/Users/you/Desktop/dev/astral-desktop` → `~/Desktop/dev/astral-desktop`. The
404
+ * card prints this, and a path with her account name in it reads like a system
405
+ * string rather than a folder she recognises. */
406
+ export function shortPath(folder, home = os.homedir()) {
407
+ const here = path.resolve(home);
408
+ const full = path.resolve(folder ?? '');
409
+ return full === here || full.startsWith(`${here}/`) ? `~${full.slice(here.length)}` : full;
410
+ }
411
+
412
+ /**
413
+ * THE AGENTS INSIDE ONE FOLDER, read the same way a project's own side is.
414
+ *
415
+ * The line she skipped could only say a number, because `findAgentFolders`
416
+ * counts files and never opens one. A folder that is going to be a heading on
417
+ * the card with its agents listed under it needs the agents themselves, so
418
+ * this reads them. Same `readDir`, same scope tag as a project's own folder,
419
+ * because that is exactly what this folder is about to become. */
420
+ export function readFolderAgents(folder, { now = Date.now() } = {}) {
421
+ return folder ? readDir(path.join(folder, '.claude', 'agents'), 'project', { now }) : [];
422
+ }
@@ -0,0 +1,254 @@
1
+ // WHEN SHE ASKED TO SEE AN AGENT AGAIN, AND WHEN SHE ASKED NOT TO. One small
2
+ // durable map, and the only state the app keeps about a session it did not start.
3
+ //
4
+ // Three facts per agent and they are different facts. `runAt` is a moment she
5
+ // named and it passes on its own. It used to be a watermark compared against
6
+ // the session's own activity; `putAway` in shared/agents.mjs carries the whole
7
+ // story of why it is not. Neither sends anything, and neither touches the
8
+ // process — both are about her inbox.
9
+ //
10
+ // `repliedAt` is the third, and it is the moment she SPOKE to the session.It
11
+ // had not, because nothing anywhere remembered that she had said anything. The
12
+ // message went into somebody's terminal and her own list looked identical
13
+ // afterwards, which is the same shape as a message being swallowed.
14
+ //
15
+ // Unlike the other two this one is not only about her inbox — it is the record
16
+ // of a thing she did — but it is kept here for the same reason they are: there
17
+ // is no ledger an agent row could be written to. It is compared against the
18
+ // session's own `statusAt` (`tookHerReply` in shared/agents.mjs), so a session
19
+ // that comes back to a stop AFTER she spoke ends the reply rather than this
20
+ // having to expire.
21
+ //
22
+ // It is here, and not in the ledger, because there is no ledger to put it in:
23
+ // an agent row stands for somebody's terminal, it belongs to no product, and a
24
+ // write against `agent:41234` would append a line to a real product's
25
+ // work-items.jsonl for an item that does not exist (main/ipc.mjs refuses
26
+ // exactly that, and still does).
27
+ //
28
+ // Machine-local on purpose. The agents this defers are processes on THIS
29
+ // machine, read out of `ps`; carrying the moment to another machine would carry
30
+ // it away from the only thing it is about.
31
+ //
32
+ // Nothing here fires. The moment is a predicate the reader asks about every
33
+ // measurement (`putOff` in shared/agents.mjs), so a moment missed while the
34
+ // laptop was shut is simply past when the lid opens, and the row is back.
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ // Imported rather than re-derived: what identifies an agent is a rule, and
39
+ // there is one copy of it.
40
+ import { agentKey } from '../shared/agents.mjs';
41
+
42
+ const num = (v) => (Number.isFinite(Number(v)) ? Math.max(0, Math.trunc(Number(v))) : 0);
43
+
44
+ // HER SIDE OF A THREAD IS SMALL, and this file is read on every draw. Measured
45
+ // across her 16 real sessions on 2026-08-17: 155 turns of hers in total, median
46
+ // 6 per session, median 131 characters. The card shows three. Eight, at 600
47
+ // characters, is far more than anything draws and small enough that a session
48
+ // left running for a month cannot grow the file without bound.
49
+ const KEEP_SPOKE = 8;
50
+ const SPOKE_CHARS = 600;
51
+
52
+ function trimSpoke(list) {
53
+ const out = [];
54
+ for (const t of Array.isArray(list) ? list : []) {
55
+ const at = num(t?.at);
56
+ const text = String(t?.text ?? '').trim().slice(0, SPOKE_CHARS);
57
+ if (at > 0 && text) out.push({ at, text });
58
+ }
59
+ out.sort((a, b) => a.at - b.at);
60
+ return out.slice(-KEEP_SPOKE);
61
+ }
62
+
63
+ export class AgentSchedule {
64
+ constructor(root) {
65
+ this.file = path.join(root, '.zero-agent-schedule.json');
66
+ this.byKey = this._read();
67
+ }
68
+
69
+ _read() {
70
+ try {
71
+ const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
72
+ const out = {};
73
+ for (const [key, value] of Object.entries(raw ?? {})) {
74
+ const runAt = num(value?.runAt);
75
+ const doneThrough = num(value?.doneThrough);
76
+ const repliedAt = num(value?.repliedAt);
77
+ const spoke = trimSpoke(value?.spoke);
78
+ // The same four-way test as `_keep`. an entry holding only `spoke` is
79
+ // the record of a conversation, and this used to write it to disk and
80
+ // then drop it on the next read.
81
+ if (runAt > 0 || doneThrough > 0 || repliedAt > 0 || spoke.length) {
82
+ out[key] = { runAt, doneThrough, repliedAt, spoke, setAt: num(value?.setAt) };
83
+ }
84
+ }
85
+ return out;
86
+ } catch {
87
+ // No file, or a half-written one. An empty map reads as "nothing is put
88
+ // off", which is the state of every machine on the day this shipped.
89
+ return {};
90
+ }
91
+ }
92
+
93
+ _write() {
94
+ try {
95
+ fs.writeFileSync(this.file, JSON.stringify(this.byKey));
96
+ } catch {}
97
+ }
98
+
99
+ /** The moment set on one agent, or 0. */
100
+ runAt(key) {
101
+ return this.byKey[key]?.runAt ?? 0;
102
+ }
103
+
104
+ /** The activity she has already seen and closed, or 0. */
105
+ doneThrough(key) {
106
+ return this.byKey[key]?.doneThrough ?? 0;
107
+ }
108
+
109
+ /** When she last spoke to this session from Agentbox, or 0. */
110
+ repliedAt(key) {
111
+ return this.byKey[key]?.repliedAt ?? 0;
112
+ }
113
+
114
+ _entry(key) {
115
+ return this.byKey[key] ?? { runAt: 0, doneThrough: 0, repliedAt: 0, spoke: [], setAt: 0 };
116
+ }
117
+
118
+ // An entry that says none of "later", "seen", "I spoke to it" or "here is what
119
+ // she said" is not an entry. The last of those four is why this is not the
120
+ // same list as it was: her words outlive the reply that carried them, so an
121
+ // entry holding only `spoke` is still the record of a conversation.
122
+ _keep(key, entry, now) {
123
+ if (entry.runAt > now || entry.doneThrough > 0 || entry.repliedAt > 0 || entry.spoke?.length) this.byKey[key] = entry;
124
+ else delete this.byKey[key];
125
+ this._write();
126
+ }
127
+
128
+ /**
129
+ * Put one off until `runAt`, or bring it back with 0.
130
+ *
131
+ * A moment already past is the same thing as no moment: the predicate would
132
+ * be false the instant it was written, so storing it would only leave a row
133
+ * in the file that means nothing and outlives the session it named.
134
+ */
135
+ set(key, runAt, now = Date.now()) {
136
+ if (!key) return 0;
137
+ const when = Math.max(0, Math.trunc(Number(runAt) || 0));
138
+ const entry = this._entry(key);
139
+ this._keep(key, { ...entry, runAt: when > now ? when : 0, setAt: now }, now);
140
+ return when > now ? when : 0;
141
+ }
142
+
143
+ /**
144
+ * `through` is the agent's own last activity at the moment she closed it; 0
145
+ * undoes that and puts the row back, which is what her undo sends.
146
+ *
147
+ * ANY MARK ABOVE ZERO MEANS CLOSED.
148
+ */
149
+ close(key, through, now = Date.now()) {
150
+ if (!key) return 0;
151
+ const mark = Math.max(0, Math.trunc(Number(through) || 0));
152
+ const entry = this._entry(key);
153
+ // CLOSING A ROW ENDS THE REPLY TOO. She is done with it here; leaving the
154
+ // mark standing would put it straight back into In progress the moment she
155
+ // undid the close, saying a session was working on something she had
156
+ // already finished with.
157
+ //
158
+ // BUT IT DOES NOT UNSAY WHAT SHE SAID. This used to empty `spoke` as well,
159
+ // and `spoke` is the ONLY record anywhere that she ever spoke to a session:
160
+ // Claude Code stamps an injected message `isMeta` and the transcript reader
161
+ // drops it, correctly, so nothing else on the machine knows. Measured on
162
+ // session-46: four of her messages reached that session and the file holds
163
+ // one, and its card was back to quoting only what she started it with five
164
+ // days earlier. Closing a row is about her inbox and nothing else, which is
165
+ // what the row itself promises her; it is not a licence to forget a
166
+ // conversation she can still open and read.
167
+ this._keep(key, {
168
+ ...entry,
169
+ doneThrough: mark,
170
+ repliedAt: mark > 0 ? 0 : entry.repliedAt,
171
+ setAt: now,
172
+ }, now);
173
+ return mark;
174
+ }
175
+
176
+ /**
177
+ * `at` is when; 0 clears it, which is what withdrawing a reply sends.
178
+ *
179
+ * Nothing here reaches the session — `main/agents.mjs` already did that, and
180
+ * this is only written once the message was actually handed over. A mark for
181
+ * a send that failed would move the row into In progress over a message that
182
+ * never arrived, which is the failure this codebase cares about most.
183
+ */
184
+ replied(key, at, now = Date.now(), text = '') {
185
+ if (!key) return 0;
186
+ const mark = Math.max(0, Math.trunc(Number(at) || 0));
187
+ const entry = this._entry(key);
188
+ // WITHDRAWING TAKES THE WORDS BACK OUT OF THE CARD TOO. `replied(key, 0)`
189
+ // is the undo, and a card that still quoted the message underneath it
190
+ // would say she had said something she had just unsaid.
191
+ const spoke = mark === 0
192
+ ? []
193
+ : trimSpoke([...(entry.spoke ?? []), ...(String(text).trim() ? [{ at: mark, text: String(text).trim() }] : [])]);
194
+ this._keep(key, { ...entry, repliedAt: mark, spoke, setAt: now }, now);
195
+ return mark;
196
+ }
197
+
198
+ /**
199
+ * WHAT SHE ACTUALLY TYPED INTO THIS SESSION FROM AGENTBOX, oldest first.
200
+ *
201
+ * It is here because it is the only place that knows. Claude Code stamps a
202
+ * message injected over its socket `isMeta: true` and wraps it in "Another
203
+ * Claude session sent a message… not typed by y measured 2026-08-17 on
204
+ * session-67, where two messages she sent at 19:41 left the row saying only
205
+ * what she had started it with four days earlier.
206
+ *
207
+ * A another of her sessions pinging this one is a real peer and drawing it as
208
+ * her words would be a lie on the one card built to be trusted. So the record
209
+ * is written at the moment of delivery, by the only process that knows it
210
+ * delivered it.
211
+ */
212
+ spoke(key) {
213
+ return this.byKey[key]?.spoke ?? [];
214
+ }
215
+
216
+ /**
217
+ * Every live agent, carrying whatever moment she set on it.
218
+ *
219
+ * THE SWEEP IS NOT ALLOWED TO EAT THE FILE. A key whose moment has passed is
220
+ * inert and always goes: the predicate already ignores it. A key for a
221
+ * session that is not in this reading is a moment about a process that has
222
+ * exited — but only if the reading is real. `listAgents` answers [] on the
223
+ * first call of every boot (it hands back the cache and measures in the
224
+ * background) and again whenever a `ps` fails, and sweeping against either of
225
+ * those would clear every moment she has set, at every app start, with no
226
+ * symptom except her deferred rows all coming back.
227
+ */
228
+ decorate(agents, now = Date.now()) {
229
+ const live = new Set();
230
+ const out = agents.map((agent) => {
231
+ const key = agentKey(agent);
232
+ live.add(key);
233
+ const entry = this.byKey[key];
234
+ if (!entry) return agent;
235
+ const out = { ...agent };
236
+ if (entry.runAt > now) { out.runAt = entry.runAt; out.runAtSetAt = entry.setAt; }
237
+ if (entry.doneThrough > 0) out.doneThrough = entry.doneThrough;
238
+ if (entry.repliedAt > 0) out.repliedAt = entry.repliedAt;
239
+ // the card quotes what she said last, and a session that has since
240
+ // stopped is exactly when she most needs to see it.
241
+ if (entry.spoke?.length) out.spoke = entry.spoke;
242
+ return out;
243
+ });
244
+ let dropped = false;
245
+ for (const [key, entry] of Object.entries(this.byKey)) {
246
+ const gone = live.size > 0 && !live.has(key);
247
+ if (!gone && (entry.runAt > now || entry.doneThrough > 0 || entry.repliedAt > 0 || entry.spoke?.length)) continue;
248
+ delete this.byKey[key];
249
+ dropped = true;
250
+ }
251
+ if (dropped) this._write();
252
+ return out;
253
+ }
254
+ }