agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -1,93 +1,44 @@
1
- // The app side of the approvals channel: read what workers are waiting on,
2
- // write the founder's decision. Pure file protocol against the spool dir the
3
- // approval-prompt server polls, so a restart on either side loses nothing.
1
+ // The app side of the approvals channel: read what workers are waiting on and
2
+ // write the founder's decision, as files in the spool dir the approval-prompt
3
+ // server polls, so a restart on either side loses nothing.
4
4
  //
5
- // ------------------------------------------------------------------------
6
- // THE SPOOL IS A DIRECTORY THE ADVERSARY CAN WRITE IN, AND THAT IS THE WHOLE
7
- // REASON THE FILE OPERATIONS BELOW ARE NOT THE OBVIOUS ONES (2026-09-04).
5
+ // A worker can write in this spool. The store root is in its
6
+ // `additionalDirectories` and Bash is allowed broadly; what an agent may do is
7
+ // the user's call (`worker-permissions.json`), so this file treats that as the
8
+ // threat model rather than narrowing it. In Node, writeFileSync and
9
+ // appendFileSync write through a symlink to its target, and readFileSync on a
10
+ // fifo blocks forever. So nothing here is opened through a link, nothing is
11
+ // opened that is not a regular file, and every write is created at an
12
+ // unguessable name and renamed onto the real one, which replaces a link
13
+ // without following it. These helpers are the one place that rule lives.
8
14
  //
9
- // It lives at `<storeRoot>/.approvals`. `writeWorkerSettings` puts the store
10
- // root in `additionalDirectories` and `worker-permissions.json` allows Bash
11
- // broadly, so a Claude Code worker can list this directory and create files in
12
- // it. That is not a misconfiguration to be narrowed here -- what an agent may
13
- // do on her machine is section 7 of the terms and hers to decide -- it is the
14
- // threat model this file is written against. Measured on this Mac, node
15
- // v22.19.0:
16
- //
17
- // fs.writeFileSync(<symlink>, x) -> writes THROUGH it, to the target
18
- // fs.appendFileSync(<symlink>, x) -> appends THROUGH it
19
- // fs.readFileSync(<fifo>) -> blocks the process, forever
20
- // fs.openSync(<symlink>, 'wx') -> EEXIST
21
- // open(..., O_APPEND|O_CREAT|O_NOFOLLOW) -> ELOOP
22
- // open(..., O_RDONLY|O_NONBLOCK) + fstat -> returns, and says isFIFO
23
- // fs.renameSync(tmp, <symlink>) -> replaces the LINK; target intact
24
- // fs.rmSync(<symlink>, {recursive:true}) -> removes the LINK; target intact
25
- //
26
- // The first three lines are what the top three used to be. So: nothing in this
27
- // directory is ever opened through a link, nothing is opened that is not a
28
- // regular file, and every write lands by creating at a name nothing can guess
29
- // and renaming onto the real one. The helpers are here rather than in each
30
- // caller because two copies of that rule are one edit away from disagreeing,
31
- // and the half that forgets is the half with the hole.
32
- //
33
- // AND NOTHING IN A REQUEST FILE IS BELIEVED ABOUT TIME. `at` is a worker's own
34
- // account of itself; the order her cards stack in is Agentbox's, see
35
- // `listPending`.
36
- //
37
- // WHAT THIS STILL DOES NOT CLAIM. A worker that can write here can still put A
38
- // card in front of her: a file is the whole of the Claude Code protocol
39
- // (main/approval-prompt-server.mjs waits in another process and has no other
40
- // channel), so a request's provenance cannot be proved. What it can no longer
41
- // do is aim her click at a file of hers, append to one, put its card in front
42
- // of every card that came before it, or leave that card there.
15
+ // Nothing in a request file is believed about time; card order is ours, see
16
+ // `listPending`. A worker can still put a card in front of her, because a file
17
+ // is the whole Claude Code protocol and provenance cannot be proved. It cannot
18
+ // aim her click at another file, append to one, or jump or hold the queue.
43
19
 
