agentbox-app 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -1,9 +1,24 @@
1
1
  // Finder launches inherit launchd's PATH rather than the user's shell tools.
2
- // Read only PATH: importing a profile's whole environment would import keys
3
- // and login overrides too. Workers still pass through their billing scrub.
2
+ // Read only named variables: importing a profile's whole environment would
3
+ // import keys and login overrides too. PATH, the two folders below, and the
4
+ // network and locale names under NETWORK and LOCALE are all that is ever read,
5
+ // and workers still pass through their billing scrub.
4
6
  import { execFile } from 'node:child_process';
7
+ import fs from 'node:fs';
5
8
  import path from 'node:path';
6
9
 
10
+ // The shell a person actually has. `$SHELL` when that file exists, then zsh,
11
+ // bash, and sh. A machine with no zsh used to spawn `/bin/zsh`: the terminal
12
+ // never came up, and the self-update never built.
13
+ export function loginShell(env = process.env, exists = fs.existsSync) {
14
+ const preferred = env.SHELL;
15
+ if (preferred && exists(preferred)) return preferred;
16
+ for (const candidate of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
17
+ if (exists(candidate)) return candidate;
18
+ }
19
+ return preferred || '/bin/sh';
20
+ }
21
+
7
22
  const MARKER = '\0agentbox-path\0';
