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
@@ -95,6 +95,11 @@ export class MemoryGateServer {
95
95
  socketPath = defaultSocketPath(),
96
96
  historyFile = null,
97
97
  history = null,
98
+ // One appended line per grant, so what the slots cost can be read later.
99
+ // Without it the only duration anywhere is a transcript's, which is waiting
100
+ // and running added together and cannot be separated.
101
+ grantLogFile = null,
102
+ grantLogMaxBytes = 4 * 1024 * 1024,
98
103
  gate = {},
99
104
  scoreFor = null,
100
105
  // WHOSE CODEX THREAD IS THIS (w-e5225b62ba). A Claude worker carries its row
@@ -128,6 +133,8 @@ export class MemoryGateServer {
128
133
  } = {}) {
129
134
  this.socketPath = socketPath;
130
135
  this.historyFile = historyFile;
136
+ this.grantLogFile = grantLogFile;
137
+ this.grantLogMaxBytes = grantLogMaxBytes;
131
138
  this.history = history ?? loadHistory(historyFile);
132
139
  this.clock = clock;
133
140
  this.idleReleaseMs = idleReleaseMs;
@@ -355,6 +362,10 @@ export class MemoryGateServer {
355
362
  res.end('');
356
363
  } else {
357
364
  this.counts.refused++;
365
+ this._writeGrant({
366
+ refused: true, cls: decision.cls, waitMs: decision.waitedMs, ranMs: null, peakMb: null,
367
+ pressure: decision.pressure, command,
368
+ });
358
369
  res.end(denyOutput(decision.reason));
359
370
  }
360
371
  });