44
20
  import fs from 'node:fs';
45
21
  import path from 'node:path';
46
22
  import crypto from 'node:crypto';
23
+ import { cardDigest } from '../shared/approval-card.mjs';
47
24
 
48
25
  export function spoolDir(storeRoot) {
49
26
  return path.join(storeRoot, '.approvals');
50
27
  }
51
28
 
52
- /* ------------------- whose answer this is, provably --------------------- */
53
- //
54
- // THE FILE STOPS BEING A CHANNEL FOR APPROVING ANYTHING (2026-09-05).
55
- //
56
- // main/approval-prompt-server.mjs read `if (answer?.allow) return { behavior:
57
- // 'allow' }` off this directory, and the paragraph at the top of this file is
58
- // the whole reason that was an approval anybody could write: the store root is
59
- // in `additionalDirectories` and Bash is allowed broadly, so a worker could
60
- // approve another worker's card, or start a watcher and approve its own the
61
- // instant it raised one.
62
- //
63
- // The Codex half was closed by moving the decision in process. This half cannot
64
- // be: the thing waiting is a SEPARATE PROCESS, spawned by the CLI as the
65
- // worker's own child, and a file is the whole of the protocol it has.
66
- //
67
- // AND NO SHARED SECRET CLOSES IT EITHER, which is worth writing down so nobody
68
- // re-derives it: anything handed to that server is handed through the worker's
69
- // own environment, and the worker can read it. A token is a token the forger
70
- // has.
29
+ // An answer file a worker writes must not count as her approval. The Claude
30
+ // Code side waits in a separate process, the worker's own child, so a file is
31
+ // all it can read. A shared secret does not help: anything handed to that
32
+ // server passes through the worker's environment, which the worker can read.
33
+ // So Agentbox signs each decision with an ed25519 private key held only in its
34
+ // own process, and the server gets the public half, which verifies and cannot
35
+ // sign. The signature covers id, verdict, note, time and card together, so a
36
+ // decision cannot be moved, flipped or replayed.
71
37
  //
72
- // SO THE SECRET IS ONE THAT ONLY HAS TO BE CHECKED THERE, NEVER HELD THERE.
73
- // Agentbox keeps an ed25519 private key in its own process and hands the worker's
74
- // MCP server the PUBLIC half, which verifies and cannot sign. The signature
75
- // covers the request id, the verdict, the note and the moment TOGETHER, so a
76
- // decision cannot be moved onto another card, flipped from a deny into an
77
- // allow, or replayed onto a later one.
78
- //
79
- // THE PAIR IS PER RUN OF AGENTBOX, deliberately: a key file in her home is a key
80
- // a worker can read, and there is nothing to persist for -- every card is
81
- // answered by the process that raised it, and restarting Agentbox kills every
82
- // worker anyway (CLAUDE.md, process etiquette). Made on first use rather than
83
- // at import, so a machine that never raises a card never pays for it.
84
- //
85
- // WHAT IS STILL NOT CLOSED, and it is not closeable from this repository. That
86
- // MCP server is the worker's own child at the same uid. It cannot read Agentbox's
87
- // memory -- macOS wants root or an entitlement for that -- so no signature can
88
- // be made. What a same-uid process CAN do is kill it, or attach to it and patch
89
- // the check out. Stopping that means the worker being allowed less, which is
90
- // `worker-permissions.json`, which is section 7 of the terms and hers.
38
+ // The pair is per run and never written to disk, because a key file in her
39
+ // home is one a worker can read, and restarting Agentbox kills every worker
40
+ // anyway. Not closed here: a same-uid worker can kill that server or patch its
41
+ // check. macOS keeps it from reading Agentbox's memory, so it cannot sign.
91
42
 
92
43
  let pair = null;
93
44
  const keys = () => (pair ??= crypto.generateKeyPairSync('ed25519'));
@@ -97,95 +48,32 @@ export function approvalPublicKey() {
97
48
  return keys().publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
98
49
  }
99
50
 