8
23
  function runShell(file, args, options) {
9
24
  return new Promise((resolve, reject) => {
@@ -12,6 +27,52 @@ function runShell(file, args, options) {
12
27
  });
13
28
  }
14
29
 
30
+ /**
31
+ * WHICH SHELLS ARE ASKED, IN ORDER, AND WHY THERE IS MORE THAN ONE (2026-10-15).
32
+ *
33
+ * Their own, interactive and login, first: that is the one that reads .zshrc,
34
+ * where nvm and most PATH lines live.
35
+ *
36
+ * Then their own again as a login shell that is NOT interactive, which reads
37
+ * .zprofile and skips .zshrc. Measured with the real /bin/zsh: a .zshrc that
38
+ * runs `[ -z "$TMUX" ] && exec tmux` (tmux exits 1 with no terminal) or
39
+ * `exec fish` (exits 0 having read nothing) ended the shell before our
40
+ * printf, and the app fell back to launchd's four folders, losing the
41
+ * Homebrew line that .zprofile carries. Asking again without -i keeps it. A
42
+ * .zshrc slower than five seconds lands here too.
43
+ *
44
+ * Last, the system's own shell, when theirs is something else: a shell that
45
+ * cannot speak `printf ... "$PATH"` (nushell hands back the words `$PATH`)
46
+ * is a fact about that shell, not about where their tools are. The program
47
+ * finder (main/engine-bin.mjs shellProbes) falls back the same way.
48
+ */
49
+ export function loginShellAttempts(env = process.env, platform = process.platform) {
50
+ const system = platform === 'darwin' ? '/bin/zsh' : '/bin/bash';
51
+ const theirs = env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh');
52
+ const out = [[theirs, '-ilc', 5000], [theirs, '-lc', 2500]];
53
+ if (system !== theirs) out.push([system, '-ilc', 2500]);
54
+ return out;
55
+ }
56
+
57
+ /** The first attempt whose output `read` accepts; the first failure otherwise. */
58
+ async function askLoginShell({ env, platform, run, script, read }) {
59
+ let first = null;
60
+ for (const [shell, flags, timeout] of loginShellAttempts(env, platform)) {
61
+ try {
62
+ const stdout = String(await run(shell, [flags, script(shell)], {
63
+ // SIGKILL: a shell's exit hook can outlive SIGTERM (main/engine-bin.mjs).
64
+ env: { ...env }, encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 64 * 1024,
65
+ }));
66
+ return read(stdout);
67
+ } catch (error) {
68
+ first ??= error;
69
+ }
70
+ }
71
+ throw first;
72
+ }
73
+
74
+ const isFish = (shell) => path.basename(shell) === 'fish';
75
+
15
76
  /**
16
77
  * WHERE THE SHELL SAYS CLAUDE CODE AND CODEX KEEP THEIR SESSIONS (w-e46f2e9cea).
17
78
  *
@@ -26,59 +87,146 @@ function runShell(file, args, options) {
26
87
  * Only an absolute path is kept. A quoted `~` the shell did not expand is
27
88
  * expanded here; anything else, or no answer at all, is left out. */
28
89
  const DIRS_MARKER = '\0agentbox-dirs\0';
29
- export async function readShellDataDirs({ env = process.env, platform = process.platform, run = runShell } = {}) {
30
- if (platform !== 'darwin' && platform !== 'linux') return {};
31
- const shell = env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh');
32
- try {
33
- const stdout = String(await run(shell, ['-ilc', `printf '\\0agentbox-dirs\\0%s\\0%s\\0' "$CLAUDE_CONFIG_DIR" "$CODEX_HOME"`], {
34
- env: { ...env }, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024,
35
- }));
90
+ const DIRS = ['CLAUDE_CONFIG_DIR', 'CODEX_HOME'];
91
+
92
+ /**
93
+ * HOW THIS MACHINE REACHES THE NETWORK, WHEN ONLY THE SHELL PROFILE SAYS (2026-10-15).
94
+ *
95
+ * Behind a company proxy, people export HTTPS_PROXY and the company's
96
+ * certificate (NODE_EXTRA_CA_CERTS) in .zshrc, which is where Claude Code's
97
+ * own error tells them to. A Dock launch never sees it. Measured with the real
98
+ * Claude Code 2.1.301 against a stub model behind a CONNECT proxy and a
99
+ * self-signed certificate: with both variables it answered in 240 ms; with no
100
+ * proxy variable it retried for minutes (10 retries, about three) and failed;
101
+ * with the proxy and no certificate it died in 371 ms on "Self-signed
102
+ * certificate detected". So a worker started by the app failed on exactly the
103
+ * machine where the person's terminal worked.
104
+ *
105
+ * None of these is a key or a login, so the billing scrub has nothing to say
106
+ * about them, and they go into the app's own environment where every worker,
107
+ * terminal and git call inherits them. A name the launch environment already
108
+ * has is left alone, and the proxies are one setting: if any proxy name is
109
+ * already set, none is added.
110
+ */
111
+ const PROXIES = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy'];
112
+ const CERTIFICATES = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE'];
113
+
114
+ /**
115
+ * THE LANGUAGE SETTING A DOCK LAUNCH DOES NOT HAVE (2026-10-15).
116
+ *
117
+ * launchd gives an app no LANG and no LC_*: read off a running Dock-launched
118
+ * Electron app with `ps eww`, and off a bare Electron started with launchd's
119
+ * environment (process.env.LANG undefined, and none in its children).
120
+ * Terminal.app sets LANG itself, so the person's own commands always had one
121
+ * and their agents never did. Measured on this Mac with LANG unset: system
122
+ * Ruby reads files as US-ASCII (what CocoaPods and fastlane refuse to run
123
+ * under), `sed 's/./X/g'` turns "café" into five X's, `wc -m` counts five.
124
+ *
125
+ * So the shell's own, when it has one. On macOS 26 that is usually Apple's:
126
+ * /etc/zprofile exports LANG=C.UTF-8 when nothing else has, so a zsh login
127
+ * answers C.UTF-8 (measured on this Mac), which fixes both of the above and
128
+ * keeps every tool's messages in the English the app reads. Otherwise, on a
129
+ * Mac only, en_US.UTF-8, which exists on every macOS and is what Terminal
130
+ * sets for most people. Linux sessions set their own.
131
+ */
132
+ const LOCALE = ['LANG', 'LC_ALL', 'LC_CTYPE'];
133
+ const MAC_LOCALE = 'en_US.UTF-8';
134
+
135
+ const SETTINGS = [...DIRS, ...PROXIES, ...CERTIFICATES, ...LOCALE];
136
+
137
+ /** Everything above from one login shell, as { dirs, network, locale }. */
138
+ export async function readShellSettings({ env = process.env, platform = process.platform, run = runShell } = {}) {
139
+ const none = { dirs: {}, network: {}, locale: {} };
140
+ if (platform !== 'darwin' && platform !== 'linux') return none;
141
+ const script = () => `printf '\\0agentbox-dirs\\0${'%s\\0'.repeat(SETTINGS.length)}' ${SETTINGS.map((n) => `"$${n}"`).join(' ')}`;
142
+ const read = (stdout) => {
36
143
  const start = stdout.lastIndexOf(DIRS_MARKER);
37
- if (start < 0) return {};
38
- const [claude = '', codex = ''] = stdout.slice(start + DIRS_MARKER.length).split('\0');
39
- const out = {};
40
- const keep = (name, raw) => {
41
- let v = String(raw ?? '').trim();
42
- if (!v || /[\r\n]/.test(v)) return;
43
- if (v === '~' || v.startsWith('~/')) {
44
- if (!env.HOME) return;
45
- v = path.join(env.HOME, v.slice(1));
46
- }
47
- if (path.isAbsolute(v)) out[name] = v;
48
- };
49
- keep('CLAUDE_CONFIG_DIR', claude);
50
- keep('CODEX_HOME', codex);
51
- return out;
144
+ if (start < 0) throw Error('Your shell did not answer.');
145
+ return stdout.slice(start + DIRS_MARKER.length).split('\0');
146
+ };
147
+ let values;
148
+ try {
149
+ values = await askLoginShell({ env, platform, run, script, read });
52
150
  } catch {
53
- return {};
151
+ return none;
54
152
  }
153
+ const raw = Object.fromEntries(SETTINGS.map((name, i) => [name, String(values[i] ?? '').trim()]));
154
+ const single = (v) => v && !/[\r\n]/.test(v);
155
+ const folder = (v) => {
156
+ if (!single(v)) return null;
157
+ if (v === '~' || v.startsWith('~/')) {
158
+ if (!env.HOME) return null;
159
+ v = path.join(env.HOME, v.slice(1));
160
+ }
161
+ return path.isAbsolute(v) ? v : null;
162
+ };
163
+ const out = { dirs: {}, network: {}, locale: {} };
164
+ for (const name of DIRS) if (folder(raw[name])) out.dirs[name] = folder(raw[name]);
165
+ for (const name of PROXIES) if (single(raw[name])) out.network[name] = raw[name];
166
+ for (const name of CERTIFICATES) if (folder(raw[name])) out.network[name] = folder(raw[name]);
167
+ for (const name of LOCALE) if (single(raw[name]) && /^[A-Za-z0-9_.@-]+$/.test(raw[name])) out.locale[name] = raw[name];
168
+ return out;
169
+ }
170
+
171
+ export async function readShellDataDirs(opts = {}) {
172
+ return (await readShellSettings(opts)).dirs;
173
+ }
174
+
175
+ /** What to add to a launch environment, given what the shell said. Never overwrites. */
176
+ export function launchAdditions({ network = {}, locale = {} } = {}, env = process.env, platform = process.platform) {
177
+ const set = (name) => env[name] !== undefined && env[name] !== '';
178
+ const add = {};
179
+ if (!PROXIES.some(set)) for (const name of PROXIES) if (network[name]) add[name] = network[name];
180
+ for (const name of CERTIFICATES) if (network[name] && !set(name)) add[name] = network[name];
181
+ if (!LOCALE.some(set)) {
182
+ for (const name of LOCALE) if (locale[name]) add[name] = locale[name];
183
+ if (!LOCALE.some((n) => add[n]) && platform === 'darwin') add.LANG = MAC_LOCALE;
184
+ }
185
+ return add;
55
186
  }
56
187
 
57
188
  /**
58
189
  * What the shell answered, kept for the life of the app. Empty until it
59
- * answers, which is long before setup reaches its last card. */
190
+ * answers, which is long before setup reaches its last card.
191
+ *
192
+ * AND THE NETWORK AND LANGUAGE SETTINGS ARE PUT INTO `into` (the app's own
193
+ * environment) here, because this is the one shell read that both the desktop
194
+ * (main/main.mjs) and the browser copy (bin/agentbox.mjs) wait for before the
195
+ * first worker starts. */
60
196
  let shellDataEnv = {};
61
197
  export const shellDataDirsNow = () => shellDataEnv;
62
- export function learnShellDataDirs(opts) {
63
- return readShellDataDirs(opts).then((found) => { shellDataEnv = found; return found; });
198
+ export function learnShellDataDirs({ into = process.env, ...opts } = {}) {
199
+ return readShellSettings(opts).then((found) => {
200
+ shellDataEnv = found.dirs;
201
+ Object.assign(into, launchAdditions(found, opts.env ?? process.env, opts.platform ?? process.platform));
202
+ return found.dirs;
203
+ });
64
204
  }
65
205
 
66
206
  export async function recoverShellPath({ env = process.env, platform = process.platform, run = runShell } = {}) {
67
207
  const original = env.PATH || '';
68
- if (platform !== 'darwin') return { path: original, error: null };
69
- const shell = env.SHELL || '/bin/zsh';
70
- const value = path.basename(shell) === 'fish' ? '(string join : $PATH)' : '"$PATH"';
71
- try {
208
+ // LINUX TOO (2026-10-15). A launcher entry starts the app with the desktop
209
+ // session's environment, and that session read ~/.profile without -i, so
210
+ // the nvm lines its installer writes into ~/.bashrc (behind Ubuntu's "if not
211
+ // running interactively, return") never reached it. readShellDataDirs above
212
+ // already asked on Linux for the same reason.
213
+ if (platform !== 'darwin' && platform !== 'linux') return { path: original, error: null };
214
+ const script = (shell) => `printf '\\0agentbox-path\\0%s\\0' ${isFish(shell) ? '(string join : $PATH)' : '"$PATH"'}`;
215
+ const read = (stdout) => {
72
216
  // NUL framing keeps messages printed by .zshrc out of the returned PATH.
73
- const stdout = String(await run(shell, ['-ilc', `printf '\\0agentbox-path\\0%s\\0' ${value}`], {
74
- env: { ...env }, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024,
75
- }));
76
217
  const start = stdout.lastIndexOf(MARKER);
77
218
  const end = start < 0 ? -1 : stdout.indexOf('\0', start + MARKER.length);
78
219
  const recovered = end < 0 ? '' : stdout.slice(start + MARKER.length, end);
79
220
  if (!recovered || /[\r\n]/.test(recovered)) throw Error('Your shell did not return its command search path.');
80
- const entries = [...new Set([...recovered.split(':'), ...original.split(':')].filter(Boolean))];
81
- if (!entries.length) throw Error('Your shell returned an empty command search path.');
221
+ // Only absolute folders. A shell that does not expand "$PATH" hands the
222
+ // words back, and a relative entry is a folder that moves with every cwd.
223
+ const entries = recovered.split(':').filter((p) => p.startsWith('/'));
224
+ if (!entries.length) throw Error('Your shell did not return its command search path.');
225
+ return entries;
226
+ };
227
+ try {
228
+ const recovered = await askLoginShell({ env, platform, run, script, read });
229
+ const entries = [...new Set([...recovered, ...original.split(':')].filter(Boolean))];
82
230
  return { path: entries.join(':'), error: null };
83
231
  } catch (error) {
84
232
  return { path: original, error: String(error?.message || error) };
@@ -0,0 +1,119 @@
1
+ // What the other side of a merge conflict was for, so the thread that hit it
2
+ // can resolve it without asking the person. Read-only git: nothing here
3
+ // touches a working tree.
4
+
5
+ import { execFile } from 'node:child_process';
6
+
7
+ const GIT_MS = 10_000;
8
+ const MAX_COMMITS = 8;
9
+ const SUMMARY_CHARS = 240;
10
+
11
+ export const KEEP_BOTH = 'Resolve it keeping both changes where they fit. Only ask the person if the two changes want opposite things, and then say which you kept and why.';
12
+
13
+ const CONFLICT_LINE = /^CONFLICT \([^)]*\): .*$/m;
14
+ // Main's own merges from `npm run ship`: "Merge origin/main into agentbox/<id>".
15
+ const SHIPPED_FROM = /\binto agentbox\/([A-Za-z0-9_-]+)\s*$/;
16
+
17
+ export const isMergeConflict = (output) => CONFLICT_LINE.test(String(output ?? ''));
18
+
19
+ /** The files the script's own output says conflicted. */
20
+ export function conflictedFilesIn(output) {
21
+ const files = new Set();
22
+ for (const line of String(output ?? '').split('\n')) {
23
+ const m = line.match(/^CONFLICT \([^)]*\): (?:Merge conflict in (.+)|(\S+) deleted in )/);
24
+ const file = m && (m[1] ?? m[2]);
25
+ if (file) files.add(file.trim());
26
+ }
27
+ return [...files];
28
+ }
29
+
30
+ function git(cwd, args) {
31
+ return new Promise((resolve) => {
32
+ execFile('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: GIT_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
33
+ resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, out: String(stdout ?? '') });
34
+ });
35
+ });
36
+ }
37
+
38
+ /** Files that would conflict merging `upstream` into `head`, or null if git cannot say. */
39
+ export async function filesThatConflict(cwd, head, upstream) {
40
+ const r = await git(cwd, ['merge-tree', '--write-tree', '--name-only', '--no-messages', head, upstream]);
41
+ if (r.code !== 0 && r.code !== 1) return null;
42
+ return r.out.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
43
+ }
44
+
45
+ // The thread whose ship brought `sha` onto main: the oldest of main's ship
46
+ // merges after it where `sha` came in on the branch side, not main's.
47
+ async function threadThatShipped(cwd, sha, upstream) {
48
+ const r = await git(cwd, ['log', '--merges', '--ancestry-path', '--reverse', '--format=%H %P%x09%s', `${sha}..${upstream}`]);
49
+ const merges = r.out.split('\n').filter(Boolean).slice(0, 3);
50
+ for (const line of merges) {
51
+ const [shas, subject = ''] = line.split('\t');
52
+ const id = subject.match(SHIPPED_FROM)?.[1];
53
+ const mainSide = shas.split(' ')[2];
54
+ if (!id || !mainSide) continue;
55
+ const onMainAlready = await git(cwd, ['merge-base', '--is-ancestor', sha, mainSide]);
56
+ if (onMainAlready.code === 1) return id;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
62
+ const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1).trimEnd()}…` : text);
63
+
64
+ // The conflicting files, then what changed them on `upstream`, by thread.
65
+ async function otherSide({ cwd, files, upstream, readThread }) {
66
+ const lines = [`Files: ${files.map((f) => `\`${f}\``).join(', ') || 'see the output below'}.`];
67
+ const since = cwd ? (await git(cwd, ['merge-base', 'HEAD', upstream])).out.trim() : '';
68
+ const log = since && files.length
69
+ ? await git(cwd, ['log', '--no-merges', `-n${MAX_COMMITS}`, '--format=%h %H%x09%s', `${since}..${upstream}`, '--', ...files])
70
+ : { out: '' };
71
+ const byThread = new Map();
72
+ for (const entry of log.out.split('\n').filter(Boolean)) {
73
+ const [shas, subject = ''] = entry.split('\t');
74
+ const [short, full] = shas.split(' ');
75
+ const id = await threadThatShipped(cwd, full, upstream);
76
+ if (!byThread.has(id)) byThread.set(id, []);
77
+ byThread.get(id).push(`${short} ${oneLine(subject)}`);
78
+ }
79
+
80
+ if (!byThread.size) return lines;
81
+ lines.push('', `What changed them on ${upstream} since this branch began:`);
82
+ for (const [id, commits] of [...byThread].sort(([a], [b]) => (a === null) - (b === null))) {
83
+ if (!id) {
84
+ lines.push(`- From no thread: ${commits.join('; ')}`);
85
+ continue;
86
+ }
87
+ let thread = null;
88
+ try { thread = readThread(id); } catch {}
89
+ const title = oneLine(thread?.title);
90
+ const about = clip(oneLine(thread?.progress || thread?.problem), SUMMARY_CHARS);
91
+ lines.push(`- Thread ${title ? `"${title}" ` : ''}(${id})${about ? `: ${about}` : ''}`, ` Commits: ${commits.join('; ')}`);
92
+ }
93
+ return lines;
94
+ }
95
+
96
+ /**
97
+ * The brief for a ship that failed, or null when it was not a merge conflict.
98
+ *
99
+ * @param {{ cwd: string, output: string, upstream?: string, readThread?: (id: string) => object|null }} opts
100
+ */
101
+ export async function conflictBrief({ cwd, output, upstream = 'origin/main', readThread = () => null }) {
102
+ if (!isMergeConflict(output)) return null;
103
+ const fromGit = cwd ? await filesThatConflict(cwd, 'HEAD', upstream) : null;
104
+ const files = fromGit?.length ? fromGit : conflictedFilesIn(output);
105
+ return ['The other side of the conflict:', '', ...await otherSide({ cwd, files, upstream, readThread }), '', KEEP_BOTH].join('\n');
106
+ }
107
+
108
+ /** For a branch that has not shipped yet: null unless `upstream` now conflicts with it. */
109
+ export async function earlyConflictNote({ cwd, upstream = 'origin/main', readThread = () => null }) {
110
+ const files = await filesThatConflict(cwd, 'HEAD', upstream);
111
+ if (!files?.length) return null;
112
+ return [
113
+ `${upstream} moved while this thread waited, and this branch no longer merges cleanly with it. Merge ${upstream} into it before you mark it ready to ship.`,
114
+ '',
115
+ ...await otherSide({ cwd, files, upstream, readThread }),
116
+ '',
117
+ KEEP_BOTH,
118
+ ].join('\n');
119
+ }
@@ -22,10 +22,12 @@
22
22
  // cannot change the code that ships it. With no `ship.json`, nothing here does
23
23
  // anything at all.
24
24
 
25
- import { spawn } from 'node:child_process';
25
+ import { spawn, execFile } from 'node:child_process';
26
26
  import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { Name } from '../shared/product-name.mjs';
29
+ import { SHIP_FAILED } from '../shared/not-shipped.mjs';
30
+ import { conflictBrief, earlyConflictNote } from './ship-conflict-brief.mjs';
29
31
 
30
32
  export const SHIP_LABEL = 'ship';
31
33
  const SHIP_MS = 20 * 60 * 1000;
@@ -83,11 +85,16 @@ export const withoutShipLabel = (item) => labelsOf(item).filter((l) => l !== SHI
83
85
  * marking, and with a task folder to ship from. THE WINDOW READS THIS TOO
84
86
  * (w-0c1ba766eb): a row on this list is waiting on the app, not on a person,
85
87
  * so it is In progress and not Needs you until the queue has dealt with it.
88
+ *
89
+ * A CLOSED ROW STILL SHIPS, because a close is not a cancel. The window
90
+ * leaves closed rows out (`waitingIds`), so one stays in Done while it ships.
86
91
  */
92
+ export const owedAShip = (item, handled = {}) => wantsShipping(item) && !(markedAt(item) <= (handled[keyOf(item)] ?? -1));
93
+
87
94
  export function waitingToShip(items, products, settings, { folderFor, handled = {} }) {
88
95
  const bySlug = new Map(products.map((p) => [p.slug, p]));
89
96
  return items
90
- .filter((i) => wantsShipping(i) && i.status !== 'done' && !(markedAt(i) <= (handled[keyOf(i)] ?? -1)))
97
+ .filter((i) => owedAShip(i, handled))
91
98
  .map((item) => {
92
99
  const product = bySlug.get(item.product);
93
100
  const script = product ? shipScriptFor(product, settings) : null;
@@ -98,12 +105,24 @@ export function waitingToShip(items, products, settings, { folderFor, handled =
98
105
  }
99
106
 
100
107
  /**
101
- * The next task to ship, oldest ask first, or null: one the queue owes, with
102
- * no session on it (an agent still working may yet commit).
108
+ * The next task to ship, the most important first, or null.
109
+ *
110
+ * A session the row's own agent is still finishing holds its ship: its commits
111
+ * may not all be in. A session that started AFTER the label does not, because
112
+ * that is a reply, and a reply to a thread already waiting used to hold it for
113
+ * as long as the new session ran (2026-10-19). `liveSince` answering null
114
+ * means nobody can say when the live work started — a cloud run whose folder
115
+ * has not come home — and that waits, as it always did.
116
+ *
117
+ * Priority before age, the way `shared/work-items.mjs` orders the queue a
118
+ * puller sees: a thread the person is waiting on queued behind every older one.
103
119
  */
104
- export function nextToShip(items, products, settings, { isLive = () => false, folderFor, handled = {} }) {
105
- const ready = waitingToShip(items, products, settings, { folderFor, handled }).filter(({ item }) => !isLive(item));
106
- ready.sort((a, b) => markedAt(a.item) - markedAt(b.item));
120
+ export function nextToShip(items, products, settings, {
121
+ isLive = () => false, liveSince = () => null, folderFor, handled = {},
122
+ }) {
123
+ const ready = waitingToShip(items, products, settings, { folderFor, handled })
124
+ .filter(({ item }) => !isLive(item) || liveSince(item) > markedAt(item));
125
+ ready.sort((a, b) => (b.item.priority ?? 0) - (a.item.priority ?? 0) || markedAt(a.item) - markedAt(b.item));
107
126
  return ready[0] ?? null;
108
127
  }
109
128
 
@@ -185,7 +204,7 @@ function diagnosis({ why, failed }) {
185
204
  }
186
205
 
187
206
  /** What the agent is told when its branch did not ship. */
188
- export function failureReply(output) {
207
+ export function failureReply(output, { brief = null } = {}) {
189
208
  const tail = String(output ?? '').trim().slice(-KEEP);
190
209
  const { died, ...rest } = whyItDidNotShip(output);
191
210
  return [
@@ -202,6 +221,7 @@ export function failureReply(output) {
202
221
  'a worker that was starved or timed out is likelier than a bug in your change.',
203
222
  'Run the tests for your change again before you go looking for one.',
204
223
  ] : []),
224
+ ...(brief ? ['', brief] : []),
205
225
  '',
206
226
  'Fix what the script says below in your task folder, commit, then add the label',
207
227
  `\`${SHIP_LABEL}\` to this row again and end your turn. Do not push or touch any`,
@@ -217,16 +237,35 @@ export function failureReply(output) {
217
237
  export function failureNote(output) {
218
238
  const { died, last, ...rest } = whyItDidNotShip(output);
219
239
  const said = died ? `${DIED}. It last said: ${last ?? 'nothing at all'}` : diagnosis(rest);
220
- return `It did not ship, and it went back to its agent: ${said.slice(0, 300)}`;
240
+ return `${SHIP_FAILED}${said.slice(0, 300)}`;
221
241
  }
222
242
 
223
- export function runScript(script, cwd, { timeout = SHIP_MS, env = process.env } = {}) {
243
+ /**
244
+ * The base checkout's hooks, read off the task folder's shared git dir. The
245
+ * ship runs with cwd = the task folder, whose core.hooksPath may be the
246
+ * relative 'scripts/hooks' resolving to the branch's own, worker-edited copy.
247
+ * Pinning the base copy here is what keeps the gate the branch cannot change.
248
+ */
249
+ function baseHooksPath(cwd) {
250
+ return new Promise((resolve) => {
251
+ execFile('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8', timeout: 10_000 }, (err, stdout) => {
252
+ const common = err ? '' : String(stdout).trim();
253
+ resolve(common ? path.resolve(common, '..', 'scripts', 'hooks') : null);
254
+ });
255
+ });
256
+ }
257
+
258
+ export async function runScript(script, cwd, { timeout = SHIP_MS, env = process.env } = {}) {
224
259
  const node = env.npm_node_execpath && fs.existsSync(env.npm_node_execpath) ? env.npm_node_execpath : 'node';
260
+ const hooks = await baseHooksPath(cwd);
261
+ const baseGate = hooks
262
+ ? { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.hooksPath', GIT_CONFIG_VALUE_0: hooks }
263
+ : {};
225
264
  return new Promise((resolve) => {
226
265
  let out = '';
227
266
  let child;
228
267
  try {
229
- child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
268
+ child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0', ...baseGate }, stdio: ['ignore', 'pipe', 'pipe'] });
230
269
  } catch (e) {
231
270
  resolve({ code: 1, out: String(e?.message ?? e) });
232
271
  return;
@@ -245,23 +284,26 @@ export function runScript(script, cwd, { timeout = SHIP_MS, env = process.env }
245
284
  * supervisor's own tick and returns at once; the run happens beside it.
246
285
  */
247
286
  export class ShipQueue {
248
- constructor({ store, userDir, folderFor, isLive, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
249
- Object.assign(this, { store, userDir, folderFor, isLive, handBack, run, afterShip, log });
287
+ constructor({ store, userDir, folderFor, isLive, liveSince = () => null, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
288
+ Object.assign(this, { store, userDir, folderFor, isLive, liveSince, handBack, run, afterShip, log });
250
289
  this.busy = null;
290
+ this.earlyConflicts = new Map();
251
291
  }
252
292
 
253
293
  tick(items, products) {
254
294
  if (this.busy) return null;
255
295
  const settings = readShipSettings(this.userDir);
256
296
  if (!Object.keys(settings).length) return null;
257
- const next = nextToShip(items, products, settings, { isLive: this.isLive, folderFor: this.folderFor, handled: readHandled(this.userDir) });
297
+ const next = nextToShip(items, products, settings, {
298
+ isLive: this.isLive, liveSince: this.liveSince, folderFor: this.folderFor, handled: readHandled(this.userDir),
299
+ });
258
300
  if (!next) return null;
259
301
  // Marked handled only once the run has ENDED, pass or fail. Marked before
260
302
  // it, a run cut off by an app restart was never tried again
261
303
  // (w-f37a34def6, 2026-10-04). Within one app, `busy` already stops a
262
304
  // second start; across a restart, trying again is the point.
263
305
  const done = () => writeHandled(this.userDir, { ...readHandled(this.userDir), [keyOf(next.item)]: markedAt(next.item) });
264
- this.busy = this.ship(next).then(done, done).finally(() => { this.busy = null; });
306
+ this.busy = this.ship(next, products, items).then(done, done).finally(() => { this.busy = null; });
265
307
  return this.busy;
266
308
  }
267
309
 
@@ -270,24 +312,78 @@ export class ShipQueue {
270
312
  const settings = readShipSettings(this.userDir);
271
313
  if (!Object.keys(settings).length) return [];
272
314
  return waitingToShip(items, products, settings, { folderFor: this.folderFor, handled: readHandled(this.userDir) })
315
+ .filter(({ item }) => item.status !== 'done')
273
316
  .map(({ item }) => item.id);
274
317
  }
275
318
 
276
- async ship({ item, product, script, cwd }) {
319
+ /** Whether this row's folder must stay for a ship still to come. */
320
+ owes(item, products) {
321
+ const settings = readShipSettings(this.userDir);
322
+ if (!Object.keys(settings).length) return false;
323
+ return waitingToShip([item], products, settings, { folderFor: this.folderFor, handled: readHandled(this.userDir) }).length > 0;
324
+ }
325
+
326
+ // A thread id from a merge on main, read from this project or another one on
327
+ // the same checkout.
328
+ _readThread(id, product, products) {
329
+ const slugs = [product.slug, ...(products ?? []).filter((p) => p.slug !== product.slug && p.repoPath && p.repoPath === product.repoPath).map((p) => p.slug)];
330
+ for (const slug of slugs) {
331
+ const found = this.store.readItem?.(slug, id);
332
+ if (found) return found;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ // After a ship lands, every other open branch of a project that ships is
338
+ // checked against the new main with `git merge-tree`, which reads commits and
339
+ // touches no task folder. A conflict waits here for that thread's next run.
340
+ async _findNewConflicts(shipped, items, products) {
341
+ const settings = readShipSettings(this.userDir);
342
+ const bySlug = new Map((products ?? []).map((p) => [p.slug, p]));
343
+ for (const other of items ?? []) {
344
+ const product = bySlug.get(other.product);
345
+ if (other.id === shipped.id || other.status === 'done' || !product || !settings[product.slug]) continue;
346
+ const cwd = this.folderFor(other, product);
347
+ if (!cwd || !fs.existsSync(cwd)) continue;
348
+ try {
349
+ const note = await earlyConflictNote({ cwd, readThread: (id) => this._readThread(id, product, products) });
350
+ if (note) this.earlyConflicts.set(keyOf(other), note); else this.earlyConflicts.delete(keyOf(other));
351
+ } catch (e) { this.log.warn?.(`zero: could not check ${other.id} against main:`, e.message); }
352
+ }
353
+ }
354
+
355
+ /** What main's last ship means for this thread's branch, once, or null. */
356
+ takeEarlyConflict(item) {
357
+ const note = this.earlyConflicts.get(keyOf(item)) ?? null;
358
+ this.earlyConflicts.delete(keyOf(item));
359
+ return note;
360
+ }
361
+
362
+ async ship({ item, product, script, cwd }, products = [], items = []) {
277
363
  this.log.info?.(`zero: shipping ${item.id} from ${cwd}`);
278
364
  const { code, out } = await this.run(script, cwd);
279
365
  try {
280
366
  // Already on main: there is nothing to fix, so nobody is woken.
281
367
  const already = /There is nothing to ship/.test(out);
282
368
  if (code === 0 || already) {
283
- this.store.shipped(product.slug, item.id, { sha: shippedSha(out), labels: withoutShipLabel(item) });
284
- this.afterShip();
369
+ const sha = shippedSha(out);
370
+ this.store.shipped(product.slug, item.id, { sha, labels: withoutShipLabel(item) });
371
+ // THE COMMIT TRAVELS WITH THE NEWS. What follows a ship wants to know
372
+ // which commit went out: main/main-went-red.mjs waits for that
373
+ // commit's test run on GitHub, because nothing else is still running
374
+ // five minutes after the agent ended.
375
+ this.afterShip({ item, product, sha });
376
+ await this._findNewConflicts(item, items, products);
285
377
  } else {
286
378
  this.store.shipFailed(product.slug, item.id, { note: failureNote(out), labels: withoutShipLabel(item) });
379
+ let brief = null;
380
+ try {
381
+ brief = await conflictBrief({ cwd, output: out, readThread: (id) => this._readThread(id, product, products) });
382
+ } catch (e) { this.log.warn?.(`zero: could not brief the conflict on ${item.id}:`, e.message); }
287
383
  // Straight back to the session that wrote the change. Not through the
288
384
  // reply on the row: that field is hers, and the app's words written
289
385
  // there would be drawn as hers and could not replace hers anyway.
290
- this.handBack(item, failureReply(out));
386
+ this.handBack(item, failureReply(out, { brief }));
291
387
  }
292
388
  } catch (e) {
293
389
  this.log.warn?.(`zero: could not record the ship of ${item.id}:`, e.message);
@@ -33,6 +33,7 @@
33
33
  import { execFile, execFileSync, spawn } from 'node:child_process';
34
34
  import fs from 'node:fs';
35
35
  import { describe } from './updater.mjs';
36
+ import { loginShell } from './shell-path.mjs';
36
37
 
37
38
  const FIRST_CHECK_MS = 20_000;
38
39
  const EVERY_MS = 30 * 60 * 1000;
@@ -103,19 +104,26 @@ function collect(bin, args, { cwd, timeout }) {
103
104
 
104
105
  // WHICH npm. Started with `npm start`, the process carries npm's own path in
105
106
  // its environment, so that one is used and nvm or a version manager never
106
- // matters. Started any other way, her login shell is asked, the way
107
- // main/claude-bin.mjs finds Claude Code. The arguments are fixed strings from
108
- // this file, never anything read from disk.
107
+ // matters. Started any other way, their login shell is asked, the way
108
+ // main/claude-bin.mjs finds Claude Code, and only a shell this machine has: a
109
+ // Linux launcher start carries no SHELL, so the old `|| '/bin/zsh'` was a path
110
+ // to nothing and every update on a box without zsh failed in two seconds. With
111
+ // no shell at all, npm on PATH is the better guess. The arguments are fixed
112
+ // strings from this file, never anything read from disk.
113
+ export function npmCommand(args, { env = process.env, exists = fs.existsSync } = {}) {
114
+ const node = env.npm_node_execpath;
115
+ const cli = env.npm_execpath;
116
+ if (node && cli && exists(node) && exists(cli)) return [node, [cli, ...args]];
117
+ const shell = loginShell(env, exists);
118
+ if (!exists(shell)) return ['npm', args];
119
+ return [shell, ['-lic', `npm ${args.join(' ')}`]];
120
+ }
121
+
109
122
  export function defaultNpm(appDir, env = process.env) {
110
123
  return (args) => {
111
124
  const timeout = args[0] === 'install' ? INSTALL_MS : BUILD_MS;
112
- const node = env.npm_node_execpath;
113
- const cli = env.npm_execpath;
114
- if (node && cli && fs.existsSync(node) && fs.existsSync(cli)) {
115
- return collect(node, [cli, ...args], { cwd: appDir, timeout });
116
- }
117
- const shell = env.SHELL || '/bin/zsh';
118
- return collect(shell, ['-lic', `npm ${args.join(' ')}`], { cwd: appDir, timeout });
125
+ const [bin, argv] = npmCommand(args, { env });
126
+ return collect(bin, argv, { cwd: appDir, timeout });
119
127
  };
120
128
  }
121
129