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,70 +1,26 @@
1
- // WHERE A CODING AGENT ACTUALLY IS, on a machine that is not ours.
1
+ // Where each coding agent is installed: one registry and one search for every
2
+ // engine the app can run. A new engine is an entry in ENGINES, not a file.
2
3
  //
3
- // ONE registry and ONE search, for every harness the app can run. There used to
4
- // be two files, `main/claude-bin.mjs` (338 lines) and `main/codex-bin.mjs` (279
5
- // lines), which were two copies of one idea; both of them now declare their
6
- // engine here and run this. A third harness is an ENTRY, not a file.
4
+ // Never tell somebody their coding agent is missing unless we are certain.
5
+ // `found` says a runnable file is really there and decides what gets spawned.
6
+ // `certain` says we may tell them it is missing, and is meant to be hard to earn.
7
7
  //
8
- // THE RULE THIS EXISTS TO KEEP, inherited from the pair it replaces and not
9
- // re-argued: never tell somebody their coding agent is missing unless we are
10
- // certain it is. So there are two different questions and they must never be
11
- // answered by the same value. `found` is whether a runnable file is really
12
- // there, and it decides which path gets spawned. `certain` is whether we are
13
- // entitled to SAY it is missing, and most of this file exists to make `certain`
14
- // hard to earn.
8
+ // The user's login shell is asked first, because it has their real PATH and
9
+ // profile. The fixed list is for when it cannot answer: a double-clicked
10
+ // Electron app inherits launchd's PATH (/usr/bin:/bin:/usr/sbin:/sbin), which
11
+ // has no nvm, volta or Homebrew. A shell probe that throws falls through to the
12
+ // list and does not count as an answer.
15
13
  //
16
- // ---------------------------------------------------------------------------
17
- // WHY THE ORDER CHANGED, 2026-10-08 (w-ce8c1a9275), and it is the whole point
18
- // of this rewrite.
19
- //
20
- // Both old finders tried the hand-written list FIRST and asked the person's own
21
- // login shell THIRD. The shell is the only source that actually KNOWS where
22
- // their tools are -- it is their PATH, with their profile loaded, as them -- and
23
- // it was the fallback. So every new installer, version manager and distro was a
24
- // patch we ship and a release they wait for. Measured on this repository
25
- // 2026-10-08: of the 7 commits those two files ever had, three are literally
26
- // "one more install location".
27
- //
28
- // THE SHELL GOES FIRST NOW. The list stays, demoted to what it was really
29
- // written for: a GUI launch with a thin PATH. A double-clicked Electron app
30
- // inherits launchd's PATH, which is /usr/bin:/bin:/usr/sbin:/sbin and has never
31
- // heard of nvm, volta or Homebrew, so a login shell that times out or will not
32
- // start leaves the list as the only thing that can answer.
33
- //
34
- // WHAT THAT COSTS, measured on this Mac the same day: `zsh -lic 'command -v
35
- // claude'` takes 0.38-0.43 s, so a machine whose list would have hit on the
36
- // first stat call now pays about 0.4 s per engine at boot instead of nothing.
37
- // Two engines, once, behind a thirty second memory. That is the price of not
38
- // shipping a release every time somebody installs their tools a new way.
39
- //
40
- // AND A LOOKUP THAT THROWS IS NOT AN ABSENCE. The shell step used never to run
41
- // at all on a machine whose list had the binary, so nothing downstream of it was
42
- // ever reached by a throw. It runs first now, and the app refuses to open an
43
- // inbox without Claude Code, so a probe that blows up falls through to the list
44
- // rather than taking Claude Code away from somebody who has it. It still does
45
- // not count as an answer, so it cannot earn the sentence either.
46
- // ---------------------------------------------------------------------------
47
- //
48
- // Nothing here overrides a path somebody set. An explicit `claudeBin` or
49
- // `codexBin` wins untouched and is never searched around, because that promise
50
- // is what makes "point at it yourself" a real way out of a search that missed
51
- // (main/ipc.mjs, `zero:engine-bin`).
14
+ // A path somebody set (`claudeBin`, `codexBin`) wins and is never searched
15
+ // around; that is what makes "point at it yourself" work (main/ipc.mjs,
16
+ // `zero:engine-bin`).
52
17
 
53
18
  import fs from 'node:fs';
54
19
  import os from 'node:os';
55
20
  import path from 'node:path';
56
- import { execFileSync } from 'node:child_process';
21
+ import { execFile, execFileSync } from 'node:child_process';
57
22
 