100
- /* ------------------ WHAT the answer is an answer FOR --------------------- */
101
- //
102
- // A SIGNATURE OVER AN ID PROVES NOTHING ABOUT WHAT SHE AGREED TO (2026-09-05).
103
- //
104
- // `decisionBytes` covered `[id, allow, note, at]`, and the request file is one
105
- // the adversary can write in: a file's NAME and the id inside it survive while
106
- // its CONTENTS are replaced. So a worker raised `rm -rf ~/Zero`, rewrote its own
107
- // request in place with `echo hi`, `listPending` drew `echo hi`, she pressed
108
- // Allow -- and the process waiting on that id was STILL HOLDING the first
109
- // command and verified her signature happily. The signature was real and it
110
- // said only "yes to <uuid>". Codex has the same shape from the other end:
111
- // `settle` takes an id and a verdict while `open` holds the original, and a
112
- // Claude Code worker can write in the spool both engines draw their cards from.
51
+ // A signature over an id alone says nothing about what she agreed to, because
52
+ // a worker can rewrite its request file in place while the waiting process
53
+ // still holds the original. So `answer` signs a digest of the card Agentbox
54
+ // last showed her, and each engine compares it with the request it holds
55
+ // (approval-prompt-server.mjs its `request`, codex-approvals.mjs its `open`
56
+ // entry). A mismatch is a deny.
113
57
  //
114
- // SO THE DECISION IS BOUND TO THE ACTION. `answer` signs a digest of the card
115
- // Agentbox last put in front of her, alongside the verdict, and each engine
116
- // compares that digest against the request IT is holding before allowing
117
- // anything -- main/approval-prompt-server.mjs against its own in-memory
118
- // `request`, main/codex-approvals.mjs against its own `open` entry. A mismatch
119
- // is a deny, on both.
58
+ // The digest is of what was displayed, not of the file at answer time: a
59
+ // re-read lets a worker flip the file between a harmless body and the real one.
60
+ // `listPending` is the only reader, and every caller acts on or draws exactly
61
+ // what it returned, so the digest taken there is the card she saw.
120
62
  //
121
- // THE DIGEST IS OF WHAT WAS DISPLAYED, NOT OF THE FILE AT ANSWER TIME, and that
122
- // difference is the whole fix. Re-reading the request inside `answer` looks
123
- // equivalent: a worker flipping the file between a harmless body and the real
124
- // one then wins every time her press lands on a harmless draw and the re-read
125
- // catches the real body. `listPending` is the only thing in the app that reads
126
- // a request, and each of its three callers acts on or draws exactly what it
127
- // returned -- the snapshot (main/ipc.mjs), the ⌘Y/⌘N chord and the Agents menu
128
- // (main/main.mjs) -- so the digest taken there IS the card she is looking at.
129
- //
130
- // AND CANONICALISATION IS THE PART THAT FAILS QUIETLY. A digest that disagrees
131
- // with itself across the round trip through the file denies every approval on
132
- // the machine, with nothing on any screen saying why. Both sides hold JSON: the
133
- // engine's own object, and Agentbox's parse of the file that object was
134
- // stringified into. So the digest is taken over a canonical form after ONE JSON
135
- // round trip -- which resolves `toJSON`, drops `undefined` from objects and
136
- // turns it and NaN into null inside arrays, exactly as the write to disk did --
137
- // and then sorts keys, because two objects carrying the same fields in a
138
- // different order are the same card.
139
-
140
- /** One JSON value as one string, with the key order taken out of it. */
141
- function canonical(value) {
142
- if (Array.isArray(value)) return `[${value.map((v) => canonical(v) ?? 'null').join(',')}]`;
143
- if (value && typeof value === 'object') {
144
- const fields = [];
145
- for (const key of Object.keys(value).sort()) {
146
- const v = canonical(value[key]);
147
- if (v !== undefined) fields.push(`${JSON.stringify(key)}:${v}`);
148
- }
149
- return `{${fields.join(',')}}`;
150
- }
151
- return JSON.stringify(value);
152
- }
153
-
154
- /**
155
- * THE CARD, AS ONE STRING EITHER SIDE CAN ARRIVE AT INDEPENDENTLY.
156
- *
157
- * Null for anything that is not a request object and for anything JSON cannot
158
- * express (a cycle, a BigInt). Null is never equal to a digest, so every caller
159
- * reads it as a mismatch and denies, which is the safe direction in all three.
160
- *
161
- * NOTHING IS NORMALISED beyond what JSON itself did. Two byte strings that draw
162
- * the same glyph are two different commands, and Agentbox deciding otherwise
163
- * would be Agentbox deciding what her shell will do with them.
164
- */
165
- export function cardDigest(request) {
166
- if (!request || typeof request !== 'object' || Array.isArray(request)) return null;
167
- let text;
168
- try { text = canonical(JSON.parse(JSON.stringify(request))); } catch { return null; }
169
- if (typeof text !== 'string') return null;
170
- return crypto.createHash('sha256').update(text, 'utf8').digest('base64');
171
- }
63
+ // The digest itself is shared/approval-card.mjs, so the cloud server takes the
64
+ // same one of a card it puts on the phone.
65
+ export { cardDigest };
172
66
 
