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
@@ -26,9 +26,11 @@
26
26
  // leftovers and no record at all is recorded as ended when this app, whose
27
27
  // store it is in, has no session for it; the clock starts then, never
28
28
  // back-dated.
29
- // 4. ITS PROGRAMS STOP `idleMs` (2 h) AFTER THE RUN ENDED, or `tightIdleMs`
30
- // (10 min) while memory is tight or critical, and never sooner than that
31
- // long after the switch went on.
29
+ // 4. ITS PROGRAMS STOP `closeGraceMs` (2 min) AFTER THE TASK IS CLOSED, so a
30
+ // Z that reopens it keeps them. While memory is tight or critical, also
31
+ // `tightIdleMs` (10 min) after the run ended, one task per look, the one
32
+ // whose run ended longest ago first. Never sooner than that long after
33
+ // the switch went on.
32
34
  // 5. NOTHING PROTECTED IS EVER STOPPED: AGENTBOX_KEEP=1 in its environment,
33
35
  // an executable inside an installed app or the system (Docker Desktop and
34
36
  // its VM, a browser an agent opened), an executable that cannot be read,
@@ -49,10 +51,10 @@
49
51
  // (main/supervisor.mjs) and everything it starts inherits it; all 40 leftovers
50
52
  // that morning carried it, Docker's backend included. The id is read only from
51
53
  // the ENVIRONMENT, which is the part of `ps -E` after the arguments `ps` prints
52
- // without it, and only when both looks agree it is the same process; a tag in a
53
- // command's own words counts for nothing. A program that renames itself
54
- // (next-server) hides its environment, so a process under a tagged one belongs
55
- // to the same task. Codex threads share one app-server and their commands carry
54
+ // without it, and only when both looks agree it is the same process;
55
+ // /proc/<pid>/environ on Linux. A tag in a command's own words counts for
56
+ // nothing. A program that renames itself (next-server) hides its environment
57
+ // from `ps`, so a process under a tagged one belongs to the same task. Codex threads share one app-server and their commands carry
56
58
  // no task id yet, so a Codex task's leftovers are not found: coverage is
57
59
  // incomplete and the switch says so.
58
60
 
@@ -151,8 +153,69 @@ export function applyExecutables(procs, lsofText) {
151
153
  return procs;
152
154
  }
153
155
 