58
- /* ------------------------------ the strategy ------------------------------ */
59
-
60
- /**
61
- * EVERY PACKAGE MANAGER'S DEFAULT BIN DIRECTORY, in order, for any tool.
62
- *
63
- * This list was written TWICE, once per engine, fourteen entries each, and a
64
- * path added to one and not the other is how one engine quietly stops being
65
- * found where the other is. It is one list now and every engine's candidates
66
- * are generated from it, so a new entry reaches every harness at once.
67
- */
23
+ /** Every package manager's default bin directory, in order, shared by all engines. */
68
24
  export function sharedBinDirs(home = os.homedir()) {
69
25
  return [
70
26
  '/opt/homebrew/bin', // Homebrew, Apple Silicon
@@ -85,10 +41,8 @@ export function sharedBinDirs(home = os.homedir()) {
85
41
  }
86
42
 
87
43
  /**
88
- * ONE BIN DIRECTORY PER INSTALLED NODE, under a name no list can hold. nvm,
89
- * fnm, n and asdf each keep one per version, so they are READ rather than
90
- * listed, and a directory that is not there contributes nothing. This is where
91
- * a global npm install under any of them lands and nowhere else.
44
+ * One bin directory per installed Node. nvm, fnm, n and asdf keep one per
45
+ * version, and a global npm install under them lands only there.
92
46
  */
93
47
  export function versionedBinPaths(bin, home = os.homedir(), readdir = defaultReaddir) {
94
48
  const under = (dir, tail) => readdir(path.join(home, dir)).map((v) => path.join(home, dir, v, tail));
@@ -106,11 +60,9 @@ function defaultReaddir(dir) {
106
60
  }
107
61
 
108
62
  /**
109
- * A path that is a symlink to nothing. `existsSync` follows the link and says
110
- * false, which is indistinguishable from nothing being there at all, and the two
111
- * mean opposite things: both engines' ordinary installs point a stable link at a
112
- * versioned file and swap that file on every update, so this is the shape of a
113
- * tool mid-upgrade rather than an odd one.
63
+ * A symlink to nothing, which `existsSync` cannot tell from no file. Both
64
+ * engines' installers point a stable link at a versioned file and swap it on
65
+ * update, so this is a tool mid-upgrade, not a missing one.
114
66
  */
115
67
  function defaultDangles(p) {
116
68
  try { return !!fs.lstatSync(p) && !fs.existsSync(p); } catch { return false; }
@@ -125,19 +77,16 @@ function newestFirst(a, b) {
125
77
  return 0;
126
78
  }
127
79
 
128
- /* ------------------------------- the registry ----------------------------- */
129
-
130
80
  /**
131
- * WHAT AN ENGINE IS, and the whole of what a new harness has to say:
81
+ * What a new engine has to declare:
132
82
  *
133
83
  * bin the program name, which is also its key here
134
84
  * name what it is called on a screen
135
85
  * installUrl where to get it, when we are sure it is not here
136
- * ownPaths where the tool's OWN installer puts it, tried before the
137
- * package managers because it is the answer the tool produces
86
+ * ownPaths where the tool's own installer puts it, tried first
138
87
  * appCopies a copy carried inside a desktop app, tried last of all
139
- * evidence what it leaves behind, which is never spawned and only ever
140
- * takes away our right to announce an absence
88
+ * evidence files it leaves behind; never spawned, and only ever stop us
89
+ * saying it is missing
141
90
  */
142
91
  function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidence }) {
143
92
  return {
@@ -147,8 +96,7 @@ function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidenc
147
96
  ownPaths,
148
97
  appCopies,
149
98
  evidence,
150
- // In order, and the first that exists wins. The tool's own paths, then every
151
- // package manager's bin directory, then one directory per installed Node.
99
+ // The first that exists wins.
152
100
  candidates(home = os.homedir(), readdir = defaultReaddir) {
153
101
  return [
154
102
  ...ownPaths(home),
@@ -161,20 +109,12 @@ function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidenc
161
109
 
162
110
  export const ENGINES = {
163
111
  /**
164
- * CLAUDE CODE. Required: the app will not open an inbox without it, which is
165
- * why its absence is the one a screen is ever allowed to shout about.
166
- *
167
- * `~/.local/bin/claude` is the installer's own path and `~/.claude/local/claude`
168
- * is what `claude migrate-installer` leaves behind. The installer points the
169
- * first at a versioned file and swaps that file on every update, so a dangling
170
- * link there is a real state and not a hypothetical.
112
+ * Claude Code is required: the app will not open an inbox without it.
171
113
  *
172
- * THE COPY THE CLAUDE DESKTOP APP KEEPS, one folder per version, newest first.
173
- * Somebody who has only ever used Claude Code inside the Claude app has this
174
- * and nothing else (w-9f6975906c, measured 2026-10-05: 2.1.209 there, sharing
175
- * the CLI's sign-in). Last of all, because it updates when the app does and a
176
- * real install is fresher -- that copy was three months old beside a 2.1.289
177
- * install on the same Mac.
114
+ * The Claude desktop app keeps its own copy, one folder per version, and it
115
+ * shares the CLI's sign-in, so someone who only uses Claude Code inside that
116
+ * app has nothing else. It is tried last because it updates with the app and
117
+ * lags a real install.
178
118
  */
179
119
  claude: engine({
180
120
  bin: 'claude',
@@ -191,8 +131,7 @@ export const ENGINES = {
191
131
  .sort(newestFirst)
192
132
  .map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
193
133
  },
194
- // WHAT CLAUDE CODE LEAVES BEHIND. Any one of these means it has run on this
195
- // machine. None is a binary and none is ever spawned.
134
+ // Any one of these means Claude Code has run on this machine.
196
135
  evidence: (home) => [
197
136
  path.join(home, '.claude.json'), // its own config file
198
137
  path.join(home, '.claude/settings.json'),
@@ -205,36 +144,15 @@ export const ENGINES = {
205
144
  }),
206
145
 
207
146
  /**
208
- * CODEX, and it is OPTIONAL where Claude Code is not. An absence is the
209
- * ordinary case rather than a fault, so nothing about it ever produces a
210
- * warning: it just leaves the second engine out of the map
211
- * `shared/engines.mjs` reads.
212
- *
213
- * MEASURED ON A MAC 2026-09-04, because the 2026-08-25 original was written
214
- * against codex-cli 0.144.0 and six of its assumptions had moved. codex-cli
215
- * 0.148.0 at /opt/homebrew/bin/codex, a Homebrew CASK rather than a formula,
216
- * whose bin entry is a SYMLINK into /opt/homebrew/Caskroom/codex/0.148.0/bin.
217
- * `brew info --cask codex` the same minute: 0.148.0 installed against 0.152.1
218
- * available, so the moment mid-upgrade where that link points at nothing is a
219
- * live state and not a story.
220
- *
221
- * THE INSTALL METHOD THE DOCS NOW LEAD WITH, `chatgpt.com/codex/install.sh`,
222
- * read 2026-09-04: `BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"`,
223
- * `STANDALONE_ROOT="${CODEX_HOME:-$HOME/.codex}/packages/standalone"`. It links
224
- * `~/.local/bin/codex` at a versioned file under that root. AND THE STORE KEEPS
225
- * THE BINARY IN TWO PLACES -- the same script stages `<release>/bin/codex` and
226
- * links `<release>/codex` beside it (lines 951 and 1021), and `current` is a
227
- * symlink at the live release -- so both resolve on a machine set up the
228
- * documented way, each costs one stat call, and listing only one is how the app
229
- * tells somebody their Codex is missing while they are looking at it.
147
+ * Codex is optional, so its absence never produces a warning; it is just
148
+ * left out of the map `shared/engines.mjs` reads.
230
149
  *
231
- * WHAT ~/.codex REALLY HOLDS: 38 entries on that Mac. auth.json, config.toml,
232
- * sessions/ and history.jsonl are all still there, but the live record moved
233
- * into state_5.sqlite, thread_history_1.sqlite, logs_2.sqlite and friends,
234
- * whose names carry a schema number that increments -- sessions/ held 1691
235
- * rollout files written the day before while history.jsonl had not been
236
- * touched since 2026-06-03. That is why the directory itself is on the list,
237
- * last and broad, and why nothing tries to enumerate what is inside it.
150
+ * Homebrew installs Codex as a cask whose bin entry is a symlink into the
151
+ * Caskroom, so it dangles mid-upgrade. The documented install.sh links
152
+ * `~/.local/bin/codex` into `$CODEX_HOME/packages/standalone`, and each
153
+ * release holds the binary at both `bin/codex` and `codex`, so both are
154
+ * listed. ~/.codex keeps its live state in sqlite files whose names carry a
155
+ * changing schema number, so the directory itself is evidence, last.
238
156
  */
239
157
  codex: engine({
240
158
  bin: 'codex',
@@ -245,17 +163,14 @@ export const ENGINES = {
245
163
  path.join(home, '.codex/packages/standalone/current/bin/codex'),
246
164
  path.join(home, '.codex/packages/standalone/current/codex'),
247
165
  ],
248
- // THE COPY THE CHATGPT APP CARRIES INSIDE ITSELF (w-9f6975906c, measured
249
- // 2026-10-05: codex-cli 0.158.0-alpha.2, sharing the CLI's sign-in), and the
250
- // one the Codex desktop app carries (w-d5d632e503, its reported place rather
251
- // than one measured here). A path that is not there costs one `exists`.
166
+ // The ChatGPT app's own copy, which shares the CLI's sign-in, and the Codex
167
+ // desktop app's (reported, not verified here).
252
168
  appCopies: (home) => [
253
169
  'ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
254
170
  'Codex.app/Contents/Resources/codex',
255
171
  ].flatMap((p) => [path.join('/Applications', p), path.join(home, 'Applications', p)]),
256
- // BOTH HOMES ARE LOOKED IN WHEN CODEX_HOME IS SET, on purpose: a CODEX_HOME
257
- // inherited from somewhere else is not proof the ordinary directory is gone,
258
- // and the whole job of this list is to make an absence hard to claim.
172
+ // Both homes when CODEX_HOME is set: an inherited CODEX_HOME does not prove
173
+ // ~/.codex is unused.
259
174
  evidence: (home, env = process.env) => {
260
175
  const homes = [env?.CODEX_HOME, path.join(home, '.codex')].filter(Boolean);
261
176
  const out = [];
@@ -284,11 +199,8 @@ function engineFor(key) {
284
199
  return found;
285
200
  }
286
201
 
287
- /* -------------------------------- the shell ------------------------------- */
288
-
289
- // The shells worth asking, in order, and never the same one twice. Theirs first
290
- // because it is the one that has their PATH in it; the other two because a
291
- // $SHELL that will not start is a fact about $SHELL and not about the tool.
202
+ // Their $SHELL first, then zsh and bash, because a $SHELL that will not start
203
+ // says nothing about the tool.
292
204
  export function shellProbes(env = process.env) {
293
205
  const preferred = env.SHELL || '/bin/zsh';
294
206
  const out = [[preferred, '-lic', 4000], [preferred, '-lc', 2500]];
@@ -299,55 +211,55 @@ export function shellProbes(env = process.env) {
299
211
  }
300
212
 
301
213
  /**
302
- * WHAT MAY BE HANDED TO A LOGIN SHELL, AND WHY THIS IS CHECKED AT ALL.
303
- *
304
- * The probe below interpolates `bin` into a string a LOGIN, INTERACTIVE shell
305
- * executes, with their profile loaded, as them. Every caller passes a registry
306
- * entry's `bin`, which is a literal in this file, so nothing is exploitable --
307
- * but a `codexBin` read out of zero.config.json reaching this argument is one
308
- * plausible slice away, and at that point `command -v x; curl | sh` is a string
309
- * in a file that runs itself.
310
- *
311
- * This function looks up program NAMES, so a name is all it takes. An allowlist
312
- * rather than quoting, because quoting is a thing a later edit can undo without
313
- * noticing and a shape check is not.
214
+ * `bin` is interpolated into a command their login shell runs, so only a plain
215
+ * program name is accepted. A config value reaching it would otherwise be shell
216
+ * injection. An allowlist rather than quoting, because a later edit can undo
217
+ * quoting without anyone noticing.
314
218
  */
315
219
  const PROGRAM_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
316
220
 
317
221
  /**
318
- * Ask the person's own login shell, and go on asking until one of them answers.
319
- * Anything that is not an absolute path to a file that exists is treated as no
320
- * path: `command -v` also prints shell functions and aliases, and a function
321
- * name handed to spawn is a crash with a worse message than "not found".
322
- *
323
- * `answered` is the strict half. It is true only when every probe that ran came
324
- * back with a real exit status, and at least one did. One shell that timed out
325
- * alongside one that said no is not a machine we know anything about.
326
- *
327
- * It must be a LOGIN, INTERACTIVE shell, because the version managers put
328
- * themselves on PATH from `.zprofile` and `.zshrc` and nowhere else. That is the
329
- * whole reason an npm install is obvious in a terminal and invisible to the app.
330
- * It is asked more than once because ONE probe that fails to start is not a fact
331
- * about anybody's machine.
222
+ * Ask their login shell until one answers. Only an absolute path to a file that
223
+ * exists counts, because `command -v` also prints functions and aliases.
224
+ * `answered` is true only when at least one probe ran and every probe returned
225
+ * a real exit status. Login and interactive (`-lic`) because version managers
226
+ * add themselves to PATH only in `.zprofile` and `.zshrc`.
332
227
  */
333
228
  export function askShell({ env = process.env, exists = fs.existsSync, run = defaultRun, bin = 'claude' } = {}) {
229
+ const asking = shellQuestion({ env, exists, bin });
230
+ for (let step = asking.next(); ; ) {
231
+ if (step.done) return step.value;
232
+ let reply;
233
+ try { reply = { out: run(...step.value) }; } catch (err) { reply = { err }; }
234
+ step = asking.next(reply);
235
+ }
236
+ }
237
+
238
+ /** `askShell` without holding the window: each probe is awaited. */
239
+ export async function askShellLater({ env = process.env, exists = fs.existsSync, run = shellRunnerLater(process.env), bin = 'claude' } = {}) {
240
+ const asking = shellQuestion({ env, exists, bin });
241
+ for (let step = asking.next(); ; ) {
242
+ if (step.done) return step.value;
243
+ let reply;
244
+ try { reply = { out: await run(...step.value) }; } catch (err) { reply = { err }; }
245
+ step = asking.next(reply);
246
+ }
247
+ }
248
+
249
+ // The probes and what their replies mean, written once for both askers: it
250
+ // yields each probe to run and is handed back `{ out }` or `{ err }`.
251
+ function* shellQuestion({ env, exists, bin }) {
334
252
  if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
335
253
  throw new Error(`askShell looks up a program name, and ${JSON.stringify(bin)} is not one`);
336
254
  }
337
255
  let said = false;
338
256
  let inconclusive = false;
339
257
  for (const [shell, flags, timeout] of shellProbes(env)) {
340
- let out = '';
341
- try {
342
- out = run(shell, [flags, `command -v ${bin}`], timeout);
343
- } catch (e) {
344
- // TWO DIFFERENT SILENCES, AND ONLY ONE OF THEM IS AN ANSWER. A shell that
345
- // ran and exited non-zero has told us the tool is not on PATH, and that is
346
- // an answer. A shell that could not be started, or that ran past the
347
- // timeout, has told us nothing at all, and reporting nothing as "not
348
- // installed" is what put a line about missing software on the screen of
349
- // people who have it.
350
- if (exitedSayingNo(e)) said = true; else inconclusive = true;
258
+ const { out, err } = yield [shell, [flags, `command -v ${bin}`], timeout];
259
+ if (err) {
260
+ // A non-zero exit means not on PATH. A shell that would not start or
261
+ // timed out has told us nothing, and must not count as "not installed".
262
+ if (exitedSayingNo(err)) said = true; else inconclusive = true;
351
263
  continue;
352
264
  }
353
265
  const line = String(out).split('\n').map((s) => s.trim()).filter(Boolean).pop();
@@ -357,29 +269,95 @@ export function askShell({ env = process.env, exists = fs.existsSync, run = defa
357
269
  return { path: null, answered: said && !inconclusive };
358
270
  }
359
271
 
360
- // The shell ran to completion and returned a non-zero exit code, which for
361
- // `command -v` is how "not on PATH" is spelt. A timeout (SIGTERM, ETIMEDOUT)
362
- // and a shell that does not exist (ENOENT) both arrive here too, and neither
363
- // of them is a fact about the tool.
272
+ // A timeout and a missing shell also throw here, and neither is a fact about
273
+ // the tool.
364
274
  function exitedSayingNo(e) {
365
275
  if (!e || typeof e !== 'object') return false;
366
276
  if (e.signal || e.code === 'ETIMEDOUT' || e.code === 'ENOENT') return false;
367
277
  return typeof e.status === 'number';
368
278
  }
369
279
 
280
+ /**
281
+ * A SHELL THAT NEVER REACHED THE QUESTION HAS NOT ANSWERED IT (2026-10-15).
282
+ *
283
+ * `[ -z "$TMUX" ] && exec tmux` in .zshrc replaces the shell before `command
284
+ * -v` runs, and tmux with no terminal says "open terminal failed: not a
285
+ * terminal" and exits 1, which is exactly how `command -v` spells "not on
286
+ * PATH". Measured with the real /bin/zsh: a claude that .zshrc put on PATH came
287
+ * back `{ answered: true }`, so the setup screen was entitled to call it
288
+ * missing. `exec fish` was the same with exit 0 and nothing printed.
289
+ *
290
+ * So the runner says a word of its own first and only hands back what comes
291
+ * after it. Without that word the shell never got to the question, and what is
292
+ * thrown carries no exit status, which `exitedSayingNo` reads as no answer.
293
+ * The question itself is unchanged, so a `run` handed in by a test sees the
294
+ * same `command -v x` it always did.
295
+ */
296
+ const REACHED = 'agentbox-shell-reached';
297
+ const notReached = () => Object.assign(new Error('the shell profile ended the shell before the question ran'), { code: 'ENOTREACHED' });
298
+ function afterReached(stdout) {
299
+ const lines = String(stdout ?? '').split('\n');
300
+ const at = lines.map((l) => l.trim()).lastIndexOf(REACHED);
301
+ return at < 0 ? null : lines.slice(at + 1).join('\n');
302
+ }
303
+
304
+ export function shellRunner(env = process.env) {
305
+ return (shell, [flags, command], timeout = 4000) => {
306
+ let out;
307
+ try {
308
+ out = execFileSync(shell, [flags, `echo ${REACHED}; ${command}`], {
309
+ encoding: 'utf8',
310
+ // stdin ignored on purpose: an interactive shell handed a live terminal can
311
+ // sit waiting for input, and this runs on the boot path.
312
+ stdio: ['ignore', 'pipe', 'ignore'],
313
+ timeout,
314
+ // Not SIGTERM: a shell's exit hook can wait forever on it, and this
315
+ // spawn blocks the whole app until the shell has exited (2026-10-16).
316
+ killSignal: 'SIGKILL',
317
+ env,
318
+ });
319
+ } catch (e) {
320
+ if (typeof e?.status === 'number' && !e.signal && afterReached(e.stdout) === null) throw notReached();
321
+ throw e;
322
+ }
323
+ const answer = afterReached(out);
324
+ if (answer === null) throw notReached();
325
+ return answer;
326
+ };
327
+ }
328
+
370
329
  function defaultRun(shell, args, timeout = 4000) {
371
- return execFileSync(shell, args, {
372
- encoding: 'utf8',
373
- // stdin ignored on purpose: an interactive shell handed a live terminal can
374
- // sit waiting for input, and this runs on the boot path.
375
- stdio: ['ignore', 'pipe', 'ignore'],
376
- timeout,
330
+ return shellRunner(process.env)(shell, args, timeout);
331
+ }
332
+
333
+ /**
334
+ * `shellRunner` as a promise, so the window keeps running while the shell
335
+ * starts. Its errors are reshaped into what execFileSync throws, which is what
336
+ * `exitedSayingNo` reads: a timeout carries ETIMEDOUT, a plain non-zero exit a
337
+ * numeric `status`.
338
+ */
339
+ export function shellRunnerLater(env = process.env) {
340
+ return (shell, [flags, command], timeout = 4000) => new Promise((resolve, reject) => {
341
+ const child = execFile(shell, [flags, `echo ${REACHED}; ${command}`], {
342
+ encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, env,
343
+ }, (err, stdout) => {
344
+ if (!err) {
345
+ const answer = afterReached(stdout);
346
+ return answer === null ? reject(notReached()) : resolve(answer);
347
+ }
348
+ if (typeof err.code === 'number' && !err.signal) {
349
+ if (afterReached(stdout) === null) return reject(notReached());
350
+ return reject(Object.assign(err, { status: err.code, code: undefined }));
351
+ }
352
+ if (err.killed && err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') err.code = 'ETIMEDOUT';
353
+ reject(err);
354
+ });
355
+ // The 'ignore' the waiting runner gives stdin: nothing will ever be typed.
356
+ child.stdin?.end();
377
357
  });
378
358
  }
379
359
 
380
- // askShell used to answer with a path or null, and both a real answer and a
381
- // failed probe were null. Anything still handing back the old shape is read as
382
- // an answer, because that is what it always meant.
360
+ // A lookup that returns a bare path or null (the old shape) counts as answered.
383
361
  function shellSaid(result) {
384
362
  if (typeof result === 'string') return { path: result, answered: true };
385
363
  if (!result || typeof result !== 'object') return { path: null, answered: true };
@@ -387,31 +365,11 @@ function shellSaid(result) {
387
365
  }
388
366
 
389
367
  /**
390
- * ONE LOGIN SHELL, ASKED ONCE, ANSWERING FOR EVERY ENGINE.
391
- *
392
- * MEASURED ON THIS MAC 2026-10-11, against the finished shell-first finder:
393
- * `findClaudeBin()` and `findCodexBin()` together took 1640, 1432 and 1435 ms
394
- * cold, and 0 ms with the shell stubbed out. main/main.mjs calls `loadConfig`
395
- * inside `createWindow` and BEFORE `new BrowserWindow`, so that was not time in
396
- * the background: it was a second and a half of nothing on screen, added to
397
- * every launch, and it was the price of asking the shell first.
398
- *
399
- * THE COST IS THE SHELL STARTING, NOT THE QUESTION. A login, interactive shell
400
- * reads the whole profile -- which is the entire reason it can answer at all --
401
- * and that costs about the same whether it is asked about one program or six.
402
- * Asking it once per engine paid it once per engine.
403
- *
404
- * So this asks about all of them in one command and `warmShellAnswers` seeds
405
- * every engine's cache from it.
406
- *
407
- * IT ONLY EVER ANSWERS POSITIVELY, which is what makes it safe to put in front
408
- * of the probe that was already here rather than in place of it. A path is
409
- * taken only when it is absolute, really on the disk, and really named after
410
- * the engine that asked -- the lines come back in whatever order the shell
411
- * prints them, and a path handed to the wrong engine would spawn the wrong
412
- * agent. Nothing here ever records an ABSENCE: `answered` is the subtle half of
413
- * this file's rules, and the per-engine probe stays the only thing allowed to
414
- * settle one. So the worst a bad combined answer can do is nothing at all.
368
+ * One login shell asked once for every engine. Starting the shell is the cost,
369
+ * not the question, and `loadConfig` runs before the window opens, so asking
370
+ * per engine delayed every launch. A path is taken only when it is absolute,
371
+ * exists, and is named after an engine, since lines come back in any order.
372
+ * This never records an absence; only the per-engine probe may settle one.
415
373
  */
416
374
  export function askShellForAll(bins, { env = process.env, exists = fs.existsSync, run = defaultRun } = {}) {
417
375
  for (const bin of bins) {
@@ -425,22 +383,18 @@ export function askShellForAll(bins, { env = process.env, exists = fs.existsSync
425
383
  try { out = run(shell, [flags, command], timeout); } catch { continue; }
426
384
  const found = {};
427
385
  for (const line of String(out).split('\n').map((s) => s.trim()).filter(Boolean)) {
428
- // A function body, an alias line or a bare name is not a path, and a path
429
- // belongs to the engine whose name it ends with or to nobody.
430
386
  if (!line.startsWith('/') || !exists(line)) continue;
431
387
  const bin = bins.find((b) => path.basename(line) === b);
432
388
  if (bin && !found[bin]) found[bin] = line;
433
389
  }
434
- // A shell that ran has had its turn, whatever it did or did not print.
435
390
  return found;
436
391
  }
437
392
  return {};
438
393
  }
439
394
 
440
395
  /**
441
- * Seed every engine's held shell answer off one probe. Silent either way: a
442
- * shell that will not start leaves each engine to its own probe, which is
443
- * exactly what used to happen for all of them.
396
+ * Seed every engine's cached shell answer from one probe. If the shell fails,
397
+ * each engine falls back to its own probe.
444
398
  */
445
399
  export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
446
400
  let found = {};
@@ -451,25 +405,14 @@ export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
451
405
  }
452
406
  }
453
407
 
454
- /**
455
- * THE SHELL STEP, AND IT MAY NOT TAKE THE SEARCH DOWN WITH IT.
456
- *
457
- * It runs FIRST now, so it is the one step whose failure would reach everything
458
- * after it. A lookup that throws is read as the shell having said nothing: the
459
- * list still gets its turn, and the throw still does not count as an answer, so
460
- * it cannot earn the sentence about missing software either.
461
- */
408
+ // A lookup that throws is read as no answer, so the list still gets its turn.
462
409
  function askedShell(lookup) {
463
410
  try { return shellSaid(lookup()); } catch { return { path: null, answered: false }; }
464
411
  }
465
412
 
466
- /* -------------------------------- the search ------------------------------ */
467
-
468
413
  /**
469
- * WHATEVER THE TOOL LEFT BEHIND, or null. None of these is a binary and none is
470
- * ever spawned; they exist only to stop the app announcing an absence it cannot
471
- * support. A dangling candidate counts the same way, because an update that has
472
- * swapped the versioned file out from under a stable link leaves exactly that.
414
+ * The first trace the tool left behind, or null. Never spawned; it only stops
415
+ * the app saying the tool is missing. A dangling candidate counts too.
473
416
  */
474
417
  export function installEvidence(key, {
475
418
  home = os.homedir(), env = process.env, exists = fs.existsSync, dangles = defaultDangles,
@@ -481,18 +424,10 @@ export function installEvidence(key, {
481
424
  }
482
425
 
483
426
  /**
484
- * THE WHOLE SEARCH, as a pure function of its inputs so a test can put a coding
485
- * agent anywhere without touching the machine it runs on.
486
- *
487
- * Returns { path, found, certain, from, searched, evidence }. `found` is the only
488
- * claim about the world: it means a file is really there. `certain` says whether
489
- * we are allowed to tell somebody it is NOT there, so a screen can stay quiet
490
- * rather than announce an absence it has not established. They come apart in
491
- * exactly one case that is not a failure, and it is the case worth keeping
492
- * honest: somebody has set a path that does not exist, so the path stays THEIRS
493
- * (anything that spawns it should fail naming the path they chose, not one we
494
- * picked) while the screen says it was not found and offers the install link,
495
- * the same as any other machine.
427
+ * The whole search, with every machine fact injectable for tests. Returns
428
+ * { path, found, certain, from, searched, evidence }. A configured path that
429
+ * does not exist is still returned as the path, so a spawn fails naming the
430
+ * path they chose, with found false and certain true.
496
431
  */
497
432
  export function findEngine(key, {
498
433
  configured = null,
@@ -511,36 +446,25 @@ export function findEngine(key, {
511
446
  };
512
447
  }
513
448
 
514
- // THE SHELL FIRST. It is the only source that knows where their tools are.
515
- // The default probe reads the real environment and the real disk, exactly as
516
- // both old finders' defaults did: a test that wants to put a tool somewhere
517
- // hands in `shellLookup`, and one that hands in only a fake `exists` is
518
- // asking about the LIST, so feeding its fake into the shell step would
519
- // silently answer a different question.
449
+ // The default probe uses the real environment and disk, not the injected
450
+ // `exists`: a test faking only `exists` is asking about the list.
520
451
  const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
521
452
  const fromShell = askedShell(lookup);
522
- // Computed either way, because a caller that wants to say where we looked
523
- // wants it most on the answer it is going to draw.
524
453
  const searched = spec.candidates(home, readdir);
525
454
  if (fromShell.path) {
526
455
  return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
527
456
  }
528
457
 
529
- // THEN THE LIST, which is the GUI launch with launchd's thin PATH.
530
458
  for (const candidate of searched) {
531
459
  if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'disk', searched, evidence: null };
532
460
  }
533
461
 
534
- // THEN A DESKTOP APP'S OWN COPY, last because it moves with that app's
535
- // releases and a real install anywhere is fresher.
536
462
  for (const candidate of spec.appCopies(home, readdir)) {
537
463
  if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
538
464
  }
539
465
 
540
- // NOT FOUND, AND WHETHER THAT IS KNOWN. Every shell we could reach missed and
541
- // every cheap path missed, so the last question is whether this machine shows
542
- // any sign of the tool anyway. If it does, we looked in the wrong places
543
- // rather than at an empty machine, and nothing may be said out loud.
466
+ // Any trace of the tool means we looked in the wrong places, so we may not
467
+ // say it is missing.
544
468
  const evidence = installEvidence(key, { home, env, exists, dangles });
545
469
  return {
546
470
  path: null,
@@ -552,44 +476,70 @@ export function findEngine(key, {
552
476
  };
553
477
  }
554
478
 
555
- /* -------------------------------- the cache ------------------------------- */
556
- // Only the SHELL step is remembered, and only briefly. Nothing else is: the path
557
- // checks are stat calls, and caching them is how a settings screen ends up
558
- // telling someone to install their coding agent and then still saying "not
559
- // found" after they have. The sentence on that screen makes a promise and a
560
- // cached answer breaks it.
561
- //
562
- // The shell is the one part worth holding, because it is up to three
563
- // subprocesses and a screen somebody toggles a switch on reads settings again on
564
- // every write. Thirty seconds is long enough that a burst of writes pays for it
565
- // once, and short enough that it is never the reason someone is still stuck.
566
- //
567
- // PER ENGINE, because looking for Claude Code tells you nothing about Codex, and
568
- // one shared window would have the second engine read the first one's answer.
479
+ // Only the shell answer is held, per engine. The path checks are cheap stat
480
+ // calls, and holding them would keep saying "not found" after someone
481
+ // installs. The shell is up to three subprocesses and blocks the window while
482
+ // it runs, so it is waited for only when nothing is held: at launch, and after
483
+ // Check again. An answer older than thirty seconds is still handed back at
484
+ // once, and the shell is asked again in the background for the next read.
569
485
 
570
486
  const SHELL_TTL = 30_000;
571
487
  const held = new Map();
488
+ const asking = new Map();
572
489
 
573
- export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, ...where } = {}) {
490
+ export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, shellLookupLater = null, ...where } = {}) {
574
491
  const spec = engineFor(key);
575
- const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
576
492
  const remembered = () => {
577
493
  const last = held.get(key);
578
- // THE WINDOW HAS A FLOOR AS WELL AS A CEILING. `now - at < TTL` alone is
579
- // true for any answer stamped in the FUTURE, and since `warmShellAnswers`
580
- // stamps real clock time into this same map, anything asking with a
581
- // synthetic `now` would read a warm answer as fresh forever. An answer from
582
- // ahead of the question is not an answer; it is asked again.
583
- const age = now - (last?.at ?? 0);
584
- if (last && age >= 0 && age < SHELL_TTL) return last.answer;
585
- const answer = lookup();
586
- held.set(key, { answer, at: now });
587
- return answer;
494
+ if (!last) {
495
+ const answer = (shellLookup ?? (() => askShell({ bin: spec.bin })))();
496
+ held.set(key, { answer, at: now });
497
+ return answer;
498
+ }
499
+ // `age >= 0` too, because `warmShellAnswers` stamps real clock time and a
500
+ // caller with a synthetic `now` would otherwise read it as fresh forever.
501
+ const age = now - last.at;
502
+ const later = shellLookupLater ?? (() => askShellLater({ bin: spec.bin }));
503
+ if (!(age >= 0 && age < SHELL_TTL)) askAgain(key, later, now);
504
+ // A path held from an earlier answer may have been uninstalled since.
505
+ const gone = typeof last.answer?.path === 'string' && !(where.exists ?? fs.existsSync)(last.answer.path);
506
+ if (gone) { askAgain(key, later, now); return { path: null, answered: false }; }
507
+ return last.answer;
588
508
  };
589
509
  return findEngine(key, { ...where, configured, shellLookup: remembered });
590
510
  }
591
511
 
512
+ /**
513
+ * Ask the shell again off the window's thread and settle once the answer is
514
+ * held, for a caller that promised a fresh look (Check again, setup, the
515
+ * update check). Never rejects.
516
+ */
517
+ export function lookAgain(key, { now = Date.now(), shellLookupLater = null } = {}) {
518
+ const spec = engineFor(key);
519
+ return askAgain(key, shellLookupLater ?? (() => askShellLater({ bin: spec.bin })), now);
520
+ }
521
+
522
+ function askAgain(key, lookup, now) {
523
+ if (asking.has(key)) return asking.get(key);
524
+ const turn = new Promise((resolve) => resolve(lookup())).then(
525
+ (answer) => {
526
+ if (asking.get(key) !== turn) return;
527
+ // A shell that timed out under load says nothing, and must not replace
528
+ // an answer that did say something.
529
+ const said = shellSaid(answer);
530
+ const had = held.get(key);
531
+ if (said.path || said.answered || !had) held.set(key, { answer, at: now });
532
+ else held.set(key, { ...had, at: now });
533
+ },
534
+ () => { /* the held answer stands, and the next read asks again */ },
535
+ ).finally(() => { if (asking.get(key) === turn) asking.delete(key); });
536
+ asking.set(key, turn);
537
+ return turn;
538
+ }
539
+
592
540
  export function forgetEngine(key) {
593
- engineFor(key); // an engine nobody declared is a mistake here too, not a no-op
541
+ engineFor(key); // throws for an undeclared engine
594
542
  held.delete(key);
543
+ // An answer already on its way was asked for before this, so it is dropped.
544
+ asking.delete(key);
595
545
  }