173
- /**
174
- * THE EXACT BYTES A SIGNATURE COVERS, and every field of the decision is in
175
- * them, including WHICH CARD it was a decision about. An array rather than an
176
- * object, because two JSON objects carrying the same fields in a different
177
- * order are different bytes, and the verifier rebuilds this from a PARSED file
178
- * rather than from the text on disk.
179
- */
67
+ // An array, not an object, because the verifier rebuilds these bytes from a
68
+ // parsed file and object key order would change them.
180
69
  function decisionBytes(id, allow, note, at, card) {
181
70
  return Buffer.from(JSON.stringify([String(id), !!allow, note ?? null, at ?? null, card ?? null]), 'utf8');
182
71
  }
183
72
 
184
73
  /**
185
- * Whether this answer is one Agentbox wrote for THIS request. False for every
186
- * shape that is not, including the one a worker with a shell writes, and
187
- * including one whose `card` has been edited away from the digest she really
188
- * signed -- so the binding cannot be stripped back off a real decision. */
74
+ * Whether Agentbox signed this answer for this request. False for anything a
75
+ * worker writes, including a real answer whose `card` was edited.
76
+ */
189
77
  export function answerIsHers(id, answer, publicKey = approvalPublicKey()) {
190
78
  if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false;
191
79
  if (typeof answer.sig !== 'string' || !answer.sig) return false;
@@ -198,60 +86,40 @@ export function answerIsHers(id, answer, publicKey = approvalPublicKey()) {
198
86
  }
199
87
 
200
88
  /**
201
- * HOW LONG A REQUEST FILE MAY OUTLIVE ANYONE WHO COULD ANSWER IT.
202
- *
203
- * Both engines give up on a card after fifteen minutes and delete the pair when
204
- * they do: main/approval-prompt-server.mjs's `TIMEOUT_MS`, and
205
- * main/codex-approvals.mjs's `timeoutMs`, which defaults to the transport's
206
- * `APPROVAL_TIMEOUT_MS`. A request still on disk past that is either a forgery
207
- * or the leftovers of a worker that died before its own deadline, and in both
208
- * cases it is a question nothing can answer.
209
- *
210
- * A minute over the longest of them, and never under: being early takes a live
211
- * card off her screen, being late leaves a dead one there one more minute.
212
- * tests/a-card-a-worker-forged-cannot-hold-the-front-of-her-stack.test.mjs
213
- * checks that against both of those constants rather than trusting this
214
- * comment.
89
+ * How long a request file may sit here with nobody waiting on it. A forgery and
90
+ * a dead worker's leftover leave this way, and they are the only two things
91
+ * that should: a card still being waited on is kept here by whoever is waiting,
92
+ * which is a beat of main/codex-approvals.mjs `tend` and of
93
+ * approval-prompt-core.mjs's own loop. Neither gives up on a clock any more
94
+ * (w-4b00022acb), so this is no longer a deadline shadowing theirs.
215
95
  */
216
96
  export const REQUEST_TTL_MS = 16 * 60_000;
217
97
 
218
98
  /**
219
- * The largest request file worth opening. main/codex-approvals.mjs already
220
- * cuts a card's text at CARD_CAP (4,000 characters), and a Claude Code tool
221
- * input that reached a megabyte could not be read on a card anyway -- so this
222
- * is only ever a bound on what a worker can make Agentbox hold in memory on
223
- * every poll of the inbox. Past it the file is skipped exactly the way an
224
- * unparseable one always has been, and the TTL above takes it away. */
99
+ * Caps what a worker can make Agentbox hold in memory on every poll. A larger
100
+ * file is skipped like an unparseable one, and the TTL removes it.
101
+ */
225
102
  export const MAX_REQUEST_BYTES = 1024 * 1024;
226
103
 
227
104
  const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0; // undefined on Windows, which this app is not
228
105
  const READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | O_NOFOLLOW;
229
106
  const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | O_NOFOLLOW;
230
107
 
231
- /**
232
- * Whether the spool holds an entry by this name, WHATEVER it is. `lstat`
233
- * rather than `existsSync`, which resolves a link and so answers "no" for a
234
- * dangling one that `readdir` can plainly see. */
108
+ // `lstat`, because `existsSync` follows a link and says no for a dangling one.
235
109
  export function spoolEntryExists(dir, name) {
236
110
  try { fs.lstatSync(path.join(dir, name)); return true; } catch { return false; }
237
111
  }
238
112
 
239
- /**
240
- * Take an entry out of the spool, whatever a worker made it. `rm` unlinks a
241
- * symlink rather than descending it, so this never reaches outside the spool,
242
- * and it removes the directory that `unlink` refuses (EPERM, measured). */
113
+ // `rm` unlinks a symlink rather than following it, so this never reaches
114
+ // outside the spool, and it removes a directory, which `unlink` refuses.
243
115
  export function removeSpoolEntry(dir, name) {
244
116
  try { fs.rmSync(path.join(dir, name), { recursive: true, force: true }); } catch { /* already gone */ }
245
117
  }
246
118
 
247
119
  /**
248
- * One spool file as the object it should hold, or null.
249
- *
250
- * O_NOFOLLOW so a request symlinked at one of her files is never opened,
251
- * O_NONBLOCK so a named pipe wearing a request file's name cannot freeze the
252
- * main process inside a synchronous read, and `fstat` on the descriptor we
253
- * actually got rather than an `lstat` on the name, which would leave a window
254
- * to swap underneath.
120
+ * One spool file as parsed JSON, or null. O_NOFOLLOW so a link to her file is
121
+ * never opened, O_NONBLOCK so a fifo cannot freeze the main process, and
122
+ * `fstat` on the open descriptor so the name cannot be swapped after a check.
255
123
  */
256
124
  export function readSpoolJson(dir, name) {
257
125
  let fd = null;
@@ -268,19 +136,11 @@ export function readSpoolJson(dir, name) {
268
136
  }
269
137
 
270
138
  /**
271
- * Put a file in the spool, without ever writing through what is already there.
272
- *
273
- * The exclusive create is at a name nothing can guess, so it cannot be
274
- * pre-created; the rename is atomic and replaces the destination's directory
275
- * entry without resolving it, so a planted link is destroyed rather than
276
- * followed AND her decision still lands on the first press. Plain `wx` on the
277
- * final name would refuse to follow but would also throw her click away
278
- * whenever anything squatted the name, and a lost Allow is the silence this
279
- * app is built not to produce.
280
- *
281
- * The second attempt is for the one squatter `rename` cannot replace: a
282
- * directory (EISDIR, measured), which is also what `unlink` refuses. Removing
283
- * it is safe for the reason `removeSpoolEntry` gives.
139
+ * Write a spool file without writing through whatever is already there. The
140
+ * exclusive create is at an unguessable name, and the rename replaces a planted
141
+ * link instead of following it. Plain `wx` on the final name would also refuse
142
+ * a link, but would lose her click whenever something squatted the name. The
143
+ * second attempt is for a directory, which `rename` cannot replace.
284
144
  */
285
145
  export function writeSpoolFile(dir, name, text) {
286
146
  const dest = path.join(dir, name);
@@ -307,21 +167,12 @@ export function writeSpoolFile(dir, name, text) {
307
167
  }
308
168
 
309
169
  /**
310
- * ONE AUDIT LINE, AND NEVER INTO A FILE OF HERS.
311
- *
312
- * `log.jsonl` is a FIXED name in a directory a worker can write in, so unlike
313
- * the answer file it needs no guessing at all, and the bytes appended carry the
314
- * worker's own `command` string. Symlinked at `briefs/founder.md` that is
315
- * agent-authored text inside the standing instructions every session is briefed
316
- * with, appended once per decision, forever.
317
- *
318
- * O_NOFOLLOW closes it with no window between a check and the write. But
319
- * refusing alone would hand the attacker the audit trail: both callers swallow
320
- * a failure here on purpose (a decision is never held hostage to its log line),
321
- * so a planted link would end auditing silently and for good. An entry that is
322
- * not a regular file is therefore destroyed and a real one opened in its place,
323
- * out loud -- this is the one warning in this area that always means a worker
324
- * attacked the spool.
170
+ * Append one audit line, never into a file of hers. `log.jsonl` is a fixed
171
+ * name and the line carries the worker's own command, so a link planted there
172
+ * would put agent text into her files. O_NOFOLLOW refuses the link. Refusing
173
+ * alone would end auditing silently, because callers ignore a failed log line,
174
+ * so anything that is not a regular file is removed, with a warning, and a
175
+ * real file opened in its place.
325
176
  */
326
177
  export function appendSpoolLine(dir, name, line) {
327
178
  if (appendOnce(dir, name, line)) return true;
@@ -347,25 +198,11 @@ function appendOnce(dir, name, line) {
347
198
  }
348
199
 
349
200
  /**
350
- * WHEN THIS PROCESS FIRST SAW EACH REQUEST, which is the only clock in that
351
- * directory a worker cannot write to.
352
- *
353
- * `listPending` used to sort by `a.at`, read straight out of the request file.
354
- * A worker writes `<uuid>.request.json` with `at: 0` and owns the front of the
355
- * stack -- and that is not one bad card, it is every card, because the renderer
356
- * draws only the front one and ⌘Y/⌘N and the Agents menu all act on
357
- * `listPending[0]`. Her keystroke answers the decoy while the question she is
358
- * reading waits behind it.
359
- *
360
- * So `at` is not read for ordering at all any more. `seq` is taken at the
361
- * sighting and only ever increases, which makes the order total as well as
362
- * unforgeable; the timestamp beside it is what the TTL is measured from. A
363
- * floor on the worker's own `at` was the other candidate and it is weaker: a
364
- * legitimate `at` is written moments before Agentbox sees the file, so it needs a
365
- * tolerance, and that tolerance is exactly the window a forgery is planted in.
366
- *
367
- * Keyed by directory as well as by id because the suite runs many spools, and
368
- * pruned against what is really on disk on every pass, so it cannot grow.
201
+ * When this process first saw each request, the only clock in the spool a
202
+ * worker cannot write. The request's own `at` is never used for order: a
203
+ * forged `at: 0` would take the front card, which ⌘Y/⌘N and the Agents menu
204
+ * act on. `seq` only increases; `at` here is what the TTL counts from. Keyed by
205
+ * directory and id, and pruned to what is on disk on every pass.
369
206
  */
370
207
  const seenAt = new Map();
371
208
  let sightings = 0;
@@ -373,28 +210,12 @@ let sightings = 0;
373
210
  const REQUEST_SUFFIX = '.request.json';
374
211
 
375
212
  /**
376
- * WHICH OF TWO REQUESTS FIRST SEEN IN THE SAME PASS CAME FIRST.
377
- *
378
- * A sighting is only as fine as the poll that made it, so cards that appear
379
- * inside one interval share a timestamp and something has to break the tie.
380
- * `readdir` is the wrong answer: measured on this Mac it comes back in NAME
381
- * order, and a forgery names itself, so a worker watching the spool could plant
382
- * a decoy the instant a real card appeared and sort itself in front of it.
383
- *
384
- * `ctime` is the right one, and it is the only timestamp here that is any use:
385
- * an unprivileged process can move a file's mtime and its BIRTHTIME backwards
386
- * -- measured, `utimes(epoch)` set both to 0 -- and the same call pushed ctime
387
- * FORWARD to now. A worker can therefore make its own request look younger than
388
- * it is, which costs it the front of the stack, and can never make it look
389
- * older. Nanoseconds because APFS keeps them and two files written back to back
390
- * differ by about 170 microseconds.
391
- *
392
- * A coarse clock stamps both files the same. tmpfs does this: two writes in
393
- * one poll share a ctime, and `readdir` then comes back in name order, which
394
- * is the forgery the paragraph above is about. The inode breaks that tie.
395
- * The kernel assigns it, a later create in a fresh directory gets a higher
396
- * one, and a worker cannot move it backwards. Equal inode and equal ctime
397
- * keep the order we already had.
213
+ * Orders requests first seen in the same poll. Not by `readdir`, which returns
214
+ * name order on macOS, and a forgery picks its own name. `ctime` is the one
215
+ * timestamp a worker cannot move backwards: `utimes` can set mtime and
216
+ * birthtime to 0 but pushes ctime to now. Nanoseconds because APFS keeps them.
217
+ * On a coarse clock (tmpfs) two files share a ctime, so the kernel-assigned
218
+ * inode breaks the tie.
398
219
  */
399
220
  function bornAt(dir, name) {
400
221
  try {
@@ -408,12 +229,10 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
408
229
  let files = [];
409
230
  try { files = fs.readdirSync(dir); } catch { return []; }
410
231
  const at = now();
411
- // NUL cannot occur in a path or a filename, so two spools never collide.
232
+ // NUL cannot occur in a path, so two spools never collide.
412
233
  const prefix = `${dir}\0`;
413
234
  const names = files.filter((f) => f.endsWith(REQUEST_SUFFIX));
414
- // Everything new in this pass takes its place in the queue before anything is
415
- // read, oldest first, so the order does not depend on what `readdir` felt
416
- // like returning.
235
+ // Queue everything new in this pass before reading any of it, oldest first.
417
236
  for (const { f } of names
418
237
  .filter((f) => !seenAt.has(prefix + f.slice(0, -REQUEST_SUFFIX.length)))
419
238
  .map((f) => ({ f, born: bornAt(dir, f) }))
@@ -427,23 +246,16 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
427
246
  const id = f.slice(0, -REQUEST_SUFFIX.length);
428
247
  const key = prefix + id;
429
248
  const seen = seenAt.get(key);
430
- // A REQUEST NOBODY IS WAITING ON ANY MORE. Nothing ever removed one whose
431
- // process was gone, which is what made a forged `at: 0` mean permanently.
432
249
  if (at - seen.at > REQUEST_TTL_MS) {
433
- console.warn(`zero: approval ${id} outlasted every deadline that could have answered it; it has been taken out of the spool`);
250
+ console.warn(`zero: approval ${id} sat in the spool with nothing waiting on it for ${Math.round(REQUEST_TTL_MS / 60_000)} minutes; it has been taken out`);
434
251
  removeSpoolEntry(dir, f);
435
252
  removeSpoolEntry(dir, `${id}.answer.json`);
436
253
  continue;
437
254
  }
438
255
  present.add(key);
439
- // A DECIDED CARD IS HIDDEN WHILE THE ENGINE REAPS IT -- BUT ONLY IF THE
440
- // DECISION IS HERS. This was a bare `files.includes(...)`, and that made
441
- // any answer file a way to take a card off her screen: a worker rewriting
442
- // a forgery once a second keeps ANY card, its own or another worker's,
443
- // hidden until the fifteen-minute deadline denies it in silence. So the
444
- // file is checked, and one that is not hers is removed rather than
445
- // believed -- the same sweep main/codex-approvals.mjs already runs, moved
446
- // to where both engines pass through it.
256
+ // A decided card is hidden while its engine reaps it, but only if the
257
+ // answer is hers. Otherwise any answer file could hide any card until it
258
+ // timed out. An answer she did not sign is removed.
447
259
  const answerName = `${id}.answer.json`;
448
260
  if (files.includes(answerName)) {
449
261
  if (answerIsHers(id, readSpoolJson(dir, answerName))) continue;
@@ -451,13 +263,9 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
451
263
  removeSpoolEntry(dir, answerName);
452
264
  }
453
265
  const request = readSpoolJson(dir, f);
454
- // AND WHAT WAS HANDED OUT IS REMEMBERED, because this is the only place a
455
- // request is ever read and every caller of this function acts on or draws
456
- // exactly what it returns. `answer` signs THIS, so her press authorises the
457
- // card that was on the screen rather than whatever is filed under the id by
458
- // the time somebody honours it. Kept from the last SUCCESSFUL read: a file
459
- // that has gone unreadable takes its card off her screen too, so leaving
460
- // the digest alone is leaving the last thing she was actually shown.
266
+ // `answer` signs this digest, so her press authorises the card she saw.
267
+ // Kept from the last successful read, because an unreadable file also
268
+ // leaves her screen, so the old digest is still the last card she saw.
461
269
  if (request) {
462
270
  seen.card = cardDigest(request);
463
271
  pending.push({ request, seq: seen.seq });
@@ -467,29 +275,25 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
467
275
  return pending.sort((a, b) => a.seq - b.seq).map((p) => p.request);
468
276
  }
469
277
 
470
- /**
471
- * The digest of the card last put in front of her for this request, or null
472
- * when nothing ever was. Kept beside the sighting, so it lives exactly as long
473
- * as the request file does. */
278
+ /** The digest of the card last shown for this request, or null if none was. */
474
279
  export function cardShown(storeRoot, id) {
475
280
  return seenAt.get(`${spoolDir(storeRoot)}\0${id}`)?.card ?? null;
476
281
  }
477
282
 
283
+ /** Whether `card` is the card last drawn for this request: a phone answers with the one it drew. */
284
+ export function cardStillShown(storeRoot, id, card) {
285
+ return typeof card === 'string' && !!card && cardShown(storeRoot, id) === card;
286
+ }
287
+
478
288
  export function answer(storeRoot, id, allow, note) {
479
289
  if (!/^[\w-]+$/.test(String(id ?? ''))) return false;
480
290
  const dir = spoolDir(storeRoot);
481
291
  if (!spoolEntryExists(dir, `${id}.request.json`)) return false;
482
- // A DECISION THAT NAMES NO ACTION IS NOT A DECISION. Every route that answers
483
- // a card reads `listPending` immediately before it -- the snapshot the card is
484
- // drawn from, the ⌘Y/⌘N chord, the Agents menu -- so in the app there is
485
- // always a card here. Reading the file instead would be signing something she
486
- // may never have seen, which is the hole this whole section is about.
292
+ // Every route that answers calls `listPending` first, so a card is always
293
+ // here. Reading the file instead would sign something she may not have seen.
487
294
  const card = cardShown(storeRoot, id);
488
295
  if (!card) return false;
489
296
  const at = Date.now();
490
- // SIGNED, so the process waiting on it can tell her decision from one a
491
- // worker wrote, and bound to the card so it cannot be honoured against a
492
- // different action. See `answerIsHers` above for why nothing weaker works.
493
297
  const sig = crypto.sign(null, decisionBytes(id, !!allow, note ?? null, at, card), keys().privateKey).toString('base64');
494
298
  return writeSpoolFile(dir, `${id}.answer.json`,
495
299
  JSON.stringify({ allow: !!allow, note: note ?? null, at, card, sig }));