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
@@ -144,9 +144,12 @@ export function createAnalytics({ config, version, dir, client, env = process.en
144
144
 
145
145
  return {
146
146
  get enabled() { return on(); },
147
+ // A whole phrase, verb and all: the line that prints it reads
148
+ // `diagnostics ${reason}`, and a reason with no verb of its own made that
149
+ // "diagnostics is has nowhere to send to".
147
150
  get reason() {
148
151
  if (!hasDestination) return 'has nowhere to send to';
149
- return diagnosticsOn(config) ? 'on' : 'turned off';
152
+ return diagnosticsOn(config) ? 'is on' : 'is turned off';
150
153
  },
151
154
  hasDestination,
152
155
  installId,
@@ -230,7 +233,14 @@ export function createAnalytics({ config, version, dir, client, env = process.en
230
233
  return sent;
231
234
  },
232
235
 
233
- async flush() { try { await posthog?.flush?.(); } catch {} },
236
+ // AND THE SWITCH IS READ HERE TOO (w-60c8ddbab6). This is a send like any
237
+ // other, which is note 4 at the top of this file, and until the quit
238
+ // started waiting for it the point was moot: a fire-and-forget flush on the
239
+ // way out mostly never left the machine. Now that it lands, a flush asked
240
+ // for after the switch went off would carry events captured before it did,
241
+ // and section 10 of the privacy page says turning it off "stops sending from
242
+ // that moment".
243
+ async flush() { if (!on()) return; try { await posthog?.flush?.(); } catch {} },
234
244
  async shutdown() { try { await posthog?.shutdown?.(); } catch {} },
235
245
  };
236
246
  }
@@ -12,23 +12,15 @@
12
12
  // was not: one hand-written line saying `"source":"founder"` granted its own
13
13
  // next run bypassPermissions. Proved on a scratch ledger, 2026-08-24.
14
14
  //
15
- // So the grant is not on the ledger any more. It lives here, in the app's own
16
- // home, written by main and by nothing else. The MCP server has no code that
17
- // reaches this file and no tool that names it.
15
+ // So the grant is not on the ledger any more. It lives in her own folder (the
16
+ // desktop's user data folder), written by main and by nothing else.
18
17
  //
19
- // WHAT THIS DOES AND WHAT IT DOES NOT DO, said plainly.
20
- //
21
- // It does NOT make forging impossible. Anything that can run commands can
22
- // write any file this user owns, and `~/.astral` is no exception. Claude Code
23
- // has the identical property: its settings file is as writable as anything
24
- // else in your home, and it relies on the sandbox and the prompt rather than
25
- // on the folder.
26
- //
27
- // What it changes is the COST. Before: one more line appended to a file the
28
- // worker was appending to anyway, in a format it writes every few seconds,
29
- // in its own working directory. After: a deliberate reach into the app's own
30
- // storage, in a file nothing briefs it about and no tool it holds mentions.
31
- // That is a real difference and it is the whole claim. It is not a wall.
18
+ // NEVER UNDER THE STORE ROOT. The app makes its own home the store root, and
19
+ // every Claude worker is granted that folder, so a file edit there needs no
20
+ // card. Outside the granted folders a write meets the same card or auto-mode
21
+ // check as any other reach into her home. That is the whole claim and it is
22
+ // not a wall: anything that can run commands can write any file this user owns
23
+ // (tests/a-worker-cannot-set-its-own-permission-mode).
32
24
  //
33
25
  // GRANTING IS THE FOUNDER'S, SPENDING ONE IS THE MACHINERY'S, AGENTS GET
34
26
  // NEITHER. That rule survives the move intact, and it is now structural rather
@@ -36,13 +28,73 @@
36
28
  // there is no `source` to check and no line for anyone else to forge.
37
29
 
38
30
  import fs from 'node:fs';
31
+ import os from 'node:os';
39
32
  import path from 'node:path';
40
- import { appHome } from './store/home.mjs';
33
+ import crypto from 'node:crypto';
34
+ import { nameSlug } from '../shared/product-name.mjs';
41
35
  import { CLAUDE_PERMISSION_MODES } from '../shared/work-items.mjs';
42
36
  import { CODEX_MODE_ORDER } from '../shared/codex-modes.mjs';
43
37
 
44
38
  const FILE = 'answer-modes.json';
45
39
 
40
+ // Set once at start by `keepAnswerModesIn`. Until then nothing is read or kept:
41
+ // a process that never said where must not fall back to the store root.
42
+ let where = null;
43
+ let saidNowhere = false;
44
+
45
+ const inside = (file, dir) => {
46
+ const f = path.resolve(file);
47
+ const d = path.resolve(dir);
48
+ return f === d || f.startsWith(d + path.sep);
49
+ };
50
+
51
+ function configHome(home, env) {
52
+ const given = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
53
+ return given && path.isAbsolute(given) ? given : path.join(home, '.config');
54
+ }
55
+
56
+ /**
57
+ * Where the app keeps these, said once at start (main/main.mjs, main/serve.mjs).
58
+ *
59
+ * In `userDir`, unless that is inside the store root, as the headless copy's
60
+ * is: then in a per-store folder under the config home, the way the team
61
+ * sign-in moves (main/team/session.mjs). If that is inside the store root too,
62
+ * nowhere: every thread runs on its project's mode, which is the safe side.
63
+ * Returns the file, or null.
64
+ */
65
+ export function keepAnswerModesIn({ userDir, storeRoot, home = os.homedir(), env = process.env }) {
66
+ let target = userDir ? path.join(userDir, FILE) : null;
67
+ if (target && storeRoot && inside(target, storeRoot)) {
68
+ const tag = crypto.createHash('sha256').update(path.resolve(storeRoot)).digest('hex').slice(0, 16);
69
+ target = path.join(configHome(home, env), nameSlug, 'grants', tag, FILE);
70
+ }
71
+ if (!target || (storeRoot && inside(target, storeRoot))) {
72
+ console.warn(`zero: nowhere outside the store root (${storeRoot}) to keep what a reply may do, so every thread runs on its project's mode`);
73
+ target = null;
74
+ }
75
+ where = target;
76
+ if (storeRoot) carryFromTheStore(path.join(storeRoot, FILE));
77
+ return where;
78
+ }
79
+
80
+ export const answerModesFile = () => where;
81
+
82
+ // AN OLDER VERSION KEPT THIS IN THE STORE ROOT. Its file is carried across the
83
+ // first time and removed every time. The new file is created even when there is
84
+ // nothing to carry, because that is what makes the carry happen once: without
85
+ // it, a file a worker wrote into the store later would be carried at the next
86
+ // start.
87
+ function carryFromTheStore(old) {
88
+ let found = null;
89
+ try { found = fs.lstatSync(old); } catch { /* nothing there */ }
90
+ if (where && !fs.existsSync(where)) {
91
+ writeAll(where, sweep(found?.isFile() ? readAll(old) : {}, Date.now()));
92
+ }
93
+ if (found) {
94
+ try { fs.rmSync(old, { force: true }); } catch (err) { console.warn(`zero: could not remove ${old}: ${err.message}`); }
95
+ }
96
+ }
97
+
46
98
  // HOW LONG A MODE LIVES, NOW THAT IT BELONGS TO A THREAD AND NOT TO ONE SEND.
47
99
  //
48
100
  // This was 24 hours, and 24 hours was right while the supervisor spent the
@@ -61,21 +113,35 @@ const FILE = 'answer-modes.json';
61
113
  // the file does not grow without limit. Swept on every write.
62
114
  const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
63
115
 
64
- const file = (home = appHome()) => path.join(home, FILE);
116
+ const fileFor = (home) => (home === undefined ? where : path.join(home, FILE));
65
117
 
66
- function readAll(home) {
118
+ // Never through a link, and never anything but a regular file, so a link left
119
+ // in place of one cannot point the read at a file somebody else writes.
120
+ const READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
121
+
122
+ function readAll(target) {
123
+ if (!target) return {};
124
+ let fd = null;
67
125
  try {
68
- const raw = JSON.parse(fs.readFileSync(file(home), 'utf8'));
126
+ fd = fs.openSync(target, READ_FLAGS);
127
+ if (!fs.fstatSync(fd).isFile()) return {};
128
+ const raw = JSON.parse(fs.readFileSync(fd, 'utf8'));
69
129
  return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
70
130
  } catch {
71
131
  return {};
132
+ } finally {
133
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
72
134
  }
73
135
  }
74
136
 
75
137
  // tmp + rename, so a reader never sees half a file and a crash mid-write leaves
76
138
  // the previous one intact.
77
- function writeAll(home, all) {
78
- const target = file(home);
139
+ function writeAll(target, all) {
140
+ if (!target) {
141
+ if (!saidNowhere) console.warn('zero: a mode was picked with nowhere to keep it, so the thread runs on its project\'s mode');
142
+ saidNowhere = true;
143
+ return;
144
+ }
79
145
  fs.mkdirSync(path.dirname(target), { recursive: true });
80
146
  const tmp = `${target}.${process.pid}.tmp`;
81
147
  fs.writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`);
@@ -123,8 +189,8 @@ function sweep(all, now) {
123
189
  */
124
190
  const ALLOWED_MODES = [...CLAUDE_PERMISSION_MODES, ...CODEX_MODE_ORDER];
125
191
 
126
- export function answerMode(slug, id, { home = appHome(), now = Date.now() } = {}) {
127
- const entry = readAll(home)[keyOf(slug, id)];
192
+ export function answerMode(slug, id, { home, now = Date.now() } = {}) {
193
+ const entry = readAll(fileFor(home))[keyOf(slug, id)];
128
194
  if (!entry || typeof entry !== 'object') return undefined;
129
195
  if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) return undefined;
130
196
  if (entry.mode === null) return null;
@@ -132,9 +198,9 @@ export function answerMode(slug, id, { home = appHome(), now = Date.now() } = {}
132
198
  }
133
199
 
134
200
  /** Every row that currently carries one, as `{ "<slug>/<id>": mode }`. */
135
- export function answerModes({ home = appHome(), now = Date.now() } = {}) {
201
+ export function answerModes({ home, now = Date.now() } = {}) {
136
202
  const out = {};
137
- for (const [key, entry] of Object.entries(readAll(home))) {
203
+ for (const [key, entry] of Object.entries(readAll(fileFor(home)))) {
138
204
  if (!entry || typeof entry !== 'object') continue;
139
205
  if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) continue;
140
206
  if (entry.mode === null) { out[key] = null; continue; }
@@ -150,12 +216,12 @@ export function answerModes({ home = appHome(), now = Date.now() } = {}) {
150
216
  * A mode that is not one of the six is refused outright rather than stored,
151
217
  * which is what the ledger's own check used to do.
152
218
  */
153
- export function setAnswerMode(slug, id, mode, { home = appHome(), now = Date.now() } = {}) {
219
+ export function setAnswerMode(slug, id, mode, { home, now = Date.now() } = {}) {
154
220
  if (mode !== null && !ALLOWED_MODES.includes(mode)) return answerMode(slug, id, { home, now });
155
- const all = sweep(readAll(home), now);
221
+ const all = sweep(readAll(fileFor(home)), now);
156
222
  all[keyOf(slug, id)] = { mode, at: now };
157
- writeAll(home, all);
158
- return mode;
223
+ writeAll(fileFor(home), all);
224
+ return fileFor(home) ? mode : undefined;
159
225
  }
160
226
 
161
227
  /**
@@ -170,14 +236,14 @@ export function setAnswerMode(slug, id, mode, { home = appHome(), now = Date.now
170
236
  * the worst this call can do is keep a grant she already made alive one more
171
237
  * month.
172
238
  */
173
- export function touchAnswerMode(slug, id, used, { home = appHome(), now = Date.now() } = {}) {
174
- const all = sweep(readAll(home), now);
239
+ export function touchAnswerMode(slug, id, used, { home, now = Date.now() } = {}) {
240
+ const all = sweep(readAll(fileFor(home)), now);
175
241
  const key = keyOf(slug, id);
176
242
  const entry = all[key];
177
243
  if (!entry || typeof entry !== 'object') return undefined;
178
244
  if (used !== undefined && entry.mode !== used) return entry.mode;
179
245
  all[key] = { mode: entry.mode, at: now };
180
- writeAll(home, all);
246
+ writeAll(fileFor(home), all);
181
247
  return entry.mode;
182
248
  }
183
249
 
@@ -193,14 +259,14 @@ export function touchAnswerMode(slug, id, used, { home = appHome(), now = Date.n
193
259
  * NOW, for the same reason as above. Clearing is not "less": it means back to
194
260
  * the project's setting, which can be the wider one.
195
261
  */
196
- export function clearAnswerMode(slug, id, spent, { home = appHome(), now = Date.now() } = {}) {
197
- const all = sweep(readAll(home), now);
262
+ export function clearAnswerMode(slug, id, spent, { home, now = Date.now() } = {}) {
263
+ const all = sweep(readAll(fileFor(home)), now);
198
264
  const key = keyOf(slug, id);
199
265
  const current = all[key]?.mode;
200
266
  if (spent !== undefined && current !== spent) return current === undefined ? undefined : current;
201
267
  all[key] = { mode: null, at: now };
202
- writeAll(home, all);
268
+ writeAll(fileFor(home), all);
203
269
  return null;
204
270
  }
205
271
 
206
- export const _internals = { file, KEEP_MS };
272
+ export const _internals = { file: fileFor, KEEP_MS, useFile: (f) => { where = f ?? null; } };
@@ -0,0 +1,197 @@
1
+ // The approvals channel: a tiny MCP server that Claude Code calls (via
2
+ // --permission-prompt-tool) whenever a headless worker wants to do something
3
+ // its standing grants do not cover. The request lands as a file in the spool,
4
+ // the app shows the founder a card, her click writes the answer file, and the
5
+ // worker unblocks in place. Before this, a gated command ended the story:
6
+ // sessions wrote everything and bounced the actual run back to the founder
7
+ // as a paste (2026-08-05).
8
+ //
9
+ // Deny is the default in every failure mode: the run ending, a spool error, a
10
+ // malformed answer. An approval can only come from the founder's explicit file,
11
+ // and a silence is not one: the question waits for her rather than being
12
+ // answered by a clock.
13
+ //
14
+ // EVERY TOUCH OF THE SPOOL GOES THROUGH main/approvals.mjs, which is where the
15
+ // rule that this directory is worker-writable is written down and enforced --
16
+ // nothing opened through a link, nothing opened that is not a regular file,
17
+ // every write landed by rename. It used to be bare `fs` here, and the audit
18
+ // line was the worst of it: `log.jsonl` is a fixed name, so a worker that
19
+ // symlinked it at `briefs/founder.md` had a repeatable append of its own
20
+ // `command` string into the standing instructions every session is briefed
21
+ // with. This process runs as the worker's own child, so it is the near half of
22
+ // that attack (2026-09-04).
23
+
24
+ import fs from 'node:fs';
25
+ import crypto from 'node:crypto';
26
+ import readline from 'node:readline';
27
+ import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEntry, spoolEntryExists, writeSpoolFile } from './approvals.mjs';
28
+
29
+ const POLL_MS = 2_000;
30
+
31
+ // A QUESTION IS NOT AN APPROVAL. Workers launch without AskUserQuestion
32
+ // (Supervisor.prepareClaudePermissions); one that arrives here anyway is
33
+ // turned back to the thread before any card exists, because the card has
34
+ // nowhere to put an answer (w-ab1f2f1d82).
35
+ const ASK_IN_THE_THREAD = 'Questions are not asked here. Finish what you can without the answer, then end your turn with the question as your last message and its choices listed under Options. Their reply comes back to this session.';
36
+
37
+ /**
38
+ * One approvals channel, over `input` and `output` (a process's own stdio, or
39
+ * a cloud run's connection when many share one process: main/cloud/run.mjs).
40
+ * `env` is that worker's: the spool, the public key, and which row it is.
41
+ */
42
+ export function serveApprovals({ env = process.env, input = process.stdin, output = process.stdout, warn = console.warn } = {}) {
43
+ const SPOOL = env.ZERO_APPROVALS_DIR;
44
+ // THE KEY THAT SAYS AN ANSWER IS THE USER'S, AND IT IS SAFE TO HAND US.
45
+ //
46
+ // This channel is the worker's own, so its environment is the worker's to
47
+ // read and anything secret given here is a secret the forger has. This is the
48
+ // PUBLIC half of a pair whose private half never leaves Agentbox's process: it
49
+ // verifies and it cannot sign, which is exactly why it can travel this way.
50
+ // main/approvals.mjs holds the whole argument, including what it does not
51
+ // close.
52
+ const PUBLIC_KEY = env.ZERO_APPROVALS_PUBKEY;
53
+ // Closed when its run has gone: a question still waiting stops at once.
54
+ let closed = false;
55
+ const sleepers = new Set();
56
+ const nap = () => new Promise((resolve) => {
57
+ const wake = () => { clearTimeout(timer); sleepers.delete(wake); resolve(); };
58
+ const timer = setTimeout(wake, POLL_MS);
59
+ sleepers.add(wake);
60
+ });
61
+ const send = (msg) => { if (!output.writableEnded && !output.destroyed) output.write(JSON.stringify(msg) + '\n'); };
62
+ async function decide(args) {
63
+ try {
64
+ if (args?.tool_name === 'AskUserQuestion') return { behavior: 'deny', message: ASK_IN_THE_THREAD };
65
+ if (!SPOOL) return { behavior: 'deny', message: 'approvals spool is not configured' };
66
+ // NO KEY IS A CHANNEL THAT CANNOT TELL HER ANSWER FROM A WORKER'S, and a
67
+ // channel that cannot tell must not guess. It refuses here rather than
68
+ // asking and then denying in fifteen minutes, because nothing that arrives
69
+ // in that quarter of an hour could become an approval anyway.
70
+ if (!PUBLIC_KEY) return { behavior: 'deny', message: 'the approvals channel was given no key to check the founder\'s answer with, so nothing here can be approved' };
71
+ fs.mkdirSync(SPOOL, { recursive: true });
72
+ const id = crypto.randomUUID();
73
+ const request = {
74
+ id,
75
+ at: Date.now(),
76
+ product: env.ZERO_PRODUCT ?? null,
77
+ item: env.ZERO_ITEM ?? null,
78
+ tool: args.tool_name ?? 'unknown',
79
+ input: args.input ?? {},
80
+ };
81
+ // A question that could not be asked has already been answered.
82
+ if (!writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))) {
83
+ return { behavior: 'deny', message: 'the approvals spool would not take this request, so nothing was put in front of the founder' };
84
+ }
85
+ const answerName = `${id}.answer.json`;
86
+ let saidItCouldNotGoBack = false;
87
+ // A QUESTION WAITS FOR ITS ANSWER (w-4b00022acb). This gave up after
88
+ // fifteen minutes and told the run it had been refused, which is not
89
+ // what a silence means. The thread reads Asking and sits in Needs you
90
+ // while this waits (w-fe48447cab), and the run that asked is the bound:
91
+ // its end closes this channel and the loop stops with it.
92
+ while (!closed) {
93
+ // `lstat` rather than `existsSync`, so this sees exactly what the app's
94
+ // own `listPending` sees: an entry by that name, whatever it is.
95
+ if (spoolEntryExists(SPOOL, answerName)) {
96
+ const answer = readSpoolJson(SPOOL, answerName);
97
+ // AN ANSWER THAT IS NOT HERS IS NOT A DECISION AT ALL, and this is the
98
+ // line the whole file turns on. It used to read `answer?.allow` off
99
+ // whatever was in the directory, so any process that could write here
100
+ // was approved -- another worker's card, or its own.
101
+ //
102
+ // AND IT IS NOT A DENY EITHER, which matters as much: denying on an
103
+ // unreadable answer would let one worker force a No onto another's
104
+ // card, losing her Allow in silence. The file goes and the question
105
+ // goes on waiting, exactly as main/codex-approvals.mjs's own sweep
106
+ // does, and her real answer still lands when she gives it.
107
+ if (!answerIsHers(id, answer, PUBLIC_KEY)) {
108
+ removeSpoolEntry(SPOOL, answerName);
109
+ warn(`zero: an answer for approval ${id} that the founder did not write has been deleted; the question is still waiting`);
110
+ await nap();
111
+ continue;
112
+ }
113
+ // AND HER DECISION IS ABOUT AN ACTION, NOT ABOUT AN ID. The request
114
+ // file is worker-writable and its name and id survive a rewrite of its
115
+ // contents, so a worker could raise one command, replace the body with
116
+ // a harmless one, and have her Allow on the harmless card honoured
117
+ // against the command THIS PROCESS is still holding. `request` here is
118
+ // that memory: it was built from the tool call and has never been read
119
+ // back off the disk, so comparing it against the digest she signed is
120
+ // the whole check. A mismatch is a deny -- the user said yes to something
121
+ // else -- and it is loud, because only a rewrite can produce it.
122
+ const mine = cardDigest(request);
123
+ if (!mine || answer.card !== mine) {
124
+ finish(id, request, { allow: false, swapped: true });
125
+ warn(`zero: approval ${id} was answered for a different card than the one this run asked about; the request file was rewritten under it and nothing has been allowed`);
126
+ return {
127
+ behavior: 'deny',
128
+ message: 'The approval request changed between being asked and being answered, so the founder\'s decision was for a different action and nothing was allowed. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
129
+ };
130
+ }
131
+ finish(id, request, answer);
132
+ if (answer.allow) return { behavior: 'allow', updatedInput: args.input ?? {} };
133
+ return { behavior: 'deny', message: answer.note || 'The founder declined this action. Work with what you are already allowed to do, or park the remainder as a review.' };
134
+ }
135
+ // THE SPOOL REAPS WHAT NOBODY IS WAITING ON, AND THIS IS HOW IT KNOWS.
136
+ // The app's `listPending` cannot see this loop, and this loop outlives
137
+ // its REQUEST_TTL_MS, so whoever waits keeps their own request there.
138
+ // Written from the `request` this process has held all along, never
139
+ // read back off the disk, so what comes back is the card that went.
140
+ if (!spoolEntryExists(SPOOL, `${id}.request.json`)
141
+ && !writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))
142
+ && !saidItCouldNotGoBack) {
143
+ // Failing here leaves this run parked with nothing on screen, which
144
+ // is the one way the wait goes bad, so it is said out loud. Once:
145
+ // this loop comes round every two seconds.
146
+ saidItCouldNotGoBack = true;
147
+ warn(`zero: approval ${id} left the spool and could not be put back, so this run is waiting with no card on screen`);
148
+ }
149
+ await nap();
150
+ }
151
+ finish(id, request, { allow: false, gone: true });
152
+ return { behavior: 'deny', message: 'the run that asked has ended' };
153
+ } catch (err) {
154
+ return { behavior: 'deny', message: `approvals channel error: ${err.message}` };
155
+ }
156
+ }
157
+
158
+ // One audit line per decision; the request/answer pair leaves the spool.
159
+ function finish(id, request, answer) {
160
+ appendSpoolLine(SPOOL, 'log.jsonl', `${JSON.stringify({ ...request, answer, decidedAt: Date.now() })}\n`);
161
+ for (const f of [`${id}.request.json`, `${id}.answer.json`]) removeSpoolEntry(SPOOL, f);
162
+ }
163
+
164
+ const rl = readline.createInterface({ input });
165
+ rl.on('line', async (line) => {
166
+ let msg;
167
+ try { msg = JSON.parse(line); } catch { return; }
168
+ const { id, method, params } = msg;
169
+ if (method === 'initialize') {
170
+ send({ jsonrpc: '2.0', id, result: {
171
+ protocolVersion: params?.protocolVersion ?? '2024-11-05',
172
+ capabilities: { tools: {} },
173
+ serverInfo: { name: 'zero-approvals', version: '1.0.0' },
174
+ } });
175
+ } else if (method === 'tools/list') {
176
+ send({ jsonrpc: '2.0', id, result: { tools: [{
177
+ name: 'approval_prompt',
178
+ description: 'Asks the founder, live in her inbox, to approve one gated action. The run stops here until she answers, however long that takes. Use only when the work genuinely cannot proceed without it.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ tool_name: { type: 'string' },
183
+ input: { type: 'object' },
184
+ tool_use_id: { type: 'string' },
185
+ },
186
+ required: ['tool_name', 'input'],
187
+ },
188
+ }] } });
189
+ } else if (method === 'tools/call' && params?.name === 'approval_prompt') {
190
+ const result = await decide(params.arguments ?? {});
191
+ send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
192
+ } else if (id !== undefined) {
193
+ send({ jsonrpc: '2.0', id, result: {} });
194
+ }
195
+ });
196
+ return { close: () => { closed = true; for (const wake of [...sleepers]) wake(); rl.close(); } };
197
+ }
@@ -19,142 +19,8 @@
19
19
  // with. This process runs as the worker's own child, so it is the near half of
20
20
  // that attack (2026-09-04).
21
21
 
22
- import fs from 'node:fs';
23
- import crypto from 'node:crypto';
24
- import readline from 'node:readline';
25
- import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEntry, spoolEntryExists, writeSpoolFile } from './approvals.mjs';
22
+ import { serveApprovals } from './approval-prompt-core.mjs';
26
23
 
27
- const SPOOL = process.env.ZERO_APPROVALS_DIR;
28
-
29
- // THE KEY THAT SAYS AN ANSWER IS THE USER'S, AND IT IS SAFE TO HAND US.
30
- //
31
- // This process is the worker's own child, so its environment is the worker's to
32
- // read and anything secret given here is a secret the forger has. This is the
33
- // PUBLIC half of a pair whose private half never leaves Agentbox's process: it
34
- // verifies and it cannot sign, which is exactly why it can travel this way.
35
- // main/approvals.mjs holds the whole argument, including what it does not
36
- // close.
37
- const PUBLIC_KEY = process.env.ZERO_APPROVALS_PUBKEY;
38
-
39
- const TIMEOUT_MS = 15 * 60_000;
40
- const POLL_MS = 2_000;
41
-
42
- const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
43
-
44
- async function decide(args) {
45
- try {
46
- if (!SPOOL) return { behavior: 'deny', message: 'approvals spool is not configured' };
47
- // NO KEY IS A CHANNEL THAT CANNOT TELL HER ANSWER FROM A WORKER'S, and a
48
- // channel that cannot tell must not guess. It refuses here rather than
49
- // asking and then denying in fifteen minutes, because nothing that arrives
50
- // in that quarter of an hour could become an approval anyway.
51
- if (!PUBLIC_KEY) return { behavior: 'deny', message: 'the approvals channel was given no key to check the founder\'s answer with, so nothing here can be approved' };
52
- fs.mkdirSync(SPOOL, { recursive: true });
53
- const id = crypto.randomUUID();
54
- const request = {
55
- id,
56
- at: Date.now(),
57
- product: process.env.ZERO_PRODUCT ?? null,
58
- item: process.env.ZERO_ITEM ?? null,
59
- tool: args.tool_name ?? 'unknown',
60
- input: args.input ?? {},
61
- };
62
- // A question that could not be asked has already been answered.
63
- if (!writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))) {
64
- return { behavior: 'deny', message: 'the approvals spool would not take this request, so nothing was put in front of the founder' };
65
- }
66
- const answerName = `${id}.answer.json`;
67
- const deadline = Date.now() + TIMEOUT_MS;
68
- while (Date.now() < deadline) {
69
- // `lstat` rather than `existsSync`, so this sees exactly what the app's
70
- // own `listPending` sees: an entry by that name, whatever it is.
71
- if (spoolEntryExists(SPOOL, answerName)) {
72
- const answer = readSpoolJson(SPOOL, answerName);
73
- // AN ANSWER THAT IS NOT HERS IS NOT A DECISION AT ALL, and this is the
74
- // line the whole file turns on. It used to read `answer?.allow` off
75
- // whatever was in the directory, so any process that could write here
76
- // was approved -- another worker's card, or its own.
77
- //
78
- // AND IT IS NOT A DENY EITHER, which matters as much: denying on an
79
- // unreadable answer would let one worker force a No onto another's
80
- // card, losing her Allow in silence. The file goes and the question
81
- // goes on waiting, exactly as main/codex-approvals.mjs's own sweep
82
- // does, and her real answer still lands when she gives it.
83
- if (!answerIsHers(id, answer, PUBLIC_KEY)) {
84
- removeSpoolEntry(SPOOL, answerName);
85
- console.warn(`zero: an answer for approval ${id} that the founder did not write has been deleted; the question is still waiting`);
86
- await new Promise((r) => setTimeout(r, POLL_MS));
87
- continue;
88
- }
89
- // AND HER DECISION IS ABOUT AN ACTION, NOT ABOUT AN ID. The request
90
- // file is worker-writable and its name and id survive a rewrite of its
91
- // contents, so a worker could raise one command, replace the body with
92
- // a harmless one, and have her Allow on the harmless card honoured
93
- // against the command THIS PROCESS is still holding. `request` here is
94
- // that memory: it was built from the tool call and has never been read
95
- // back off the disk, so comparing it against the digest she signed is
96
- // the whole check. A mismatch is a deny -- the user said yes to something
97
- // else -- and it is loud, because only a rewrite can produce it.
98
- const mine = cardDigest(request);
99
- if (!mine || answer.card !== mine) {
100
- finish(id, request, { allow: false, swapped: true });
101
- console.warn(`zero: approval ${id} was answered for a different card than the one this run asked about; the request file was rewritten under it and nothing has been allowed`);
102
- return {
103
- behavior: 'deny',
104
- message: 'The approval request changed between being asked and being answered, so the founder\'s decision was for a different action and nothing was allowed. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
105
- };
106
- }
107
- finish(id, request, answer);
108
- if (answer.allow) return { behavior: 'allow', updatedInput: args.input ?? {} };
109
- return { behavior: 'deny', message: answer.note || 'The founder declined this action. Work with what you are already allowed to do, or park the remainder as a review.' };
110
- }
111
- await new Promise((r) => setTimeout(r, POLL_MS));
112
- }
113
- finish(id, request, { allow: false, timeout: true });
114
- return {
115
- behavior: 'deny',
116
- message: 'No answer from the founder within 15 minutes. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
117
- };
118
- } catch (err) {
119
- return { behavior: 'deny', message: `approvals channel error: ${err.message}` };
120
- }
121
- }
122
-
123
- // One audit line per decision; the request/answer pair leaves the spool.
124
- function finish(id, request, answer) {
125
- appendSpoolLine(SPOOL, 'log.jsonl', `${JSON.stringify({ ...request, answer, decidedAt: Date.now() })}\n`);
126
- for (const f of [`${id}.request.json`, `${id}.answer.json`]) removeSpoolEntry(SPOOL, f);
127
- }
128
-
129
- const rl = readline.createInterface({ input: process.stdin });
130
- rl.on('line', async (line) => {
131
- let msg;
132
- try { msg = JSON.parse(line); } catch { return; }
133
- const { id, method, params } = msg;
134
- if (method === 'initialize') {
135
- send({ jsonrpc: '2.0', id, result: {
136
- protocolVersion: params?.protocolVersion ?? '2024-11-05',
137
- capabilities: { tools: {} },
138
- serverInfo: { name: 'zero-approvals', version: '1.0.0' },
139
- } });
140
- } else if (method === 'tools/list') {
141
- send({ jsonrpc: '2.0', id, result: { tools: [{
142
- name: 'approval_prompt',
143
- description: 'Asks the founder, live in her inbox, to approve one gated action. She answers within minutes when present; a 15-minute silence is a deny. Use only when the work genuinely cannot proceed without it.',
144
- inputSchema: {
145
- type: 'object',
146
- properties: {
147
- tool_name: { type: 'string' },
148
- input: { type: 'object' },
149
- tool_use_id: { type: 'string' },
150
- },
151
- required: ['tool_name', 'input'],
152
- },
153
- }] } });
154
- } else if (method === 'tools/call' && params?.name === 'approval_prompt') {
155
- const result = await decide(params.arguments ?? {});
156
- send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
157
- } else if (id !== undefined) {
158
- send({ jsonrpc: '2.0', id, result: {} });
159
- }
160
- });
24
+ // Run as its own process by Claude Code for a run on this Mac; a cloud run's
25
+ // relay serves the same channel in-process (main/cloud/run.mjs).
26
+ serveApprovals();