154
- /** Every process on the Mac, tagged with its task where it can be. About 100 ms. */
155
- export async function listProcesses() {
156
+ /** /proc counts in USER_HZ, which the kernel fixes at 100 whatever its own tick is. */
157
+ const USER_HZ = 100;
158
+
159
+ /** When this computer booted, which /proc/<pid>/stat counts a start time from. */
160
+ function bootSeconds(root) {
161
+ const stat = fs.readFileSync(path.join(root, 'stat'), 'utf8').match(/^btime (\d+)$/m);
162
+ if (stat) return +stat[1];
163
+ const up = Number(String(fs.readFileSync(path.join(root, 'uptime'), 'utf8')).split(/\s+/)[0]);
164
+ return Math.floor(Date.now() / 1000 - up);
165
+ }
166
+
167
+ /**
168
+ * The same record the Mac's three `ps` listings give, read from /proc, because
169
+ * `-E` is BSD's and Linux's procps refuses it (w-bd1806285e).
170
+ *
171
+ * `exe` is the real executable even for a program that renamed itself, so
172
+ * nothing here needs lsof. A process that ends mid-read is left out; one
173
+ * belonging to somebody else keeps an empty executable and no environment,
174
+ * which is what leaves it protected.
175
+ */
176
+ export function readLinuxProcesses({ root = '/proc', hz = USER_HZ, btime = null } = {}) {
177
+ const boot = btime ?? bootSeconds(root);
178
+ const out = [];
179
+ for (const name of fs.readdirSync(root)) {
180
+ if (!/^\d+$/.test(name)) continue;
181
+ const dir = path.join(root, name);
182
+ let stat;
183
+ try { stat = fs.readFileSync(path.join(dir, 'stat'), 'utf8'); } catch { continue; }
184
+ const close = stat.lastIndexOf(')');
185
+ if (close < 0) continue;
186
+ const comm = stat.slice(stat.indexOf('(') + 1, close);
187
+ // The fields after the command, which is the only one that can hold spaces
188
+ // and brackets: state, the parent, and on to the start time.
189
+ const rest = stat.slice(close + 2).split(' ');
190
+ const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8'); } catch { return null; } };
191
+ const words = (text) => String(text ?? '').split('\0').filter(Boolean);
192
+ const env = new Map(words(read('environ')).map((s) => {
193
+ const eq = s.indexOf('=');
194
+ return eq < 0 ? [s, ''] : [s.slice(0, eq), s.slice(eq + 1)];
195
+ }));
196
+ const argv = words(read('cmdline')).join(' ');
197
+ const rss = (read('status') ?? '').match(/^VmRSS:\s+(\d+) kB$/m);
198
+ let exe = '';
199
+ try { exe = fs.readlinkSync(path.join(dir, 'exe')).replace(/ \(deleted\)$/, ''); } catch {}
200
+ const item = env.get('ZERO_ITEM');
201
+ out.push({
202
+ pid: +name,
203
+ ppid: +rest[1],
204
+ mb: rss ? +rss[1] / 1024 : 0,
205
+ start: boot + Math.floor(+rest[19] / hz),
206
+ exe,
207
+ cmd: (argv || `[${comm}]`).slice(0, 300),
208
+ item: /^w-[0-9a-f]+$/.test(item ?? '') ? item : null,
209
+ product: env.get('ZERO_PRODUCT')?.split(/\s/)[0] || null,
210
+ keep: env.get('AGENTBOX_KEEP') === '1',
211
+ });
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** Every process, tagged with its task where it can be: `ps` on a Mac (about 100 ms), /proc on Linux. */
217
+ export async function listProcesses(platform = process.platform) {
218
+ if (platform !== 'darwin') return readLinuxProcesses();
156
219
  const [base, argv, full] = await Promise.all([
157
220
  ps(['-axo', 'pid=,ppid=,rss=,lstart=,comm=']),
158
221
  ps(['-ww', '-axo', 'pid=,lstart=,command=']),
@@ -170,7 +233,7 @@ export async function listProcesses() {
170
233
  return procs;
171
234
  }
172
235
 
173
- async function readMacPressureWord() {
236
+ export async function readMacPressureWord() {
174
237
  const out = await new Promise((resolve) => {
175
238
  execFile('/usr/sbin/sysctl', ['-n', 'kern.memorystatus_vm_pressure_level', 'kern.memorystatus_level'], { timeout: 5_000 },
176
239
  (err, stdout) => resolve(err ? '' : stdout));
@@ -215,7 +278,9 @@ export class LeftoverCleaner {
215
278
  enabledSince = Date.now(),
216
279
  live = () => false,
217
280
  owns = () => false,
218
- idleMs = 2 * 60 * 60_000,
281
+ // When the task was closed, or null while it is open.
282
+ closedAt = () => null,
283
+ closeGraceMs = 2 * 60_000,
219
284
  tightIdleMs = 10 * 60_000,
220
285
  confirmMs = 60_000,
221
286
  settleMs = 30_000,
@@ -225,8 +290,9 @@ export class LeftoverCleaner {
225
290
  enabled = true,
226
291
  log = () => {},
227
292
  } = {}) {
228
- Object.assign(this, { list, kill, clock, readPressure, file, enabledSince, live, owns, idleMs, tightIdleMs, confirmMs, settleMs, home, enabled, log });
293
+ Object.assign(this, { list, kill, clock, readPressure, file, enabledSince, live, owns, closedAt, closeGraceMs, tightIdleMs, confirmMs, settleMs, home, enabled, log });
229
294
  this.firstSeen = new Map();
295
+ this.withPrograms = new Set();
230
296
  this.lastStatus = { leftovers: [], survivors: [], stopped: null, pressure: 'normal' };
231
297
  const saved = this._load();
232
298
  this.runs = saved.runs;
@@ -274,6 +340,9 @@ export class LeftoverCleaner {
274
340
 
275
341
  status() { return this.lastStatus; }
276
342
 
343
+ /** Whether the last look found programs of this task's that could be stopped. */
344
+ hasPrograms(item) { return this.withPrograms.has(item); }
345
+
277
346
  /** Stop signalling from this moment, even in a look already under way. */
278
347
  disable() { this.enabled = false; }
279
348
 
@@ -308,6 +377,7 @@ export class LeftoverCleaner {
308
377
  return this.lastStatus;
309
378
  }
310
379
  const view = this._view(procs, now);
380
+ this.withPrograms = new Set([...view.tasks.values()].filter((t) => t.procs.length).map((t) => t.item));
311
381
  this._reconcileRuns(view, now);
312
382
  this._trackStopping(view, now);
313
383
  const due = this._due(view, now, pressure);
@@ -407,31 +477,53 @@ export class LeftoverCleaner {
407
477
  }
408
478
  }
409
479
 
410
- _stopsAt(item, pressure) {
480
+ /** When this task's programs stop, or null while nothing says they should. */
481
+ _stopsAt(item, product, pressure) {
411
482
  const run = this.runs[item];
412
483
  if (!run || run.state !== 'ended') return null;
413
484
  const from = Math.max(run.endedAt, this.enabledSince);
414
- return from + (pressure === 'normal' ? this.idleMs : this.tightIdleMs);
485
+ let closed = null;
486
+ try { closed = this.closedAt(item, product); } catch { closed = null; }
487
+ const times = [];
488
+ if (Number.isFinite(closed)) times.push(Math.max(from, closed) + this.closeGraceMs);
489
+ if (pressure !== 'normal') times.push(from + this.tightIdleMs);
490
+ return times.length ? Math.min(...times) : null;
415
491
  }
416
492
 
417
- /** Programs whose time has come and that have been seen long enough. */
493
+ /**
494
+ * Programs whose time has come and that have been seen long enough: every
495
+ * closed task's, and while memory is short, one more task's, the one whose
496
+ * run ended longest ago.
497
+ */
418
498
  _due(view, now, pressure) {
419
499
  const due = [];
420
500
  if (!this.enabled) return due;
501
+ let short = null;
421
502
  for (const t of view.tasks.values()) {
422
503
  if (this._alive(t)) continue;
423
- const at = this._stopsAt(t.item, pressure);
504
+ const closed = this._stopsAt(t.item, t.product, 'normal');
505
+ if (closed !== null && now >= closed) { due.push(...this._ready(t, now)); continue; }
506
+ const at = pressure === 'normal' ? null : this._stopsAt(t.item, t.product, pressure);
424
507
  if (at === null || now < at) continue;
425
- for (const p of t.procs) {
426
- const id = idOf(p);
427
- if (this.stopping[id]) continue;
428
- if (now - (this.firstSeen.get(id) ?? now) < this.confirmMs) continue;
429
- due.push({ item: t.item, id, pid: p.pid });
430
- }
508
+ const ready = this._ready(t, now);
509
+ const ended = this.runs[t.item].endedAt;
510
+ if (ready.length && (!short || ended < short.ended)) short = { ended, ready };
431
511
  }
512
+ if (short) due.push(...short.ready);
432
513
  return due;
433
514
  }
434
515
 
516
+ _ready(t, now) {
517
+ const out = [];
518
+ for (const p of t.procs) {
519
+ const id = idOf(p);
520
+ if (this.stopping[id]) continue;
521
+ if (now - (this.firstSeen.get(id) ?? now) < this.confirmMs) continue;
522
+ out.push({ item: t.item, id, pid: p.pid });
523
+ }
524
+ return out;
525
+ }
526
+
435
527
  /** One fresh look, every rule again, then SIGTERM to what still passes. */
436
528
  async _stop(due, pressure) {
437
529
  let procs;
@@ -448,8 +540,8 @@ export class LeftoverCleaner {
448
540
  const t = view.tasks.get(d.item);
449
541
  if (!t || !t.procs.includes(p)) continue; // protected now, or no longer this task's
450
542
  if (this._alive(t)) continue;
451
- const at = this._stopsAt(d.item, pressure);
452
- if (at === null || now < at) continue; // a run started meanwhile
543
+ const at = this._stopsAt(d.item, t.product, pressure);
544
+ if (at === null || now < at) continue; // a run started, or the task reopened, meanwhile
453
545
  // Switched off while this look was under way: nothing more is signalled.
454
546
  if (!this.enabled) break;
455
547
  try { this.kill(p.pid, 'SIGTERM'); } catch { continue; }
@@ -477,7 +569,7 @@ export class LeftoverCleaner {
477
569
  kept: t.guarded.length,
478
570
  mb: all.reduce((n, p) => n + (p.mb ?? 0), 0),
479
571
  oldest: Math.min(...all.map((p) => p.start * 1000)),
480
- stopsAt: t.procs.length ? this._stopsAt(t.item, pressure) : null,
572
+ stopsAt: t.procs.length ? this._stopsAt(t.item, t.product, pressure) : null,
481
573
  });
482
574
  }
483
575
  return out.sort((a, b) => a.oldest - b.oldest);
@@ -0,0 +1,26 @@
1
+ // The Mac's own Liquid Glass for a window, or null where there is none: not a
2
+ // Mac, a macOS before 26, or a build that could not compile main/native
3
+ // (scripts/build-liquid-glass.mjs). Null means the page draws its own glass.
4
+ import { createRequire } from 'node:module';
5
+
6
+ export function loadLiquidGlass({ mac = process.platform === 'darwin', load = createRequire(import.meta.url) } = {}) {
7
+ if (!mac) return null;
8
+ let native;
9
+ try {
10
+ native = load('./native/liquid-glass/build/liquid-glass.node');
11
+ if (!native.available()) return null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ const handle = (win) => win.getNativeWindowHandle();
16
+ return {
17
+ // tone: 'light' or 'dark' holds the glass that way; leave it out and the
18
+ // glass decides from what is behind it. style: 'clear' for the Mac's clear
19
+ // glass, which lets a light page through; otherwise the regular glass.
20
+ set: (win, rect, { radius, tone, style } = {}) => native.set(handle(win), rect, {
21
+ radius, ...(tone ? { tone } : {}), ...(style ? { style } : {}),
22
+ }),
23
+ clear: (win) => native.clear(handle(win)),
24
+ tone: (win) => native.tone(handle(win)),
25
+ };
26
+ }
@@ -15,9 +15,11 @@ import { TURN_CLOSED } from './claude-input.mjs';
15
15
  // is the result, so without this it has every reason to save the answer for an
16
16
  // ending the person is not waiting through.
17
17
  //
18
- // Their words go LAST, as in `replyBrief`: the newest thing they said is the
19
- // newest thing in its context, and nothing of ours is written after it.
20
- export function liveReplyBrief(answer) {
18
+ // Their words go LAST: the newest thing they said is the newest thing in its
19
+ // context, and nothing of ours is written after it. `files` is the supervisor's
20
+ // `attachmentBlock` for this reply: the pictures in it by absolute path, which
21
+ // is also what a cloud run reads to send them up (main/cloud/run.mjs).
22
+ export function liveReplyBrief(answer, files = '') {
21
23
  return [
22
24
  'This arrived in your thread just now, while you work, and they are watching',
23
25
  'for a reply. Do what it asks. If it asks you anything, or says it did not',
@@ -25,6 +27,7 @@ export function liveReplyBrief(answer) {
25
27
  'you go back to what you were doing: the sentences you write while you work',
26
28
  'are what they read, so an answer saved for the end reads as no answer at all.',
27
29
  'Then carry on.',
30
+ ...(files ? [files] : []),
28
31
  '',
29
32
  'What they said:',
30
33
  '',
@@ -49,7 +52,10 @@ export function submitReply(supervisor, payload, commit) {
49
52
  // A held Remote Control conversation is sitting idle rather than working,
50
53
  // and the slash commands it takes stop being commands once prose is wrapped
51
54
  // round them. Both reach it exactly as typed.
52
- const taken = session.child.steer(session.remoteIdle ? answer : liveReplyBrief(answer));
55
+ const files = session.remoteIdle ? null : supervisor.replyFiles?.(product, answer, session.engine, id);
56
+ const taken = session.remoteIdle
57
+ ? session.child.steer(answer)
58
+ : session.child.steer(liveReplyBrief(answer, files?.block), files?.pictures ?? []);
53
59
  // SENT NOW: her words go in line first, then the step is cut, in the same
54
60
  // tick, so the cut turn's result can never close the input ahead of them.
55
61
  // A cut that fails leaves the message waiting its turn, which is still sent.
@@ -0,0 +1,330 @@
1
+ // MAIN GOES RED ON GITHUB AND ONE TASK SAYS WHICH TESTS (w-3836800002).
2
+ //
3
+ // Main was red on GitHub from 2026-10-09 to 2026-10-13 and nobody noticed. The
4
+ // `tests` workflow takes about five minutes, which is after the agent that
5
+ // shipped has already ended its turn, and `scripts/ship.mjs` signs off by
6
+ // printing "check the run" to a terminal nobody is reading. So the one gate
7
+ // that runs the WHOLE suite on both machines had no reader at all.
8
+ //
9
+ // The app ships the branch (main/ship-queue.mjs), so the app is the thing still
10
+ // running when that cross appears. After every successful ship it waits for
11
+ // that commit's run and files ONE task when main turns red: which tests, which
12
+ // machine each failed on, and the commit that did it.
13
+ //
14
+ // NOTHING BLOCKS ON THIS. No ship waits for it, no agent is held, and every
15
+ // failure here is silent: a machine without `gh`, or signed out of it, or
16
+ // without access to the repository, does nothing at all. A watcher that
17
+ // interrupted shipping to report on shipping would be worse than the gap it
18
+ // closes.
19
+ //
20
+ // ONE BREAKAGE IS ONE ROW. While main stays red, further ships file nothing:
21
+ // a row per red commit would be a dozen rows about one mistake. The green
22
+ // again lands on that same row as a note, so the row that told them reads as
23
+ // finished where they are already looking.
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+
29
+ /** How often to ask GitHub, and how long before giving up on a run. */
30
+ export const POLL_MS = 60 * 1000;
31
+ export const GIVE_UP_MS = 30 * 60 * 1000;
32
+
33
+ const STATE = 'main-tests.json';
34
+ const WORKFLOW = 'tests';
35
+ const LOG_BYTES = 16 * 1024 * 1024;
36
+
37
+ /** `gh`, with its own words back and never a throw. */
38
+ export function runGh(args, { cwd, timeout = 60 * 1000 } = {}) {
39
+ return new Promise((resolve) => {
40
+ execFile('gh', args, { cwd, encoding: 'utf8', timeout, maxBuffer: LOG_BYTES }, (error, stdout, stderr) => {
41
+ if (error) resolve({ code: error.code ?? 1, out: `${stdout ?? ''}${stderr ?? ''}${error.message ?? ''}` });
42
+ else resolve({ code: 0, out: String(stdout ?? '') });
43
+ });
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The run for the commit that was just shipped, or null while GitHub has not
49
+ * made one yet.
50
+ *
51
+ * `scripts/ship.mjs` reports the SHORT sha and GitHub answers with the full
52
+ * forty, so either may be the prefix of the other.
53
+ */
54
+ export function runFor(runs, sha) {
55
+ const want = String(sha ?? '').trim().toLowerCase();
56
+ if (!want) return null;
57
+ return (runs ?? []).find((run) => {
58
+ const head = String(run?.headSha ?? '').toLowerCase();
59
+ return head && (head.startsWith(want) || want.startsWith(head));
60
+ }) ?? null;
61
+ }
62
+
63
+ /**
64
+ * What that run says: `waiting`, `passing`, `failing`, or `ignore`.
65
+ *
66
+ * `cancelled` is IGNORED because a second push to main cancels the first
67
+ * (the `concurrency` block in .github/workflows/tests.yml), so a cancelled run
68
+ * is not a verdict on anything; the newer push's own watch carries it.
69
+ * `startup_failure` is ignored because it is a GitHub ACCOUNT problem rather
70
+ * than a test: for three weeks every run here ended that way, and the answer
71
+ * was a payment method, not a commit (the head of that workflow file says so).
72
+ * A row per push during a spell like that is noise with no fix in it.
73
+ */
74
+ export function verdict(run) {
75
+ if (!run || run.status !== 'completed') return 'waiting';
76
+ const how = String(run.conclusion ?? '');
77
+ if (how === 'success') return 'passing';
78
+ if (how === 'failure' || how === 'timed_out') return 'failing';
79
+ return 'ignore';
80
+ }
81
+
82
+ /** Which machine a job ran on, by the label the workflow asked for. */
83
+ export function machineOf(job) {
84
+ const labels = (job?.labels ?? []).join(' ').toLowerCase();
85
+ const name = String(job?.name ?? '');
86
+ if (/ubuntu|linux/.test(`${labels} ${name}`)) return 'Linux';
87
+ if (/macos/.test(`${labels} ${name}`)) return 'macOS';
88
+ if (/windows/.test(`${labels} ${name}`)) return 'Windows';
89
+ return name || 'that machine';
90
+ }
91
+
92
+ const ANSI = /\u001b\[[0-9;]*m/g;
93
+ // A job log is one timestamped line per line of output, and GitHub's own
94
+ // lines carry a kind in front of them.
95
+ const STAMP = /^\S+Z\s/;
96
+ const GITHUB_SAYS = /^##\[[a-z]+\]/;
97
+ const clean = (raw) => String(raw).replace(ANSI, '').replace(STAMP, '').replace(GITHUB_SAYS, '').trim();
98
+ // Vitest's own `FAIL tests/x.test.mjs > suite > case`, and ONLY at the start
99
+ // of the line. A LINE VITEST QUOTED IS NOT THE RUNNER SPEAKING: the ship queue
100
+ // learned that three times over (w-560647d4db). A failing text assertion
101
+ // prints the file's own source as a diff (`+`/`-`) and as a code frame
102
+ // (`34| ...`), and this repo's test files are sentences, so plenty of them
103
+ // carry "failed" or "FAIL" inside a name. Anchoring at FAIL rules all of that
104
+ // out, and the tick vitest puts on a file it ran green rules out the rest.
105
+ const NAMES_A_RED_TEST = /^FAIL\s+(\S+?\.(?:test|spec)\.[a-z]+)(?:\s|$)/;
106
+
107
+ /** Every test file the runner printed a FAIL for, once each, in order. */
108
+ export function failedTestsIn(log) {
109
+ const found = [];
110
+ for (const raw of String(log ?? '').split('\n')) {
111
+ const name = NAMES_A_RED_TEST.exec(clean(raw))?.[1];
112
+ if (name && !found.includes(name)) found.push(name);
113
+ }
114
+ return found;
115
+ }
116
+
117
+ // VITEST EXITS 1 FOR THINGS THAT ARE NOT A FAILING TEST, and none of them
118
+ // print FAIL: an error thrown after a file's tests have passed, a collection
119
+ // that never started, a worker that died. The row used to stop at "names no
120
+ // failing test" with the log it had already fetched left unread.
121
+ //
122
+ // A banner is matched along with the rule vitest draws it in, because a line
123
+ // vitest QUOTED is not the runner speaking. The plural heading over the whole
124
+ // section is not one of these, and the three lines of advice are about
125
+ // unhandled errors in general rather than about this run.
126
+ const UNHANDLED = /^⎯+\s*Unhandled (?:Error|Rejection)\s*⎯*$/;
127
+ const NAMES_A_BREAKAGE = /^(?:No test files found|FATAL ERROR:)/;
128
+ const ADVICE = /^(?:Vitest caught|This might cause|Serialized Error)/;
129
+ const SAID_AT_MOST = 5;
130
+
131
+ /** What the runner itself said, for a row that can name no failing test. */
132
+ export function whatTheRunnerSaid(log) {
133
+ const lines = String(log ?? '').split('\n').map(clean);
134
+ const said = [];
135
+ const keep = (line) => {
136
+ if (line && !ADVICE.test(line) && !said.includes(line)) said.push(line);
137
+ };
138
+ lines.forEach((line, i) => {
139
+ if (UNHANDLED.test(line)) keep(lines.slice(i + 1, i + 8).find((l) => l && !ADVICE.test(l)));
140
+ else if (NAMES_A_BREAKAGE.test(line)) keep(line);
141
+ });
142
+ return said.slice(0, SAID_AT_MOST);
143
+ }
144
+
145
+ const shortSha = (sha) => String(sha ?? '').slice(0, 7);
146
+ const NOTHING_NAMED = 'nothing in its log names a failing test';
147
+ // A log `gh` could not hand over says nothing about the run, and a row that
148
+ // reported it as "names no failing test" would be making that up.
149
+ const NOT_READ = 'its log could not be read from here';
150
+ const unread = (machines) => machines.length > 0 && machines.every((m) => m.read === false);
151
+ /** The first thing any machine said, cut to something a title can hold. */
152
+ const firstWords = (machines, max) => {
153
+ const said = machines.flatMap((m) => m.said ?? []).find(Boolean);
154
+ if (!said) return '';
155
+ return said.length > max ? `${said.slice(0, max - 1).trimEnd()}…` : said;
156
+ };
157
+
158
+ /** The row's title: the one test, or the count and the machines. */
159
+ export function redTitle(machines) {
160
+ const names = [...new Set(machines.flatMap((m) => m.tests))];
161
+ if (names.length === 1) return `main went red on GitHub: ${names[0]}`;
162
+ if (!names.length) {
163
+ const said = firstWords(machines, 58);
164
+ if (said) return `main went red on GitHub: ${said}`;
165
+ if (unread(machines)) return `main went red on GitHub, and ${NOT_READ}`;
166
+ return 'main went red on GitHub, and its log names no failing test';
167
+ }
168
+ const where = machines.filter((m) => m.tests.length).map((m) => m.machine);
169
+ return `main went red on GitHub: ${names.length} tests, on ${where.join(' and ')}`;
170
+ }
171
+
172
+ /** The row's Context: what a reader gets before they open anything. */
173
+ export function redContext({ sha, machines }) {
174
+ const names = [...new Set(machines.flatMap((m) => m.tests))];
175
+ const where = machines.filter((m) => m.tests.length).map((m) => m.machine);
176
+ if (!names.length) {
177
+ const said = firstWords(machines, 110);
178
+ let how = NOTHING_NAMED;
179
+ if (said) how = `no test failed and the runner said "${said}"`;
180
+ else if (unread(machines)) how = NOT_READ;
181
+ return `main's tests run went red on GitHub at ${shortSha(sha)}, and ${how}. The run itself is in the task.`;
182
+ }
183
+ return [
184
+ `main's tests run went red on GitHub at ${shortSha(sha)}, on ${where.join(' and ')}.`,
185
+ `${names.length === 1 ? 'The failing test' : `The ${names.length} failing tests`}: ${names.join(', ')}.`,
186
+ 'Which machine each failed on, and the run, are in the task.',
187
+ ].join(' ');
188
+ }
189
+
190
+ /** The whole row: what failed, where, and the commit that did it. */
191
+ export function redRow({ sha, subject, url, machines }) {
192
+ return {
193
+ title: redTitle(machines),
194
+ problem: redContext({ sha, machines }),
195
+ body: [
196
+ "main's `tests` run went red on GitHub, at this commit:",
197
+ '',
198
+ ` ${shortSha(sha)} ${subject || 'no title'}`,
199
+ '',
200
+ ...machines.flatMap(({ machine, tests, said, read }) => [
201
+ `${machine}:`,
202
+ ...(tests.length ? tests.map((t) => `- ${t}`) : [
203
+ `- ${read === false ? NOT_READ : NOTHING_NAMED}${said?.length ? ', and the runner said:' : ''}`,
204
+ ...(said ?? []).map((line) => ` ${line}`),
205
+ ]),
206
+ '',
207
+ ]),
208
+ ...(url ? [`The whole run: ${url}`, ''] : []),
209
+ 'That run finishes about five minutes after the agent that shipped has',
210
+ 'already ended, so nobody is watching it: main was red here from',
211
+ '2026-10-09 to 2026-10-13 and nothing said so. This is the only row filed',
212
+ 'while main stays red, and a note lands here when it goes green again.',
213
+ ].join('\n'),
214
+ };
215
+ }
216
+
217
+ /** What that row hears when main comes back. */
218
+ export function greenAgain({ sha, subject }) {
219
+ return `main's tests are green again on GitHub, as of ${shortSha(sha)}${subject ? ` (${subject})` : ''}.`;
220
+ }
221
+
222
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); });
223
+
224
+ /**
225
+ * The watch itself: one per successful ship, and never anything a person waits
226
+ * on.
227
+ *
228
+ * WHETHER MAIN IS KNOWN RED LIVES IN THE APP'S OWN DATA FOLDER, beside the
229
+ * ship settings, because it is a fact about this Mac's watching rather than
230
+ * about anyone's work. With no file, main counts as fine, so the FIRST red run
231
+ * this ever sees files a row: requiring a green first would have kept it silent
232
+ * through exactly the four days that put this here.
233
+ */
234
+ export class MainTests {
235
+ constructor({ store, userDir, gh = runGh, wait = sleep, now = Date.now, log = console } = {}) {
236
+ Object.assign(this, { store, userDir, gh, wait, now, log });
237
+ this.stopped = false;
238
+ }
239
+
240
+ stop() { this.stopped = true; }
241
+
242
+ stateFile() { return path.join(this.userDir, STATE); }
243
+
244
+ read() {
245
+ try {
246
+ const raw = JSON.parse(fs.readFileSync(this.stateFile(), 'utf8'));
247
+ return raw && typeof raw === 'object' ? raw : {};
248
+ } catch { return {}; }
249
+ }
250
+
251
+ write(state) {
252
+ try {
253
+ const tmp = `${this.stateFile()}.${process.pid}.tmp`;
254
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
255
+ fs.renameSync(tmp, this.stateFile());
256
+ } catch (e) { this.log.warn?.(`main-tests: could not record where main stands: ${e.message}`); }
257
+ }
258
+
259
+ /** gh's JSON, or null if this machine cannot ask. */
260
+ async ask(args, cwd) {
261
+ const { code, out } = await this.gh(args, { cwd });
262
+ if (code !== 0) return null;
263
+ try { return JSON.parse(out); } catch { return null; }
264
+ }
265
+
266
+ async watch({ product, sha } = {}) {
267
+ const cwd = product?.repoPath;
268
+ if (!cwd || !sha || !fs.existsSync(cwd)) return null;
269
+ const until = this.now() + GIVE_UP_MS;
270
+ while (!this.stopped) {
271
+ const runs = await this.ask(
272
+ ['run', 'list', '--branch', 'main', '--workflow', WORKFLOW, '-L', '20',
273
+ '--json', 'databaseId,headSha,status,conclusion,displayTitle,url'],
274
+ cwd,
275
+ );
276
+ // No gh, signed out, or no access to this repository: nothing, quietly.
277
+ if (!runs) return null;
278
+ const run = runFor(runs, sha);
279
+ const says = verdict(run);
280
+ if (says === 'ignore') return null;
281
+ if (says === 'passing') return this.wentGreen(product, run);
282
+ if (says === 'failing') return this.wentRed(product, run, cwd);
283
+ if (this.now() >= until) {
284
+ this.log.info?.(`main-tests: ${shortSha(sha)}'s run has not finished in half an hour; leaving it.`);
285
+ return null;
286
+ }
287
+ await this.wait(POLL_MS);
288
+ }
289
+ return null;
290
+ }
291
+
292
+ /** One row, and only while main was not already known red. */
293
+ async wentRed(product, run, cwd) {
294
+ const state = this.read();
295
+ if (state.red) return null;
296
+ const jobs = (await this.ask(['api', `repos/{owner}/{repo}/actions/runs/${run.databaseId}/jobs`, '--paginate'], cwd))?.jobs ?? [];
297
+ const machines = [];
298
+ for (const job of jobs) {
299
+ if (String(job?.conclusion ?? '') !== 'failure') continue;
300
+ const { code, out } = await this.gh(['api', `repos/{owner}/{repo}/actions/jobs/${job.id}/logs`], { cwd });
301
+ const read = code === 0;
302
+ const tests = read ? failedTestsIn(out) : [];
303
+ machines.push({ machine: machineOf(job), tests, read, said: read && !tests.length ? whatTheRunnerSaid(out) : [] });
304
+ }
305
+ const row = redRow({ sha: run.headSha, subject: run.displayTitle, url: run.url, machines });
306
+ let filed = null;
307
+ try {
308
+ filed = this.store.fileItem(product.slug, { ...row, kind: 'task' });
309
+ } catch (e) {
310
+ this.log.warn?.(`main-tests: could not file the red main: ${e.message}`);
311
+ return null;
312
+ }
313
+ this.write({ ...state, red: { product: product.slug, id: filed.id, sha: run.headSha } });
314
+ this.log.info?.(`main-tests: main went red at ${shortSha(run.headSha)}; filed ${filed.id}.`);
315
+ return filed;
316
+ }
317
+
318
+ /** The green again, on the row that reported the red. */
319
+ wentGreen(product, run) {
320
+ const state = this.read();
321
+ if (!state.red) return null;
322
+ try {
323
+ this.store.noteItem(state.red.product, state.red.id, greenAgain({ sha: run.headSha, subject: run.displayTitle }));
324
+ } catch (e) {
325
+ this.log.warn?.(`main-tests: could not say main is green again: ${e.message}`);
326
+ }
327
+ this.write({ ...state, red: null });
328
+ return null;
329
+ }
330
+ }