@@ -372,6 +383,16 @@ export class MemoryGateServer {
372
383
  this.grants.delete(id);
373
384
  const was = this.gate.finish(id);
374
385
  if (!was) return;
386
+ this._writeGrant({
387
+ refused: false,
388
+ cls: was.cls,
389
+ waitMs: was.waitedMs ?? 0,
390
+ ranMs: this.clock() - was.startedAt,
391
+ peakMb: grant?.ambiguous ? null : was.peakMb,
392
+ shared: !!grant?.ambiguous,
393
+ pressure: was.pressureAt,
394
+ command: was.command,
395
+ });
375
396
  // A SHARED READING IS NOT NO READING (w-5601e99977). Every Codex thread of
376
397
  // a login runs under one `codex app-server`, so two Codex commands at once
377
398
  // are measured together and neither may claim the figure. How long each
@@ -500,6 +521,47 @@ export class MemoryGateServer {
500
521
  this.lastPids = new Set(procs.map((p) => p.pid));
501
522
  }
502
523
 
524
+ /**
525
+ * One line per grant: what it was, how long it queued, how long it ran and
526
+ * its measured peak. `shared` says the peak is null because two commands on
527
+ * one agent process were measured together, without which a week of this
528
+ * reads as two commands where there was one.
529
+ *
530
+ * Every shell command on the Mac waits on this path, so a write that fails
531
+ * is logged and swallowed rather than returned to the agent.
532
+ */
533
+ _writeGrant({ refused, cls, waitMs, ranMs, peakMb, shared = false, pressure = null, command }) {
534
+ if (!this.grantLogFile) return;
535
+ try {
536
+ if (this.grantBytes === undefined) {
537
+ try { this.grantBytes = fs.statSync(this.grantLogFile).size; } catch { this.grantBytes = 0; }
538
+ }
539
+ // Bounded, and the one before it is kept, so a week is still readable
540
+ // across the two even when a busy day fills one.
541
+ if (this.grantBytes >= this.grantLogMaxBytes) {
542
+ fs.renameSync(this.grantLogFile, `${this.grantLogFile}.1`);
543
+ this.grantBytes = 0;
544
+ }
545
+ const line = `${JSON.stringify({
546
+ t: this.clock(),
547
+ cls: cls ?? null,
548
+ waitMs: Number.isFinite(waitMs) ? waitMs : null,
549
+ ranMs: Number.isFinite(ranMs) ? ranMs : null,
550
+ peakMb: Number.isFinite(peakMb) ? peakMb : null,
551
+ // Only a wait that happened while this was `normal` is evidence about
552
+ // the slot count; under `tight` nothing heavy starts whatever it is.
553
+ pressure: pressure ?? null,
554
+ ...(shared ? { shared: true } : {}),
555
+ ...(refused ? { refused: true } : {}),
556
+ cmd: String(command ?? '').slice(0, 300),
557
+ })}\n`;
558
+ fs.appendFileSync(this.grantLogFile, line);
559
+ this.grantBytes += Buffer.byteLength(line);
560
+ } catch (err) {
561
+ this.log(`memory gate: could not write the grant log: ${err.message}`);
562
+ }
563
+ }
564
+
503
565
  _saveHistory() {
504
566
  if (!this.historyFile || !this.dirty) return;
505
567
  try {
@@ -206,7 +206,15 @@ export class MemoryGate {
206
206
  for (const w of [...this.waiting.values()]) {
207
207
  if (now - w.arrived >= this.maxWaitMs) {
208
208
  this.waiting.delete(w.id);
209
- for (const r of w.replies) r({ allow: false, reason: refusal(now - w.arrived, this.pressure) });
209
+ for (const r of w.replies) {
210
+ r({
211
+ reason: refusal(now - w.arrived, this.pressure),
212
+ allow: false,
213
+ cls: w.cls,
214
+ waitedMs: now - w.arrived,
215
+ pressure: this.pressure,
216
+ });
217
+ }
210
218
  }
211
219
  }
212
220
  if (this.pressure === 'critical') return;
@@ -241,6 +249,11 @@ export class MemoryGate {
241
249
  this.running.set(w.id, {
242
250
  id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
243
251
  urgent: w.urgent, session: w.session, background: w.background, startedAt: now, peakMb: null, shared: null,
252
+ // Only the coordinator reads these, to write down what the grant cost.
253
+ // The pressure is what makes the wait mean anything: under `normal` only
254
+ // full slots can hold a command back, so the wait is evidence about
255
+ // `slots`; under `tight` nothing heavy starts whatever the count is.
256
+ waitedMs: now - w.arrived, pressureAt: this.pressure,
244
257
  });
245
258
  for (const r of w.replies) r({ allow: true, cls: w.cls });
246
259
  }
package/main/menu.mjs CHANGED
@@ -159,8 +159,10 @@ export function installMenu({ onApproval, onReload, onZoom } = {}) {
159
159
  {
160
160
  label: 'Agents',
161
161
  submenu: [
162
- { label: 'Allow Pending Approval', accelerator: 'Command+Y', click: () => onApproval?.(true) },
163
- { label: 'Deny Pending Approval', accelerator: 'Command+N', click: () => onApproval?.(false) },
162
+ // CommandOrControl like every other accelerator here: Command alone
163
+ // left the approval card's hint naming two dead keys on Linux.
164
+ { label: 'Allow Pending Approval', accelerator: 'CommandOrControl+Y', click: () => onApproval?.(true) },
165
+ { label: 'Deny Pending Approval', accelerator: 'CommandOrControl+N', click: () => onApproval?.(false) },
164
166
  ],
165
167
  },
166
168
  {
@@ -48,8 +48,8 @@ export function wantsPriority(item, product, me) {
48
48
  // AND NEVER OVER THE LEVEL THE SENDER PICKED (w-7ba439c883). A message now
49
49
  // crosses carrying how urgent the person who wrote it thought it was, and a
50
50
  // guess from here would overwrite it: the sender's line is composeItem's, so
51
- // it claims no authority the fold would defend, and a level written from here
52
- // counts for nothing anyway (an agent's tag, shared/rank.mjs itemPriority).
51
+ // it claims no authority the fold would defend, and a guess from here is not
52
+ // what the sender said.
53
53
  // A row nobody chose a level for is composeItem's 0, so a number above it is
54
54
  // exactly "somebody picked one", and `by` says it was not this Mac.
55
55
  if (set?.by && set.by !== me && Number.isFinite(item.priority) && item.priority > 0) return false;
package/main/mic.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // Getting the microphone with one press, for a voice note.
2
+ //
3
+ // macOS asks on behalf of the app that opened Agentbox (Terminal, when it is
4
+ // run from a checkout), and once that app has been refused it never shows its
5
+ // Allow box again: asking just answers no. So the press clears that one
6
+ // refusal, for that one app and the microphone only, and asks again, which
7
+ // brings the Allow box back. Only ever on the press, never on its own.
8
+ import { execFile } from 'node:child_process';
9
+
10
+ export const SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone';
11
+
12
+ const NAMES = {
13
+ 'com.apple.Terminal': 'Terminal',
14
+ 'com.googlecode.iterm2': 'iTerm',
15
+ 'com.microsoft.VSCode': 'VS Code',
16
+ 'dev.warp.Warp-Stable': 'Warp',
17
+ 'com.mitchellh.ghostty': 'Ghostty',
18
+ };
19
+
20
+ /** The app macOS asks about, from the bundle id macOS gave this process. */
21
+ export function micOwner(env = process.env) {
22
+ const id = env.__CFBundleIdentifier;
23
+ if (typeof id !== 'string' || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)) return { id: null, name: null };
24
+ return { id, name: NAMES[id] ?? id.split('.').at(-1) };
25
+ }
26
+
27
+ /** 'granted', or 'settings' once the Settings page has been opened instead. */
28
+ export async function getMic({ systemPreferences, openExternal, owner = micOwner(), run = execFile }) {
29
+ const status = systemPreferences.getMediaAccessStatus('microphone');
30
+ if (status === 'granted') return 'granted';
31
+ if (status === 'denied' && owner.id) {
32
+ await new Promise((resolve) => run('tccutil', ['reset', 'Microphone', owner.id], { timeout: 10_000 }, () => resolve()));
33
+ }
34
+ const granted = await systemPreferences.askForMediaAccess('microphone').catch(() => false);
35
+ if (granted) return 'granted';
36
+ openExternal(SETTINGS_URL);
37
+ return 'settings';
38
+ }
@@ -0,0 +1,110 @@
1
+ // NEW CHATS COME IN ON THEIR OWN, when somebody ticked "Keep bringing in new
2
+ // chats from Claude Code and Codex" on setup's last screen or turned it on in
3
+ // Settings (`importNew`, from the moment `importNewSince`).
4
+ //
5
+ // A chat comes in when it was started after that moment, in a folder one of
6
+ // the projects points at, and has gone quiet. Claude Code may still have it
7
+ // open: people leave it open for hours, and a reply here to an open chat
8
+ // carries on a copy (Supervisor#importedThreadFor). It is filed the way setup
9
+ // files one: waiting on you to Needs you, finished to Done. Nothing asks about
10
+ // a chat first; asking rows are what the old Codex watch was removed for.
11
+ import { productsForThread } from '../shared/codex-import.mjs';
12
+ import { isShared } from '../shared/team-rules.mjs';
13
+ import { appSessionFolders } from './agent-homes.mjs';
14
+ import { outsideAgentsMode } from './settings.mjs';
15
+ import { claudeSessionOpen } from './claude-session-open.mjs';
16
+ import { readCodexThreadsOffThread } from './codex-threads.mjs';
17
+ import { readRecentThreads } from './importable-threads.mjs';
18
+
19
+ export const NEW_CHATS_POLL_MS = 60_000;
20
+ /** How long a chat has to have been still before it comes in. */
21
+ export const QUIET_MS = 10 * 60_000;
22
+ const PASS_DEADLINE_MS = 90_000;
23
+
24
+ function idsWithRows(items) {
25
+ const ids = new Set();
26
+ for (const item of items ?? []) {
27
+ for (const label of item?.labels ?? []) {
28
+ if (typeof label !== 'string') continue;
29
+ if (label.startsWith('thread:')) ids.add(label.slice(7));
30
+ else if (label.startsWith('codex:')) ids.add(label.slice(6));
31
+ }
32
+ }
33
+ return ids;
34
+ }
35
+
36
+ /** Which of `threads` come in now, grouped by the project each lands in. */
37
+ export function newChatsToBring(threads, { products, items, since, now, isOpen = () => false, openShownLive = false }) {
38
+ const had = idsWithRows(items);
39
+ const bySlug = new Map();
40
+ for (const t of threads ?? []) {
41
+ const id = String(t?.id ?? '').trim();
42
+ if (!id || had.has(id)) continue;
43
+ if (!(Number(t.startedAt) > since)) continue;
44
+ if (now - Number(t.when ?? 0) < QUIET_MS) continue;
45
+ if (t.source === 'codex' ? t.live : openShownLive && isOpen(t)) continue;
46
+ const slug = productsForThread(t, products)[0]?.slug;
47
+ if (!slug) continue;
48
+ if (!bySlug.has(slug)) bySlug.set(slug, []);
49
+ bySlug.get(slug).push(t);
50
+ had.add(id);
51
+ }
52
+ return bySlug;
53
+ }
54
+
55
+ export async function scanNewChats({ store, config, readThreads, isOpen, now = Date.now() }) {
56
+ if (config?.importNew !== true) return { brought: 0 };
57
+ const since = Number(config.importNewSince) || now;
58
+ // Only as far back as the switch, and never further than the import card reads.
59
+ const days = Math.min(10, Math.max(1, Math.ceil((now - since) / 86400e3) + 1));
60
+ // Never a project shared with a team: teammates would read chats nobody chose to share.
61
+ const products = store.listProducts().filter((p) => !isShared(p));
62
+ const folders = products.map((p) => p.repoPath).filter(Boolean);
63
+ const threads = await readThreads({ days, since, folders, now });
64
+ // An open session already has its own row when "Show while they run" is on.
65
+ const openShownLive = outsideAgentsMode(config) !== 'off';
66
+ const picks = newChatsToBring(threads, { products, items: store.listItems(), since, now, isOpen, openShownLive });
67
+ let brought = 0;
68
+ for (const [slug, list] of picks) {
69
+ brought += store.importThreadRows(slug, list, { now, land: 'sorted' }).added;
70
+ brought += store.importCodexThreads(slug, list, { now, land: 'sorted' }).added;
71
+ }
72
+ return { brought };
73
+ }
74
+
75
+ export function startNewChatsWatch({
76
+ store, config, readThreads, isOpen,
77
+ everyMs = NEW_CHATS_POLL_MS, firstDelayMs = 30_000, deadlineMs = PASS_DEADLINE_MS, log = console.warn,
78
+ }) {
79
+ // One pass at a time, and a pass that never answers is given up on so the
80
+ // next one still runs.
81
+ let running = null;
82
+ const run = () => {
83
+ if (running) return running;
84
+ let timer = null;
85
+ const deadline = new Promise((_, reject) => {
86
+ timer = setTimeout(() => reject(new Error(`no answer in ${Math.round(deadlineMs / 1000)}s`)), deadlineMs);
87
+ });
88
+ running = Promise.race([scanNewChats({ store, config, readThreads, isOpen }), deadline])
89
+ .catch((e) => log('new chats watch:', e?.message ?? e))
90
+ .finally(() => { clearTimeout(timer); running = null; });
91
+ return running;
92
+ };
93
+ const first = setTimeout(run, firstDelayMs);
94
+ const timer = setInterval(run, everyMs);
95
+ return {
96
+ scanNow: run,
97
+ stop: () => { clearTimeout(first); clearInterval(timer); },
98
+ };
99
+ }
100
+
101
+ /** The watch as the app runs it, on the desktop (main/main.mjs) and in the browser copy (bin/agentbox.mjs). */
102
+ export function watchNewChatsFor({ store, config, supervisor, ...timing }) {
103
+ return startNewChatsWatch({
104
+ store,
105
+ config,
106
+ readThreads: (opts) => readRecentThreads({ ...opts, config, supervisor, readCodex: readCodexThreadsOffThread }),
107
+ isOpen: (t) => claudeSessionOpen(t.id, appSessionFolders({ config, supervisor }).dirs),
108
+ ...timing,
109
+ });
110
+ }
@@ -0,0 +1,25 @@
1
+ // A throwaway copy's window says so, and stands where you can see both.
2
+ //
3
+ // "Open Agentbox as a new user" (main/fresh-user.mjs) launches the app again
4
+ // inside a throwaway home, beside the one it was opened from. The offset is a
5
+ // window's shoulder rather than a tile: the point is to see there are two, with
6
+ // the real one still readable behind. A copy that cannot fit the offset is left
7
+ // centred rather than pushed half off the screen, which a 1440-wide laptop is.
8
+
9
+ /** How far down and across a second copy stands. */
10
+ export const OFFSET = 48;
11
+
12
+ /** The window options a copy adds, given whether it is a throwaway one. */
13
+ export function newUserWindow({ fresh, name, screen, width = 1440, height = 900 } = {}) {
14
+ if (!fresh) return { title: name };
15
+ const title = `${name} (new user)`;
16
+ if (!screen) return { title };
17
+ const centred = {
18
+ x: Math.round(screen.x + (screen.width - width) / 2),
19
+ y: Math.round(screen.y + (screen.height - height) / 2),
20
+ };
21
+ const x = centred.x + OFFSET;
22
+ const y = centred.y + OFFSET;
23
+ if (x + width > screen.x + screen.width || y + height > screen.y + screen.height) return { title };
24
+ return { title, x, y };
25
+ }
package/main/notify.mjs CHANGED
@@ -52,10 +52,15 @@ export function markFor({ root, createFromPath }) {
52
52
  // with one sound instead of four rewrites and four chimes.
53
53
  const COALESCE_MS = 1500;
54
54
 
55
- export function createNotifier({ window, Notification, powerMonitor, icon = null, coalesceMs = COALESCE_MS }) {
55
+ // `onChange` hears that what it has told you about changed: the tray counts
56
+ // all of it (main/corner-tag.mjs), so a banner never speaks for a thread the
57
+ // tray leaves out.
58
+ export function createNotifier({ window, Notification, powerMonitor, icon = null, coalesceMs = COALESCE_MS, onChange = () => {} }) {
56
59
  // Everything she has not seen since she last had the window, keyed by id so
57
60
  // a repeated push is idempotent. Insertion order is arrival order.
58
61
  const unseen = new Map();
62
+ const toldAt = new Map();
63
+ const changed = () => { try { onChange(); } catch { /* the tray catches up on its next look */ } };
59
64
  let live = null; // the one notification currently on screen, if any
60
65
  let timer = null;
61
66
  // HOW MANY TIMES SHE HAS BEEN INTERRUPTED SINCE SHE LAST HAD THE WINDOW.
@@ -83,11 +88,14 @@ export function createNotifier({ window, Notification, powerMonitor, icon = null
83
88
  // dropped rather than banked: showing them later, when she steps away, would
84
89
  // be telling her about work she already watched arrive.
85
90
  const seen = () => {
91
+ const had = unseen.size > 0;
86
92
  unseen.clear();
93
+ toldAt.clear();
87
94
  if (timer) { clearTimeout(timer); timer = null; }
88
95
  spoke = false;
89
96
  spokeAsk = false;
90
97
  takeDown();
98
+ if (had) changed();
91
99
  };
92
100
 
93
101
  const speak = () => {
@@ -156,34 +164,42 @@ export function createNotifier({ window, Notification, powerMonitor, icon = null
156
164
  // Asked before banking anything, so that a burst arriving while she is
157
165
  // at the app is dropped rather than saved up for when she leaves.
158
166
  if (atTheApp({ focused: focused(), idleMs: idleMs() })) return seen();
167
+ let banked = false;
159
168
  for (const a of arrivals) {
160
169
  if (!a || !a.id || unseen.has(a.id)) continue;
161
170
  unseen.set(a.id, a);
171
+ toldAt.set(a.id, Date.now());
172
+ banked = true;
162
173
  }
174
+ if (banked) changed();
163
175
  if (!unseen.size || timer) return;
164
176
  timer = setTimeout(speak, coalesceMs);
165
177
  },
166
178
  // The window came forward. Whatever was on screen is now behind her.
167
179
  seen,
180
+ // What it has told you about, or is about to, since you were last at the
181
+ // app, and when each arrived.
182
+ told: () => [...toldAt].map(([id, at]) => ({ id, at })),
168
183
  // Tests, and nothing else.
169
184
  _pending: () => [...unseen.values()],
170
185
  _spoken: () => ({ spoke, spokeAsk }),
171
186
  };
172
187
  }
173
188
 
174
- // `onArrivals` hears every arrival too, for the phone's alerts
175
- // (main/phone-link.mjs), which make their own call about speaking.
176
- export function installNotifier({ app, window, Notification, powerMonitor, ipcMain, nativeImage, onArrivals = () => {} }) {
189
+ // `onArrivals` hears every arrival too, with the inbox they arrived in, for
190
+ // the phone's alerts (main/phone-alerts.mjs), which make their own call.
191
+ const needsYouOf = (payload) => (Array.isArray(payload.needsYou) ? payload.needsYou : []);
192
+ export function installNotifier({ app, window, Notification, powerMonitor, ipcMain, nativeImage, onArrivals = () => {}, onChange = () => {} }) {
177
193
  if (Notification.isSupported && !Notification.isSupported()) {
178
- ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : []); } catch {} });
179
- return { add() {}, seen() {} };
194
+ ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : [], needsYouOf(payload)); } catch {} });
195
+ return { add() {}, seen() {}, told: () => [] };
180
196
  }
181
197
  // app.getAppPath() is the repo from source and the asar inside the bundle
182
198
  // when it is packaged, and the mark sits at the same place in both.
183
199
  const icon = nativeImage
184
200
  ? markFor({ root: app.getAppPath(), createFromPath: (p) => nativeImage.createFromPath(p) })
185
201
  : null;
186
- const notifier = createNotifier({ window, Notification, powerMonitor, icon });
202
+ const notifier = createNotifier({ window, Notification, powerMonitor, icon, onChange });
187
203
  window.on('focus', () => notifier.seen());
188
204
  // Unlocking with Agentbox already the front app never fires 'focus' (it never
189
205
  // lost it), so without this the banner that fired while the screen was
@@ -191,7 +207,7 @@ export function installNotifier({ app, window, Notification, powerMonitor, ipcMa
191
207
  try { powerMonitor.on('unlock-screen', () => { if (window.isFocused()) notifier.seen(); }); } catch {}
192
208
  ipcMain.handle('zero:notify', (_e, payload = {}) => {
193
209
  const arrivals = Array.isArray(payload.arrivals) ? payload.arrivals : [];
194
- try { onArrivals(arrivals); } catch {}
210
+ try { onArrivals(arrivals, needsYouOf(payload)); } catch {}
195
211
  notifier.add(arrivals);
196
212
  });
197
213
  return notifier;
@@ -0,0 +1,117 @@
1
+ // ALERTS ON HER PHONE'S LOCK SCREEN, SENT BY THIS MAC.
2
+ //
3
+ // The arrivals are the desktop banner's (main/notify.mjs, from App.tsx's
4
+ // inbox), and so are the rules (shared/notify-rules.mjs): nothing while she
5
+ // has used this Mac in the last five minutes, once per stretch away, once
6
+ // more for a permission card. They go out from the phone pass, after it has
7
+ // published, so the thread an alert opens is already on the phone. The
8
+ // server-side sender in docs/mobile/PLAN.md, which alerts with every Mac
9
+ // shut, is a later phase.
10
+ //
11
+ // The words are the phone banner's (phoneBanner): the thread's title in bold,
12
+ // asked for on 2026-10-13 and again on 2026-10-23, and nothing else of it.
13
+ import { atTheComputer, shouldSpeak, phoneBanner } from '../shared/notify-rules.mjs';
14
+
15
+ export const EXPO_PUSH = 'https://exp.host/--/api/v2/push/send';
16
+ // Arrivals this close together are one alert.
17
+ export const FOLD_MS = 3000;
18
+ const DEADLINE_MS = 10_000;
19
+ const ALERTED_KEPT_MS = 30 * 24 * 3600_000;
20
+
21
+ // renderer/src/phone-rows.ts phoneSlug, which the phone opens a thread by.
22
+ const phoneSlugOf = (device, project) => `${project}@${device}`;
23
+ const stampOf = (item) => `${item.status ?? ''}:${item.wrote?.status?.ts ?? item.updatedAt ?? ''}`;
24
+
25
+ /** One request for every phone; Expo answers a ticket for each, in order. */
26
+ export async function sendToExpo(messages, { fetchImpl = fetch, url = EXPO_PUSH, deadlineMs = DEADLINE_MS } = {}) {
27
+ const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(messages), signal: AbortSignal.timeout(deadlineMs) });
28
+ const said = await res.json().catch(() => null);
29
+ if (!res.ok || !Array.isArray(said?.data)) throw new Error(`Expo answered ${res.status}${said?.errors?.[0]?.message ? `: ${said.errors[0].message}` : ''}`);
30
+ return said.data;
31
+ }
32
+
33
+ export function createPhoneAlerts({ backend, owner, device, state, save, now, log, idleMs, send = sendToExpo, still = () => {} }) {
34
+ state.alerted ??= {};
35
+ // In memory, as the desktop's banner keeps them: an arrival is news once.
36
+ const queued = new Map();
37
+ let firstAt = 0;
38
+ let needsYou = [];
39
+ let spoke = false;
40
+ let spokeAsk = false;
41
+ const said = new Set();
42
+ const once = (kind, line) => { if (!said.has(kind)) log(line); said.add(kind); };
43
+ const away = () => { try { return !atTheComputer({ idleMs: idleMs() }); } catch { return true; } };
44
+
45
+ function seen() {
46
+ queued.clear();
47
+ spoke = false;
48
+ spokeAsk = false;
49
+ }
50
+
51
+ /** What the desktop's notifier was told arrived, and the inbox it arrived in. */
52
+ function arrived(list, inbox = []) {
53
+ if (!Array.isArray(list) || !list.length) return;
54
+ if (!away()) return seen();
55
+ if (Array.isArray(inbox)) needsYou = inbox.filter((r) => r && typeof r.id === 'string' && typeof r.product === 'string');
56
+ for (const a of list) {
57
+ if (!a || typeof a.id !== 'string' || typeof a.product !== 'string') continue;
58
+ const ask = a.kind === 'approval';
59
+ const item = ask ? a.item : a.id;
60
+ if (typeof item !== 'string') continue;
61
+ if (!queued.size) firstAt = now();
62
+ queued.set(ask ? `ask:${a.id}` : `${a.product}:${a.id}`, { ask, product: a.product, item, title: a.title, thread: a.thread });
63
+ }
64
+ }
65
+
66
+ // `carried(slug, id)` is the thread's row when the phone has it, else null.
67
+ async function pass(carried) {
68
+ if (!away()) return seen();
69
+ if (!queued.size || now() - firstAt < FOLD_MS) return;
70
+ const fresh = [];
71
+ for (const [key, q] of queued) {
72
+ const row = carried(q.product, q.item);
73
+ if (!row) continue;
74
+ const stamp = q.ask ? 'asked' : stampOf(row);
75
+ if (state.alerted[key]?.stamp !== stamp) fresh.push({ key, stamp, ...q, named: row.title });
76
+ }
77
+ queued.clear();
78
+ if (!fresh.length || !shouldSpeak({ spoke, spokeAsk, waiting: fresh.map((f) => ({ kind: f.ask ? 'approval' : 'item' })) })) return;
79
+ still();
80
+ let phones;
81
+ try { phones = await backend.pushDevices(owner); } catch (err) { once('read', `phone: could not read the phones to alert: ${err?.message ?? err}`); return; }
82
+ if (!phones.length) return;
83
+ const threads = [...new Set(fresh.map((f) => `${f.product}\0${f.item}`))];
84
+ const [product, item] = threads[0].split('\0');
85
+ const badge = new Set(needsYou.filter((r) => carried(r.product, r.id)).map((r) => `${r.product}\0${r.id}`)).size;
86
+ const data = threads.length === 1 ? { product: phoneSlugOf(device, product), id: item } : {};
87
+ const say = phoneBanner(fresh.map((f) => (f.ask
88
+ ? { kind: 'approval', title: f.title, thread: f.thread || f.named }
89
+ : { kind: 'item', title: f.title || f.named })));
90
+ const messages = phones.map((p) => ({ to: p.token, title: say.title, body: say.body, sound: 'default', badge, priority: 'high', channelId: 'default', data }));
91
+ still();
92
+ let tickets;
93
+ try { tickets = await send(messages); } catch (err) { once('send', `phone: could not send an alert: ${err?.message ?? err}`); return; }
94
+ let reached = 0;
95
+ for (const [i, t] of (tickets ?? []).entries()) {
96
+ if (t?.status === 'ok') { reached += 1; continue; }
97
+ const why = t?.details?.error ?? t?.message ?? 'no answer';
98
+ if (why === 'DeviceNotRegistered') {
99
+ try { await backend.dropPushDevice(messages[i].to, owner); } catch (err) { once('drop', `phone: could not forget a phone that stopped taking alerts: ${err?.message ?? err}`); }
100
+ } else once(`ticket:${why}`, `phone: Expo did not take an alert: ${why}`);
101
+ }
102
+ if (!reached) return;
103
+ said.clear();
104
+ spoke = true;
105
+ if (fresh.some((f) => f.ask)) spokeAsk = true;
106
+ for (const f of fresh) state.alerted[f.key] = { stamp: f.stamp, at: now() };
107
+ for (const [k, v] of Object.entries(state.alerted)) if (!(now() - (v?.at ?? 0) < ALERTED_KEPT_MS)) delete state.alerted[k];
108
+ save();
109
+ }
110
+
111
+ return {
112
+ arrived,
113
+ pass,
114
+ /** She answered from her phone, so she has seen what it holds. */
115
+ phoneUsed: () => { spoke = false; spokeAsk = false; },
116
+ };
117
+ }