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,412 @@
1
+ // Shared contracts: the schemas everything meets at.
2
+ // Plain ESM, one import. Imported by main (Node) and renderer (Vite).
3
+ // Validation is deliberately light: enough to make lying structurally hard
4
+ // (EXECUTED without evidence, dangling refs, stale ask responses), no more.
5
+
6
+ import { nameSlug } from './product-name.mjs';
7
+
8
+ export const SCHEMA_VERSION = 2;
9
+
10
+ /* ---------------------------------- IPC ---------------------------------- */
11
+ // Channel names, one place. Renderer subscribes/invokes only through these.
12
+ export const IPC = {
13
+ // renderer -> main
14
+ sendMessage: 'chat:send', // { projectId, text }
15
+ answerAsk: 'ask:answer', // AskResponse
16
+ openProject: 'project:open', // { projectId } -> ProjectSnapshot
17
+ listProjects: 'project:list', // -> [{ id, name, updatedAt }]
18
+ createProject: 'project:create', // { name } -> { id }
19
+ renameProject: 'project:rename', // { projectId, name } -> { ok }
20
+ deleteProject: 'project:delete', // { projectId } -> { ok } | { error } (refuses the open product; erases the folder from disk)
21
+ interruptRun: 'run:interrupt', // { projectId, chatId }
22
+ recoveryResume: 'recovery:resume', // { projectId, chatIds: string[] } -> { ok, resumed: string[] } resume interrupted (cut) chats: for each, ensureSession then kickoff the app-written resume brief (non-persisted, non-echoed). Chats sitting on a pending ask card are skipped (their card is the recovery). Kicking a turn off clears cutAt in the ledger, so the row leaves the Welcome Back sheet as it actually goes working.
23
+ queueAction: 'chat:queue-action', // { projectId, chatId, action: 'cancel'|'sendNow'|'bringBack', id } -> { ok, text?, attachments? } act on an up-next (held) message
24
+ closeCreation: 'creation:close', // { projectId, creationId }
25
+ deleteCreation: 'creation:delete', // { projectId, creationId } -> { ok } (removes the file + catalog entry, not just the tab)
26
+ saveCreationContent: 'creation:save', // { projectId, creationId, content } -> { ok } (founder edits a doc)
27
+ creationContent: 'creation:content', // { projectId, creationId } -> { content } lazy body fetch: project:open ships content only for open tabs and small docs (big closed-tab bodies made boot crawl); the renderer hydrates the rest through here on tab open
28
+ creationPageInlined: 'creation:page-inlined', // { projectId, creationId, page } -> { html } | { error } a folder-design page rendered fully self-contained (relative assets inlined as data URIs) for a token-free srcdoc preview
29
+ createCreation: 'creation:create', // { projectId, title } -> { ok, id } founder makes a markdown doc by hand (the Library's New document)
30
+ addTemplate: 'creation:add-template', // { projectId, template } -> { ok, id, existing? } instantiate a stock template (e.g. 'agents-tray') as a folder-backed creation; opens the existing one if the project already has it
31
+ libraryImport: 'library:import', // { projectId, folder, files: [{ name, mediaType, data }] } -> { ok, imported, refused } founder drops files into the Library; each lands as a kind 'file' creation
32
+ creationFiles: 'creation:files', // { projectId, creationId } -> { tree } | { error } nested file tree (shared/file-tree.mjs buildFileTree) for a folder-backed creation
33
+ creationReadFile: 'creation:read-file', // { projectId, creationId, file } -> { content } | { binary: true } | { error } read one file from a folder-backed creation; binary assets are served instead via creation-file://
34
+ creationSearch: 'creation:search', // { projectId, creationId, query } -> { results: [{ file, line, text }] } | { error } case-insensitive content grep over a folder-backed creation (skips binaries/.env), caps 100 total / 5 per file / 200 chars
35
+ creationSaveFile: 'creation:save-file', // { projectId, creationId, file, content } -> { ok } | { ok: false, error } write + commit one file in a folder-backed creation
36
+ creationSaveImage: 'creation:save-image', // { projectId, creationId, name, mediaType, data(base64) } -> { ok, url, path } | { ok: false, error } save a pasted/dropped image into a doc's assets/ folder, served back over creation-file://
37
+ creationRun: 'creation:run', // { projectId, creationId } -> { ok } start the local runtime for a folder-backed creation
38
+ creationStop: 'creation:stop', // { projectId, creationId } -> { ok } stop the running local runtime
39
+ creationRestore: 'creation:restore', // { projectId, creationId, ref? } -> { ok, restoredTo?, snapshotTag?, warning? } restore a folder-backed creation's files to `ref` (default the "original" tag); dirty work is snapshotted AND tagged first
40
+ setCreationStatus: 'creation:setStatus', // { projectId, creationId, status: 'history'|'exploration'|'current', replacedBy? } -> { ok, creation } | { error } what a creation IS to the company: 'history' = superseded (kept, hidden from default reading), 'exploration' = made and never adopted (kept and SHOWN, but never the live answer to anything), 'current' = the live answer (the absence of a status key). Metadata only, no files move
41
+ saveView: 'view:save', // { projectId, openTabs, activeTab, pinnedTabs, browsers } -> { ok } (which tabs are up; view.json)
42
+ // chats (multiple conversations per product)
43
+ listChats: 'chat:list', // { projectId } -> [{ id, title, updatedAt }]
44
+ createChat: 'chat:create', // { projectId } -> { id, title }
45
+ scrubEmpty: 'chat:scrub-empty', // { projectId, keepIds?, activeId? } -> { removed: string[] } trash abandoned zero-message chats (never the active one or a keepIds chat with unsent draft text); recoverable, never rm
46
+ quickCapture: 'chat:quick-capture', // { projectId?, text, attachments?, model? } -> { id, title, projectId } | { error } create a chat in the current project and send its first message WITHOUT changing the active chat (Cmd+J quick capture); model pins the new chat's brain at birth ('auto'/omitted = the workspace default), so it applies to the first message; emits chat.created + chat.status like a normal send
47
+
48
+ openChat: 'chat:open', // { projectId, chatId } -> { chatId, chat: ChatMessage[] }
49
+ renameChat: 'chat:rename', // { projectId, chatId, title } -> { ok }
50
+ deleteChat: 'chat:delete', // { projectId, chatId } -> { ok } (session closed first, log trashed)
51
+ chatModel: 'chat:model', // { projectId, chatId, model? } -> { model } get/set THIS chat's model; setting also stamps project.lastModel for future chats
52
+ setChatUnread: 'chat:set-unread', // { projectId, chatId, unread } -> { ok } flip a chat's read state WITHOUT opening it: true resurfaces it under Your turn / For you (sidebar "Mark as unread", tray drag), false puts it away (tray drag to Quiet). Goes through the status ledger so it persists and the next feed sync agrees.
53
+ // settings + app
54
+ getSettings: 'settings:get', // -> settings + { dataDir, imapPasswordSet }
55
+ setSettings: 'settings:set', // patch (imapPassword goes to the Keychain, never the file)
56
+ revealData: 'app:reveal-data', // open the configured store root in Finder
57
+ openExternal: 'app:open-external', // { url } -> { ok } open a link in the real browser (doc-editor "Visit"); http/https/mailto only
58
+ // GitHub connector (device flow lives in main; renderer only relays)
59
+ githubConnectStart: 'github:connect-start', // -> { userCode, verificationUri } (opens browser, starts polling)
60
+ githubConnectWait: 'github:connect-wait', // -> { ok, login } | { error } resolves when the poll finishes
61
+ githubStatus: 'github:status', // -> { connected, login? }
62
+ githubDisconnect: 'github:disconnect', // -> { ok } removes the Keychain token
63
+ // Personal Telegram bot connector. Token validation and long polling stay
64
+ // in main; the renderer only drives setup and reads connection state.
65
+ telegramStatus: 'telegram:status', // -> configured/link/listener state
66
+ telegramConnect: 'telegram:connect', // { token } validates, Keychain-saves, and starts linking
67
+ telegramBeginLink: 'telegram:begin-link', // -> a fresh one-tap t.me link
68
+ telegramDisconnect: 'telegram:disconnect', // -> removes token, link, and local routing state
69
+ // Per-product secrets (the founder's OWN product API keys), stored in the
70
+ // macOS Keychain per project, injected into a runnable creation's env at
71
+ // spawn. NO channel ever returns a secret VALUE: list is names-only, set/
72
+ // delete return only { ok }. Values ride set one-way and are never read back.
73
+ secretsList: 'secrets:list', // { projectId } -> { names: string[] }
74
+ secretsSet: 'secrets:set', // { projectId, name, value } -> { ok } | { error }
75
+ secretsDelete: 'secrets:delete', // { projectId, name } -> { ok }
76
+ usageGet: 'usage:get', // -> { projects: { id: { name, models } }, updatedAt } token/cost ledger (best effort, never a gate)
77
+ // Plan and credits surface. Both NEVER throw and NEVER gate; they mirror the
78
+ // billing client's { ok, reason } philosophy so the renderer can render
79
+ // signed-out / unreachable states honestly.
80
+ billingSummary: 'billing:summary', // -> { ok:true, balance, plan, status, menu } | { ok:false, reason, menu } balance (GET /balance) + menu (GET /menu) together
81
+ billingCheckout: 'billing:checkout', // { kind: 'subscription'|'topup' } -> { ok:true } (opens Stripe Checkout in the system browser) | { ok:false, reason }
82
+ billingPortal: 'billing:portal', // -> { ok:true, url } Stripe billing portal (past invoices, card, cancel) | { ok:false, reason }; reason:'no-customer' when they never checked out
83
+ // Cloud (Supabase; auth + sync live in main, tokens in the Keychain)
84
+ authStatus: 'auth:status', // -> { signedIn, email?, isAdmin, gateRequired, sync? }
85
+ // `authSignInGoogle` was here. It opened the system browser and waited for a
86
+ // deep link on the other product's URL scheme to come back.
87
+ authSignInPassword: 'auth:signin-password', // { email, password } -> { ok, error?, code? } signInWithPassword; on ok fans out auth.changed
88
+ authSignUpPassword: 'auth:signup-password', // { email, password } -> { ok, error?, code? } signUp (no email confirmation); on ok fans out auth.changed
89
+ // NOTE: there is deliberately no emailed-code door.
90
+ // `auth:start`/`auth:verify` were removed on 2026-08-06; the product now
91
+ // sends no email.
92
+ authSignOut: 'auth:signout', // -> { ok }
93
+ // Consent-based, move-only migration of PRE-account projects at the legacy
94
+ // root into the signed-in account's space (main/store/account.mjs).
95
+ migrationStatus: 'migration:status', // -> { pending, count, names } does this Mac have legacy projects this account hasn't answered about?
96
+ migrationDecide: 'migration:decide', // { choice: 'bring'|'leave' } -> { ok, moved?, count?, manifestPath?, error? } bring moves each dir (manifest-first, verified, never overwrites/deletes); leave records the dismissal
97
+ // Managed database (Neon) viewer. Every channel takes { projectId, creationId }
98
+ // where creationId is the APP creation that owns the database (the viewer
99
+ // creation's meta.databaseFor). All handlers return { error } on failure.
100
+ dbOverview: 'db:overview', // -> { record, tables, usage, userCount }
101
+ dbRows: 'db:rows', // + { schema, table, limit, offset, orderBy?, orderDir? } -> { columns, rows, total }
102
+ dbMutate: 'db:mutate', // + { action: 'insert'|'update'|'delete', schema, table, values?, where? } -> { ok, rowCount }
103
+ dbQuery: 'db:query', // + { sql } -> { columns, rows, rowCount, truncated, command, durationMs }
104
+ dbUsers: 'db:users', // + { limit?, offset? } -> { enabled, users, total }
105
+ dbUserDelete: 'db:user-delete', // + { userId } -> { ok }
106
+ dbExport: 'db:export', // -> { ok, file, tables, totalRows, bytes } (save dialog, full SQL dump)
107
+ dbConnection: 'db:connection', // -> { pooled, direct, authBaseUrl } (deliberate reveal)
108
+ dbTransfer: 'db:transfer', // -> { ok, url?, expiresAt?, reason? } claim link to the founder's own Neon account
109
+ dbDelete: 'db:delete', // -> { ok } deletes the Neon project (recoverable 7 days server-side) + local record
110
+ // Feedback-pipeline review records (the Agents Tray's founder review surface).
111
+ // Reviews live in per-creation `reviews` records collections; list AGGREGATES
112
+ // every one in the project (the ledger creation differs per project).
113
+ reviewsList: 'reviews:list', // { projectId } -> { reviews: [{ creationId, id, key, ts, data }] } | { error }
114
+ reviewUpdate: 'reviews:update', // { projectId, creationId, key, status: 'pending'|'validated'|'flagged', flagNote? } -> { ok, review } | { error } upserts the SAME record; flag requires a note
115
+ // The dashboard: the company's real state, folded from the append-only
116
+ // ledger at `<projectDir>/dashboard.jsonl` (main/store/dashboard.mjs) with
117
+ // PostHog-sourced metrics resolved against the founder's own analytics.
118
+ dashboardGet: 'dashboard:get', // { projectId } -> { dashboard } | { error } folded picture, metrics resolved; never throws on an analytics failure (the metric stays honestly absent)
119
+ // Asked when the founder clicks a number, never on page load. `sources: null`
120
+ // means this product is not on managed analytics, which is the renderer's
121
+ // signal that the number cannot be clicked into at all.
122
+ dashboardSources: 'dashboard:sources', // { projectId, event, days } -> { sources: { event, days, rows: [{ source, campaign, people, hits }], absent } | null } | { error }
123
+ dashboardPatch: 'dashboard:patch', // { projectId, patch, source: 'founder'|'agent' } -> { ok, dashboard } | { error } append one partial patch; a founder patch is not overruled by a later agent patch to the same field
124
+ // main -> renderer (single event channel, payload = StreamEvent)
125
+ //
126
+ // The channel's own name, not the other product's. Both ends read it from
127
+ // this constant and nothing else in the repo spells it out, so the string was
128
+ // free to change; it was verified unreferenced before it did.
129
+ stream: `${nameSlug}:stream`,
130
+ };
131
+
132
+ // StreamEvent.type values (main -> renderer). One channel, discriminated union.
133
+ export const STREAM = {
134
+ appNotify: 'app.notify', // { title, body } cloud brain ping -> the web client shows a browser Notification when permitted (desktop uses native notifications main-side and never emits this)
135
+ chatDelta: 'chat.delta', // { messageId, text } incremental agent text
136
+ chatMessage: 'chat.message', // ChatMessage completed (agent or user echo)
137
+ runStatus: 'run.status', // { label, state: 'working'|'idle'|'error'|'needs-cleanup', detail?, stalled? } stalled: the turn is alive but the transport is failing and retrying (the founder must never read a retry loop as progress)
138
+ creationOpened: 'creation.opened', // Creation envelope
139
+ creationUpdated: 'creation.updated', // Creation envelope (same id, new content)
140
+ creationDeleted: 'creation.deleted', // { creationId } (founder deleted it; drop from catalog + any open tab)
141
+ creationRuntime: 'creation.runtime', // { projectId, creationId, status: 'idle'|'starting'|'running'|'stopping'|'error', port?, previewUrl?, logTail? } dev-server runtime status for a folder-backed app creation (project-wide, not chat-scoped); previewUrl only in cloud, where the port rides a public subdomain
142
+ askRequest: 'ask.request', // AskRequest
143
+ askResolved: 'ask.resolved', // { requestId }
144
+ runFrame: 'run.frame', // { runId, seq, file } screenshot FILE REF, never blobs
145
+ runFrameLive: 'run.frame.live', // { runId, seq, src, w, h } screencast frame DESCRIPTOR; src is a
146
+ // a live:// URL into main's in-memory cache (display-only,
147
+ // latest wins, never evidence). run.frame stays the evidence still.
148
+ runCursor: 'run.cursor', // { runId, seq, x, y, click } where the persona's hands are, in the
149
+ // frame's CSS pixels; the live view glides a pointer there
150
+ runEvent: 'run.event', // RunEvent (for live narration / transcript)
151
+ sessionError: 'session.error', // { message, kind: 'rate-limit'|'crash'|'auth'|'other' }
152
+ receipts: 'chat.receipts', // { messageId, receipts: string[] }
153
+ chatRenamed: 'chat.renamed', // { chatId, title } (auto-title from the first message)
154
+ projectRenamed: 'project.renamed', // { projectId, name } the agent named an idea-stage project (rename_project); the sidebar/top bar update live
155
+ chatCreated: 'chat.created', // { chat: { id, title, updatedAt } } the agent made a new chat; the sidebar adds it live. NOT chat-scoped, so it folds whatever chat is active.
156
+ chatMessageRemoved: 'chat.message-removed', // { id } drop a message from the log/dock (an up-next message the founder cancelled or pulled back)
157
+ chatModel: 'chat.model', // { model } the agent switched THIS conversation's model; the chip reflects it live
158
+ chatWorkers: 'chat.workers', // { op: 'start'|'end', worker: { id, label, model } } a delegated worker session (e.g. a build session) started or finished on this conversation; the model picker's "Working now" roster reflects who is actually running, on which model
159
+ compaction: 'chat.compaction', // { state: 'running'|'done'|'failed' } dedicated, persistent compaction indicator (distinct from run.status so agent work never clobbers it)
160
+ browserOpen: 'browser-open', // { url } a renderer link escape (http/https) main routes into the in-app browser: open, or focus a tab already at that url. GLOBAL, not chat-scoped, so it folds regardless of the active project/chat.
161
+ chatStatus: 'chat.status', // { projectId, chatId, status: { working, workingSince, askPending, askSince, doneAt, doneDetail, readAt, cutAt, cutKind } } per-chat sidebar status ledger (main/status-ledger.mjs). cutAt (epoch ms) + cutKind ('sleep'|'restart') carry the INTERRUPTED state: cutAt set with no new turn since == Your turn, unread, like a pending question. DELIBERATELY NOT in CHAT_SCOPED_EVENTS: the sidebar must reflect BACKGROUND chats too, so it folds into a per-chat map regardless of the active chat (the project gate in foldStream still applies). Persisted in main (with a rolling backup); the project:open snapshot re-seeds the renderer map on startup.
162
+ recoveryWake: 'recovery.wake', // { projectId, wokeAt, chats: [{ id, title, cutAt, cutKind, ranForMs, lastFinished, askPending }] } ONE signal on system resume (powerMonitor) carrying the OPEN project's interrupted chats, so the renderer can raise the Welcome Back sheet. ranForMs = cutAt - workingSince (null if unknown); lastFinished = the last tool trail line before the cut (null if none); askPending marks a row cut mid-question (offer Open, not Resume). GLOBAL, not chat-scoped. Ordinary chat.status events also flow for each entry, so the sidebar is correct even if the sheet never shows.
163
+ authChanged: 'auth.changed', // { signedIn, email? } main finished a sign-in (Google deep link) or sign-out; the gate lifts without polling. GLOBAL, not chat-scoped.
164
+ billingChanged: 'billing.changed', // { summary } main refreshed the billing summary after a finished checkout; the Plan and usage room updates without a manual reload. GLOBAL, not chat-scoped. Nothing emits or listens for this today.
165
+ dashboardUpdated: 'dashboard.updated', // { projectId } an agent (in ANY conversation) appended to the dashboard ledger; the page refetches. Carries no payload on purpose: the fold plus the analytics resolution happen in main, and several chats may write at once, so the renderer asks for the current truth rather than folding a patch it may have raced. NOT chat-scoped (the project gate in foldStream still applies).
166
+ };
167
+
168
+ // Stream events scoped to ONE conversation: the renderer folds these only when
169
+ // event.chatId matches the active chat. Everything else (creations, run frames)
170
+ // is project-wide.
171
+ export const CHAT_SCOPED_EVENTS = [
172
+ 'chat.delta', 'chat.message', 'chat.message-removed', 'chat.receipts', 'ask.request', 'ask.resolved',
173
+ 'run.status', 'session.error', 'chat.model', 'chat.workers', 'chat.compaction',
174
+ ];
175
+
176
+ /* ------------------------------ chat + asks ------------------------------ */
177
+ // ChatMessage: { id, role: 'user'|'agent', text, ts, receipts?: string[] }
178
+
179
+ export function makeAskRegistry() {
180
+ // Idempotent ask_user round-trips: one pending request at a time per session,
181
+ // stable requestId, stale/duplicate answers rejected, timeout expires cleanly.
182
+ const pending = new Map(); // requestId -> { resolve, timer }
183
+ return {
184
+ register(request, resolve, onTimeout) {
185
+ if (!request?.requestId) throw new Error('ask request needs requestId');
186
+ if (pending.has(request.requestId)) return false; // duplicate register = no-op
187
+ const timer = request.timeoutMs
188
+ ? setTimeout(() => { pending.delete(request.requestId); onTimeout?.(); }, request.timeoutMs)
189
+ : null;
190
+ pending.set(request.requestId, { resolve, timer });
191
+ return true;
192
+ },
193
+ answer(response) {
194
+ const entry = pending.get(response?.requestId);
195
+ if (!entry) return { ok: false, reason: 'stale-or-unknown' };
196
+ clearTimeout(entry.timer);
197
+ pending.delete(response.requestId);
198
+ entry.resolve(response);
199
+ return { ok: true };
200
+ },
201
+ cancelAll(reason = 'cancelled') {
202
+ for (const [id, entry] of pending) {
203
+ clearTimeout(entry.timer);
204
+ entry.resolve({ requestId: id, cancelled: true, reason });
205
+ }
206
+ pending.clear();
207
+ },
208
+ has(requestId) { return pending.has(requestId); },
209
+ get size() { return pending.size; },
210
+ };
211
+ }
212
+
213
+ /* -------------------------------- creations ------------------------------- */
214
+ export const CREATION_KINDS = [
215
+ 'markdown', // the workhorse: reports, ICP docs, plans (rendered as doc)
216
+ 'findings', // findings report (evidence-first order, trust spine)
217
+ 'live-run', // live browser view of a persona driving the product
218
+ 'persona', // persona profile
219
+ 'transcript', // interview / session transcript
220
+ 'design', // rendered HTML in a sandboxed iframe
221
+ 'canvas', // agent-authored HTML on an infinite pannable/zoomable canvas
222
+ // (camera + ground only; everything on the board is the HTML)
223
+ 'app', // folder-backed runnable app (dir + entry, has a local runtime;
224
+ // the ONE creation for a product: code, database views, and
225
+ // the DATABASE.md/DEPLOYMENT.md receipts all live here)
226
+ 'file', // founder-imported asset (video, image, audio, anything):
227
+ // bytes live at envelope.path, the viewer renders by
228
+ // extension via creation-file://, content is never read
229
+ ];
230
+
231
+ // The kinds the AGENT may mint via open_creation. The other two stay in
232
+ // CREATION_KINDS only because internal code paths build them: live-run by
233
+ // start_test_run, file by founder drag-drop.
234
+ export const AGENT_CREATION_KINDS = [
235
+ // NOTE: 'findings' was retired 2026-07-23. A test run's report is now a
236
+ // plain markdown doc (fewer primitives is the goal), so the agent never
237
+ // mints a bespoke report type. 'findings' stays in CREATION_KINDS only so
238
+ // legacy reports already on disk remain valid; nothing creates new ones and
239
+ // there is no special renderer (a legacy findings creation renders as
240
+ // markdown, converted on the fly).
241
+ 'markdown', 'persona', 'transcript', 'design', 'canvas', 'app',
242
+ ];
243
+
244
+ // The kinds whose content IS text and must reach disk as a string. A doc saved
245
+ // as an editor's JSON tree instead renders as nothing at all: the viewer tests
246
+ // `typeof content === 'string'`, an object fails it, and the fallback renderer
247
+ // has no branch for that shape, so the founder is shown an empty panel beside a
248
+ // chat that says the document is open (reported twice, on two different
249
+ // documents, 2026-07-30 and 2026-07-31).
250
+ export const TEXT_CREATION_KINDS = ['markdown', 'persona', 'transcript', 'design', 'canvas'];
251
+
252
+ const BLOCK_NODES = new Set([
253
+ 'doc', 'paragraph', 'heading', 'blockquote', 'listItem', 'bulletList',
254
+ 'orderedList', 'codeBlock', 'horizontalRule', 'tableRow',
255
+ ]);
256
+
257
+ function gatherText(node, out) {
258
+ if (!node || typeof node !== 'object') return;
259
+ if (Array.isArray(node)) { for (const n of node) gatherText(n, out); return; }
260
+ if (typeof node.text === 'string') { out.push(node.text); return; }
261
+ if (Array.isArray(node.content)) {
262
+ for (const n of node.content) gatherText(n, out);
263
+ if (BLOCK_NODES.has(node.type)) out.push('\n\n');
264
+ }
265
+ }
266
+
267
+ /**
268
+ * The text of a creation whose content should be a string, whatever shape it
269
+ * actually arrived in. Used at BOTH ends on purpose: the save path so a
270
+ * malformed write never lands, and the viewer so the documents already on disk
271
+ * are readable without anyone rewriting them.
272
+ *
273
+ * It never returns empty for content that exists. Falling back to the raw JSON
274
+ * is ugly, and it is the point: a founder can see what is there, copy it, and
275
+ * ask the agent to rewrite it. A blank panel tells them their work is gone.
276
+ */
277
+ export function creationText(content) {
278
+ if (typeof content === 'string') return content;
279
+ if (content == null) return '';
280
+ if (typeof content !== 'object') return String(content);
281
+ for (const key of ['markdown', 'body', 'text']) {
282
+ if (typeof content[key] === 'string') return content[key];
283
+ }
284
+ const parts = [];
285
+ gatherText(content, parts);
286
+ const text = parts.join('').replace(/\n{3,}/g, '\n\n').trim();
287
+ if (text) return text;
288
+ return ['```json', JSON.stringify(content, null, 2), '```'].join('\n');
289
+ }
290
+
291
+ // Creation envelope: { id, kind, title, ts, schemaVersion, meta?, and exactly
292
+ // one of: path (project-relative single file) or dir (project-relative
293
+ // folder, requires entry: the file to run/open within that folder) }
294
+ // Content lives on disk, not the envelope.
295
+ export function validateCreation(c) {
296
+ const errors = [];
297
+ if (!c?.id) errors.push('creation.id required');
298
+ if (!CREATION_KINDS.includes(c?.kind)) errors.push(`creation.kind must be one of ${CREATION_KINDS.join('|')}`);
299
+ if (!c?.title) errors.push('creation.title required');
300
+ const hasPath = typeof c?.path === 'string' && c.path.length > 0;
301
+ const hasDir = typeof c?.dir === 'string' && c.dir.length > 0;
302
+ if (hasPath === hasDir) errors.push('creation needs exactly one of path (single file) or dir (folder-backed)');
303
+ if (hasDir && !c?.entry) errors.push('folder-backed creation requires entry');
304
+ return errors;
305
+ }
306
+
307
+ /* -------------------------------- findings -------------------------------- */
308
+ export const PROVENANCE = ['EXECUTED', 'INFERRED'];
309
+ export const SEVERITIES = ['critical', 'major', 'minor', 'note'];
310
+
311
+ // Finding: { id, title, provenance, severity, category, trigger, expected,
312
+ // actual, quote?, quoteCite?, repro: string[],
313
+ // evidenceRefs: [{ type:'event', runId, seq } | { type:'screenshot', runId, file }] }
314
+ //
315
+ // The structural honesty rule (Codex round 2): EXECUTED requires evidence that
316
+ // RESOLVES. resolver = { eventSeqs(runId): Set<number>, screenshots(runId): Set<string> }
317
+ export function validateFinding(f, resolver) {
318
+ const errors = [];
319
+ if (!f?.id) errors.push('finding.id required');
320
+ if (!f?.title) errors.push('finding.title required');
321
+ if (!PROVENANCE.includes(f?.provenance)) errors.push('finding.provenance must be EXECUTED|INFERRED');
322
+ if (!SEVERITIES.includes(f?.severity)) errors.push('finding.severity invalid');
323
+ if (f?.provenance === 'EXECUTED') {
324
+ if (!Array.isArray(f.evidenceRefs) || f.evidenceRefs.length === 0) {
325
+ errors.push(`finding ${f.id}: EXECUTED requires non-empty evidenceRefs`);
326
+ } else if (resolver) {
327
+ for (const ref of f.evidenceRefs) {
328
+ if (ref.type === 'event') {
329
+ if (!resolver.eventSeqs?.(ref.runId)?.has(ref.seq)) {
330
+ errors.push(`finding ${f.id}: dangling event ref ${ref.runId}#${ref.seq}`);
331
+ }
332
+ } else if (ref.type === 'screenshot') {
333
+ if (!resolver.screenshots?.(ref.runId)?.has(ref.file)) {
334
+ errors.push(`finding ${f.id}: dangling screenshot ref ${ref.runId}/${ref.file}`);
335
+ }
336
+ } else {
337
+ errors.push(`finding ${f.id}: unknown evidenceRef type ${ref?.type}`);
338
+ }
339
+ }
340
+ }
341
+ if (!Array.isArray(f.repro) || f.repro.length === 0) {
342
+ errors.push(`finding ${f.id}: EXECUTED requires repro steps`);
343
+ }
344
+ }
345
+ return errors;
346
+ }
347
+
348
+ // Report: { schemaVersion, title, kicker, receipts: { sessions, minutes, findings },
349
+ // intro, findings: Finding[], coverage?, wtp? (QUARANTINED, never a headline),
350
+ // dissent?, theDo: { title, body } } Reports END in a DO.
351
+ export function validateReport(r, resolver) {
352
+ const errors = [];
353
+ if (!r?.title) errors.push('report.title required');
354
+ if (!r?.theDo?.title) errors.push('report must end in a DO (theDo.title required)');
355
+ if (!Array.isArray(r?.findings) || r.findings.length === 0) errors.push('report.findings required');
356
+ for (const f of r?.findings ?? []) errors.push(...validateFinding(f, resolver));
357
+ return errors;
358
+ }
359
+
360
+ /* ---------------------------------- runs ---------------------------------- */
361
+ export const BROWSER_MODES = ['playwright', 'patchright'];
362
+
363
+ // RunConfig: { runId, productDir, entryUrl, personaIds: string[],
364
+ // skillVersions: {}, browserMode, model, startedAt, schemaVersion }
365
+ export function validateRunConfig(c) {
366
+ const errors = [];
367
+ if (!c?.runId) errors.push('runConfig.runId required');
368
+ if (!c?.entryUrl) errors.push('runConfig.entryUrl required');
369
+ if (!BROWSER_MODES.includes(c?.browserMode)) errors.push('runConfig.browserMode invalid');
370
+ if (!Array.isArray(c?.personaIds) || c.personaIds.length === 0) errors.push('runConfig.personaIds required');
371
+ return errors;
372
+ }
373
+
374
+ // RunEvent (events.jsonl lines): { seq, ts, type, ...payload }
375
+ export const RUN_EVENT_TYPES = [
376
+ 'action', // { tool, args, durationMs, result? } a browser/tool action
377
+ 'screenshot', // { file } file ref relative to the run dir
378
+ 'heartbeat', // { state } liveness for the quiet chip
379
+ 'hygiene', // HygieneEntry created/updated
380
+ 'tool', // { name, durationMs } non-browser tool timing
381
+ 'note', // { text } agent narration
382
+ 'usage', // { model, inputTokens?, outputTokens?, costUsd? } best effort,
383
+ // partial under subscription auth; NEVER a dogfood gate
384
+ ];
385
+
386
+ // HygieneEntry: { id, kind: 'account'|'row'|'webhook'|'email'|'other', identifier,
387
+ // createdAt, cleanup: 'auto'|'manual', status: 'pending'|'cleaned'|'dismissed',
388
+ // reason? } A run with any status:'pending' entry is 'needs-cleanup', which is
389
+ // a COMPLETION GATE: the UI keeps the run open until each entry is cleaned or
390
+ // dismissed with a persisted reason.
391
+ export function runNeedsCleanup(hygieneEntries) {
392
+ return (hygieneEntries ?? []).some((h) => h.status === 'pending');
393
+ }
394
+
395
+ // Payment rail rule (concrete, v1): never enter card/payment fields; any payment
396
+ // URL/form pauses the run and asks the user. Exported so the browser driver and
397
+ // the user-test skill reference ONE rule.
398
+ export const PAYMENT_RULE =
399
+ 'NEVER enter card or payment details. On any payment page, checkout form, or ' +
400
+ 'card field: stop, record a hygiene note, and ask the user how to proceed.';
401
+
402
+ /* ------------------------- diff-rerun noise honesty ------------------------ */
403
+ // v1 makes NO numeric delta claims by default. Count-level comparisons are
404
+ // UNCALIBRATED unless >= 2 prior same-config baseline runs exist.
405
+ export function noiseBand(baselineFindingCounts) {
406
+ if (!Array.isArray(baselineFindingCounts) || baselineFindingCounts.length < 2) {
407
+ return { calibrated: false, label: 'UNCALIBRATED' };
408
+ }
409
+ const min = Math.min(...baselineFindingCounts);
410
+ const max = Math.max(...baselineFindingCounts);
411
+ return { calibrated: true, min, max, label: `${min}-${max} across ${baselineFindingCounts.length} baseline runs` };
412
+ }
@@ -0,0 +1,182 @@
1
+ // A crash report is scrubbed BEFORE it leaves, never after (the founder,
2
+ // 2026-08-19, her yes on). The order matters and it is the whole design: a
3
+ // reporter that ships a raw error and filters at the far end has already put a
4
+ // stranger's home directory, username and client project name on someone else's
5
+ // server, and no retention policy takes that back.
6
+ //
7
+ // What a real error carries, measured 2026-08-19 (reports/analytics-evaluation.html
8
+ // in the Agentbox store): a plain ENOENT puts the user's home dir, their username
9
+ // and their project folder name in THREE places at once — `err.message`,
10
+ // `err.path`, and the first line of `err.stack`. the app's own code adds a fourth:
11
+ // of 46 `throw new Error` sites, several interpolate a user string straight into
12
+ // the message (`no such product: ${slug}`, where the slug is the name of the
13
+ // project someone is working on).
14
+ //
15
+ // So this file is the only place that decides what a report may contain, and it
16
+ // works by ALLOWLIST: a payload is built field by field out of things we know
17
+ // are safe, never by copying an error and deleting the bad parts. Deleting is
18
+ // how the fourth place gets missed.
19
+ //
20
+ // The four rules, in the founder's terms:
21
+ // 1. The home directory becomes `~`, everywhere, in every string.
22
+ // 2. Below a folder root the names go and the DEPTH stays. `~/<home+4>` says
23
+ // "four levels under home" and names none of them.
24
+ // 3. `err.path`, `err.dest` and the rest of the syscall's arguments are gone
25
+ // outright. The syscall NAME (`open`) and the code (`ENOENT`) survive,
26
+ // because they debug and they carry nothing.
27
+ // 4. A stack keeps only the frames inside the app bundle. Everything else is
28
+ // counted, not sent.
29
+ // And one line under all of them: NO TASK TEXT, EVER. In Agentbox a task's title
30
+ // and body ARE the user's prompt, so any payload carrying one turns the page's
31
+ // promise about prompts into a lie rather than an imprecision.
32
+
33
+ import path from 'node:path';
34
+
35
+ // Anything that looks like a filesystem path: a `/` or `~/` start, then
36
+ // everything up to whitespace or a closing quote/bracket. Deliberately greedy
37
+ // about what counts as a path, because a missed one is a leak and a false
38
+ // positive costs a word of debug detail.
39
+ const PATH_RE = /(?:file:\/\/)?(?:~|\/)[^\s'"`)\]},;]*/g;
40
+
41
+ // A redacted token always carries `<`, which is how later passes tell "already
42
+ // handled" from "still raw" without re-parsing.
43
+ const MARK = '<';
44
+
45
+ function depthOf(rest) {
46
+ return rest.split('/').filter(Boolean).length;
47
+ }
48
+
49
+ // A path inside our own bundle, which `scrubPath` has already made relative to
50
+ // the bundle root and so cannot contain anything of theirs. It is the same
51
+ // string in every report from every Mac, which is exactly why it may be set
52
+ // aside before we go looking for the person: it carries no information about
53
+ // them. EXPORTED because the check below and the tests both have to set the
54
+ // same thing aside, and two definitions of "ours" is how they drift apart.
55
+ export function withoutOurOwnPaths(text) {
56
+ return String(text).replace(/app:[^\s'"`)\]},;]*/g, ' ');
57
+ }
58
+
59
+ // One path token in, one safe token out.
60
+ export function scrubPath(raw, ctx = {}) {
61
+ const home = ctx.home ? path.resolve(ctx.home) : null;
62
+ const appDir = ctx.appDir ? path.resolve(ctx.appDir) : null;
63
+ let p = raw.startsWith('file://') ? raw.slice('file://'.length) : raw;
64
+ // A trailing `:12:5` from a stack frame is line and column, not path.
65
+ let tail = '';
66
+ const lineCol = p.match(/(:\d+(?::\d+)?)$/);
67
+ if (lineCol) { tail = lineCol[1]; p = p.slice(0, -tail.length); }
68
+ if (home && (p === '~' || p.startsWith('~/'))) p = path.join(home, p.slice(1));
69
+
70
+ // Inside the app bundle is OUR code, which is the one thing a crash report
71
+ // exists to tell us. It is also the only path with no user in it.
72
+ if (appDir && (p === appDir || p.startsWith(appDir + path.sep))) {
73
+ const rel = path.relative(appDir, p).split(path.sep).join('/');
74
+ return `app:${rel || '.'}${tail}`;
75
+ }
76
+ if (home && (p === home || p.startsWith(home + path.sep))) {
77
+ const d = depthOf(p.slice(home.length));
78
+ return d ? `<~+${d}>${tail}` : '<~>';
79
+ }
80
+ if (p.startsWith('/')) return `<abs+${depthOf(p)}>${tail}`;
81
+ return `<path>${tail}`;
82
+ }
83
+
84
+ // Every string that leaves goes through this, including ones we believe are
85
+ // safe. Belief is what the ENOENT measurement disproved.
86
+ export function scrubText(text, ctx = {}) {
87
+ if (typeof text !== 'string' || !text) return text;
88
+ // The trailing value goes FIRST, before the path pass: the shape the audit
89
+ // found in our own throw sites is a RELATIVE path (`no such file: ${rel}`),
90
+ // and the path pass would keep its first segment (`clients<abs+2>`) because a
91
+ // relative path has no leading slash to match on.
92
+ let out = text.replace(/(: )([^\s:'"]{1,200})$/, (m, sep, token) => (token.includes(MARK) ? m : `${sep}<value>`));
93
+ out = out.replace(PATH_RE, (m) => scrubPath(m, ctx));
94
+ // A username reaches a message on its own too (`os.userInfo.username`),
95
+ // with no path around it to catch it.
96
+ const user = ctx.username || (ctx.home ? path.basename(ctx.home) : null);
97
+ if (user && user.length > 2) out = out.split(user).join('<user>');
98
+ // Quoted runs are where interpolated user strings hide when they are not
99
+ // paths. Anything already redacted keeps its shape; the rest goes.
100
+ out = out.replace(/'([^']{1,400})'|"([^"]{1,400})"/g, (m, a, b) => {
101
+ const inner = a ?? b ?? '';
102
+ return inner.includes(MARK) ? m : "'<str>'";
103
+ });
104
+ return out.length > 300 ? out.slice(0, 300) + '…' : out;
105
+ }
106
+
107
+ const FRAME_RE = /^\s*at\s+(?:(.+?)\s+\()?([^()]+?)(?::(\d+):(\d+))?\)?\s*$/;
108
+
109
+ // Rule 4. Frames inside the bundle are kept whole (function name included:
110
+ // it is our identifier, not theirs); everything else is counted and dropped.
111
+ export function scrubStack(stack, ctx = {}) {
112
+ const frames = [];
113
+ let dropped = 0;
114
+ for (const line of String(stack || '').split('\n')) {
115
+ const m = line.match(FRAME_RE);
116
+ if (!m) continue;
117
+ const [, fn, file, ln, col] = m;
118
+ const safe = scrubPath(file.trim(), ctx);
119
+ if (safe.startsWith('app:')) {
120
+ frames.push({ fn: fn ? fn.trim().slice(0, 80) : null, at: safe, line: ln ? Number(ln) : null, col: col ? Number(col) : null });
121
+ } else {
122
+ dropped += 1;
123
+ }
124
+ }
125
+ return { frames: frames.slice(0, 20), dropped };
126
+ }
127
+
128
+ // Rule 3, as an allowlist rather than a delete-list. `err.path`, `err.dest`,
129
+ // `err.address`, `err.port`, `err.hostname`, `err.config`, `err.request` and
130
+ // whatever the next library invents are absent because nothing copies them,
131
+ // not because someone remembered them.
132
+ export function scrubError(err, ctx = {}) {
133
+ const e = err && typeof err === 'object' ? err : { message: String(err) };
134
+ const { frames, dropped } = scrubStack(e.stack, ctx);
135
+ return {
136
+ name: typeof e.name === 'string' ? e.name.slice(0, 60) : 'Error',
137
+ // A code is a constant (`ENOENT`, `EPERM`, `ERR_MODULE_NOT_FOUND`) and a
138
+ // syscall is a verb (`open`). Neither has ever held a user's word.
139
+ code: typeof e.code === 'string' || typeof e.code === 'number' ? String(e.code).slice(0, 40) : null,
140
+ syscall: typeof e.syscall === 'string' ? e.syscall.slice(0, 40) : null,
141
+ message: scrubText(typeof e.message === 'string' ? e.message : String(e), ctx),
142
+ site: frames[0] ? `${frames[0].at}:${frames[0].line ?? 0}` : null,
143
+ frames,
144
+ framesDropped: dropped,
145
+ };
146
+ }
147
+
148
+ // The last gate, and the reason it exists: every rule above is a claim, and a
149
+ // claim about a payload is worth what a test of the payload says it is worth.
150
+ // This walks the finished report and answers "is there anything private left in
151
+ // here", and the writer refuses to keep a report that fails. Scrub before it
152
+ // leaves means there is no second chance further down.
153
+ export function findPrivate(value, ctx = {}) {
154
+ const home = ctx.home ? path.resolve(ctx.home) : null;
155
+ const user = ctx.username || (home ? path.basename(home) : null);
156
+ const hits = [];
157
+ const walk = (v, at) => {
158
+ if (typeof v === 'string') {
159
+ // A path inside our own bundle is ours and is the point of the report, so
160
+ // it comes out of the string before ANY of the three scans rather than
161
+ // being special-cased inside them. It used to come out before the path
162
+ // scan only, and that cost us every report on a whole class of Mac: a
163
+ // username that happens to be a substring of one of our own file paths
164
+ // reads as a leak, the report is thrown away, and `kind` becomes
165
+ // `redacted`. Measured on a GitHub macOS runner, 2026-08-25, where the
166
+ // account is called `runner` and the bundle ships
167
+ // `node_modules/@vitest/runner`. `dev`, `test`, `app` and `node` are the
168
+ // same shape and are ordinary names for a Mac account. `scrubPath` has
169
+ // already made these relative to the bundle, so nothing of theirs can be
170
+ // in one.
171
+ const rest = withoutOurOwnPaths(v);
172
+ if (home && rest.includes(home)) hits.push(`${at}: home directory`);
173
+ if (user && user.length > 2 && rest.includes(user)) hits.push(`${at}: username`);
174
+ for (const m of rest.match(PATH_RE) || []) {
175
+ if (m.trim().split('/').filter(Boolean).length >= 2) hits.push(`${at}: path ${m.trim()}`);
176
+ }
177
+ } else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${at}[${i}]`));
178
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, at ? `${at}.${k}` : k);
179
+ };
180
+ walk(value, '');
181
+ return hits;
182
+ }