agentbox-app 0.1.12 → 0.1.13

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 (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -2,11 +2,11 @@
2
2
  // started only by the app's Update now action, never by a poll or terminal open.
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
- import path from 'node:path';
6
5
  import {execFile} from 'node:child_process';
7
6
  import {promisify} from 'node:util';
8
7
  import {TaskTerminals,terminalEnv} from './task-terminals.mjs';
9
- import {codexLaunchEnv} from './codex-launch-env.mjs';
8
+ import { harnessFor } from './harnesses.mjs';
9
+ import { harnessDefinition } from '../shared/harness-definitions.mjs';
10
10
  const exec=promisify(execFile);
11
11
  const valid=v=>typeof v==='string'&&/^\d+\.\d+\.\d+$/.test(v);
12
12
  export function newerVersion(installed,latest){
@@ -15,28 +15,15 @@ export function newerVersion(installed,latest){
15
15
  for(let i=0;i<3;i++){if(b[i]!==a[i])return b[i]>a[i];}return false;
16
16
  }
17
17
  export function updatePlan({engine,bin,real,home=os.homedir(),channel='latest'}){
18
- if(!['claude','codex'].includes(engine)||!path.isAbsolute(bin||'')||!real)return null;
19
- const name=engine==='claude'?'Claude Code':'Codex';
20
- const brew=real.match(/^(\/opt\/homebrew|\/usr\/local)\/Caskroom\/(claude-code(?:@latest)?|codex)\//);
21
- if(brew&&((engine==='claude'&&brew[2].startsWith('claude-code'))||(engine==='codex'&&brew[2]==='codex')))
22
- return {name,file:`${brew[1]}/bin/brew`,args:['upgrade','--cask',brew[2]],feed:`https://formulae.brew.sh/api/cask/${brew[2]}.json`,format:'brew'};
23
- const pkg=engine==='claude'?'@anthropic-ai/claude-code':'@openai/codex';
24
- const tail=`/lib/node_modules/${pkg}/`;
25
- const at=real.indexOf(tail);
26
- if(at>0){const prefix=real.slice(0,at);return {name,file:path.join(prefix,'bin/npm'),args:['install','--global','--prefix',prefix,`${pkg}@${channel}`],env:{PATH:`${prefix}/bin:${process.env.PATH||''}`},feed:`https://registry.npmjs.org/${pkg}/${channel}`,format:'npm'};}
27
- if(engine==='claude'&&real.startsWith(path.join(home,'.local/share/claude/versions')+'/'))return {name,file:bin,args:['update'],feed:`https://downloads.claude.ai/claude-code-releases/${channel}`,format:'text'};
28
- const standalone=real.match(/^(.*)\/packages\/standalone\/(?:releases|current)\//);
29
- if(engine==='codex'&&standalone)return {name,file:'/bin/bash',args:['-o','pipefail','-c','/usr/bin/curl -fsSL --connect-timeout 10 --max-time 60 https://chatgpt.com/codex/install.sh | /bin/sh'],env:{CODEX_HOME:standalone[1],CODEX_INSTALL_DIR:path.dirname(bin)},feed:'https://releases.openai.com/codex/channels/latest',format:'codex'};
30
- return null;
18
+ if(!harnessDefinition(engine))return null;
19
+ return harnessFor(engine).updatePlan({bin,real,home,channel});
31
20
  }
32
- const json=file=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return {}}};
33
21
  export async function installedAgent(engine,config){
34
- const bin=engine==='codex'?config.codexBin:config.claudeBin;
22
+ const harness=harnessFor(engine), bin=harness.binary(config);
35
23
  if(!bin)throw Error('This agent is not installed.');
36
24
  const home=config.home||os.homedir();
37
- const settings=engine==='claude'?json(path.join(process.env.CLAUDE_CONFIG_DIR||path.join(home,'.claude'),'settings.json')):{};
38
- const cleanEnv=terminalEnv();
39
- const env=engine==='codex'?codexLaunchEnv(bin,cleanEnv):cleanEnv;
25
+ const settings=harness.updateSettings({home});
26
+ const env=harness.launchEnv(bin,terminalEnv());
40
27
  // Version inspection must not itself auto-update anything.
41
28
  const {stdout}=await exec(bin,['--version'],{timeout:8000,maxBuffer:16384,env:{...env,DISABLE_AUTOUPDATER:'1'}});
42
29
  const version=stdout.match(/\b\d+\.\d+\.\d+(?:-[\w.-]+)?\b/)?.[0];
@@ -59,7 +46,7 @@ export class AgentUpdates{
59
46
  }
60
47
  status(engine){return this.entries.get(engine)||{engine,state:'unknown'};}
61
48
  check(engine,force=false){
62
- if(!['claude','codex'].includes(engine))return Promise.reject(Error('Unknown agent.'));
49
+ if(!harnessDefinition(engine))return Promise.reject(Error('Unknown agent.'));
63
50
  if(this.starts.has(engine)||this.status(engine).state==='running')return Promise.resolve(this.status(engine));
64
51
  if(this.checks.has(engine))return this.checks.get(engine);
65
52
  const saved=this.status(engine);if(!force&&saved.checkedAt&&this.now()-saved.checkedAt<3600000)return Promise.resolve(saved);
@@ -105,7 +92,7 @@ export class AgentUpdates{
105
92
  if(!this.plans.has(engine))throw Error('Click Update now first.');
106
93
  switch(p.action){
107
94
  case 'open':return this.terminals.read(engine,0);
108
- case 'read':return this.terminals.read(engine,p.offset);
95
+ case 'read':return this.terminals.read(engine,p.offset,p.wait);
109
96
  case 'write':return this.terminals.write(engine,p.data);
110
97
  case 'resize':return this.terminals.resize(engine,p.cols,p.rows);
111
98
  case 'close':return this.terminals.close(engine);
@@ -178,6 +178,36 @@ export function createAnalytics({ config, version, dir, client, env = process.en
178
178
  report_version: Number(payload.v ?? 1),
179
179
  error: payload.error ?? null,
180
180
  happened_at: String(payload.ts ?? ''),
181
+ // THE VERSION IT CRASHED ON, WHICH IS NOT ALWAYS THE ONE SENDING IT.
182
+ // A report written by a dying process is drained on the NEXT launch,
183
+ // and that launch may be a newer app: `base.app_version` then files
184
+ // 0.1.11's crash against 0.1.12. It did, for three of the six window
185
+ // errors the launch report attributes to 0.1.12 (PostHog, 2026-10-08),
186
+ // and a version asked to answer for the bugs of the one before it is
187
+ // how a release that fixed something reads as a release that broke it.
188
+ // The report has carried the right answer all along.
189
+ app_version: String(payload.app?.version ?? base.app_version),
190
+ // WHY THE WINDOW DIED, which is the only thing a `renderer-gone`
191
+ // report is for and the one thing it was not carrying.
192
+ // `installCrashReports` collects Electron's `details.reason` and
193
+ // `details.exitCode` and says in its own comment why they are safe:
194
+ // the reason is Electron's closed vocabulary (`crashed`, `oom`,
195
+ // `killed`, `launch-failed` and the rest) and carries nothing of
196
+ // anyone's. They were written to disk and then dropped here, because
197
+ // this list names its properties by hand. So the launch report's one
198
+ // dead window says `render process gone` and nothing else, and `oom`
199
+ // after three and a half hours (a leak) could not be told apart from a
200
+ // hard fault (not a leak). Measured 2026-10-08.
201
+ //
202
+ // Absent rather than empty when there is nothing to say: a null reason
203
+ // on the thirteen crashes that never had one reads as an answer in a
204
+ // chart. A reason longer than Electron's longest word is a bug
205
+ // upstream rather than a reason, and is left out too.
206
+ ...(typeof payload.reason === 'string' && payload.reason && payload.reason.length <= 40
207
+ ? { crash_reason: payload.reason } : {}),
208
+ ...(Number.isFinite(payload.exitCode) ? { exit_code: Number(payload.exitCode) } : {}),
209
+ ...(typeof payload.processType === 'string' && payload.processType && payload.processType.length <= 40
210
+ ? { process_type: payload.processType } : {}),
181
211
  });
182
212
  },
183
213
 
@@ -1,338 +1,72 @@
1
- // Where Claude Code actually is, on a machine that is not hers.
1
+ // Where Claude Code actually is, on a machine that is not ours.
2
2
  //
3
- // Agentbox assumed one path, `~/.local/bin/claude`, which is where Claude Code's
4
- // own installer puts it. That is one of several normal answers: install through
5
- // npm or Homebrew and the binary is somewhere else entirely. For those people
6
- // the app found nothing, said "missing" on a grey dot in Settings, and gave no
7
- // sentence and no next step. It read as broken rather than as unfinished.
3
+ // THIS FILE IS NOW THE NAMES AND NOTHING ELSE. The search, the registry entry
4
+ // that declares Claude Code, the measured notes about where it installs and the
5
+ // `found` / `certain` rule all live in main/engine-bin.mjs, which does the same
6
+ // job for every coding agent the app can run. Until 2026-10-08 this was 338
7
+ // lines and main/codex-bin.mjs was 279, and they were two copies of one idea:
8
+ // adding a harness meant a third file and three hundred lines of test.
8
9
  //
9
- // THE RULE this file exists to keep: never tell someone Claude Code is
10
- // missing unless we are certain it is.
11
- //
12
- // So there are two different questions here and they must never be answered by
13
- // the same value. `found` is whether a runnable file is really there, and it
14
- // decides which path gets spawned. `certain` is whether we are entitled to SAY
15
- // it is missing, and everything below exists to make `certain` hard to earn.
16
- //
17
- // Four steps before an absence is ever claimed, cheapest first.
18
- //
19
- // KNOWN PATHS. A filesystem check is instant, spawns nothing, and is the right
20
- // answer for most machines. The list is every normal home for this binary: the
21
- // installer's own directory, Claude Code's local install (what
22
- // `claude migrate-installer` leaves behind), Homebrew on Apple Silicon and on
23
- // Intel, MacPorts, and the default bin of every package manager that is not npm.
24
- //
25
- // VERSION MANAGER DIRECTORIES. nvm, fnm, n and asdf each keep one bin directory
26
- // PER INSTALLED NODE, under a name nobody can guess, so they are read rather
27
- // than listed. A global npm install under any of them lands there and nowhere
28
- // else, and this is the case the shell step used to be the only cover for.
29
- //
30
- // THE SHELL. The catch-all for installs no list can enumerate: volta, bun, a
31
- // custom npm prefix. It must be a LOGIN, INTERACTIVE shell, because those
32
- // managers put themselves on PATH from `.zprofile` and `.zshrc` and nowhere
33
- // else. And it is needed at all because a double-clicked Electron app inherits
34
- // launchd's PATH, which is /usr/bin:/bin:/usr/sbin:/sbin and has never heard of
35
- // any of them. That is the whole reason an npm install is obvious in a terminal
36
- // and invisible to the app. It is asked more than once now, because ONE probe
37
- // that fails to start is not a fact about anybody's Mac.
10
+ // WHAT IS KEPT HERE is the vocabulary the rest of the app already imports --
11
+ // main/config.mjs, main/settings.mjs, scripts/read-claude-commands.mjs,
12
+ // scripts/read-claude-models.mjs -- plus `askShell` and `shellProbes`, which
13
+ // several tests reach for through this file because Claude Code is the binary the
14
+ // app will not open an inbox without.
38
15
  //
39
- // EVIDENCE THAT IT LIVES HERE ANYWAY. Last, and it decides nothing about which
40
- // binary to run: it only takes away our right to announce an absence. Claude
41
- // Code leaves a home folder, a settings file and a projects directory behind
42
- // the moment it is used once. If those are on this Mac then Claude Code is on
43
- // this Mac, whatever our search of PATH did or did not manage, and the honest
44
- // thing on the screen is silence rather than a sentence telling somebody to
45
- // install software they are already running. A dangling `claude` symlink counts
46
- // the same way, and it is a real state: the installer points ~/.local/bin/claude
47
- // at a versioned file and swaps that file on every update.
16
+ // THE RULE THIS FILE ARGUED FOR AND engine-bin.mjs KEEPS: never tell somebody
17
+ // Claude Code is missing unless we are certain it is. `found` is whether a
18
+ // runnable file is really there, and it decides which path gets spawned.
19
+ // `certain` is whether we are entitled to SAY it is missing.
48
20
  //
49
21
  // Nothing here overrides the config file. An explicit `claudeBin` in
50
- // zero.config.json wins untouched and is never searched around, because that
51
- // file promises explicit paths with no discovery, and a search that
52
- // second-guesses a written setting would break that promise.
22
+ // zero.config.json wins untouched and is never searched around.
53
23
 
54
- import fs from 'node:fs';
55
24
  import os from 'node:os';
56
- import path from 'node:path';
57
- import { execFileSync } from 'node:child_process';
58
-
59
- // In order. The first that exists wins, so the installer's path stays first and
60
- // a machine that has always worked keeps resolving exactly as it did.
61
- export function candidatePaths(home = os.homedir()) {
62
- return [
63
- path.join(home, '.local/bin/claude'), // the installer
64
- path.join(home, '.claude/local/claude'), // claude migrate-installer
65
- '/opt/homebrew/bin/claude', // Homebrew, Apple Silicon
66
- '/usr/local/bin/claude', // Homebrew on Intel, and npm -g
67
- path.join(home, '.bun/bin/claude'), // bun
68
- path.join(home, '.volta/bin/claude'), // volta
69
- path.join(home, '.asdf/shims/claude'), // asdf, through its shims
70
- path.join(home, '.npm-global/bin/claude'), // the two npm prefixes people
71
- path.join(home, '.npm-packages/bin/claude'), // actually write in a guide
72
- path.join(home, '.yarn/bin/claude'), // yarn global
73
- path.join(home, 'Library/pnpm/claude'), // pnpm's own bin on macOS
74
- path.join(home, '.local/share/pnpm/claude'), // pnpm's own bin on Linux
75
- path.join(home, '.deno/bin/claude'), // deno
76
- path.join(home, 'bin/claude'), // a hand-rolled ~/bin on PATH
77
- '/opt/local/bin/claude', // MacPorts
78
- '/usr/bin/claude', // packaged by something system-wide
79
- ];
80
- }
81
-
82
- // One bin directory per installed Node, under a name no list can hold. Read
83
- // rather than guessed, and a directory that is not there just contributes
84
- // nothing. This is where a global npm install under nvm, fnm, n or asdf ends
85
- // up, and until now only the shell could reach it.
86
- export function versionedPaths(home = os.homedir(), readdir = defaultReaddir) {
87
- const under = (dir, tail) => readdir(path.join(home, dir)).map((v) => path.join(home, dir, v, tail));
88
- return [
89
- ...under('.nvm/versions/node', 'bin/claude'),
90
- ...under('.local/share/fnm/node-versions', 'installation/bin/claude'),
91
- ...under('Library/Application Support/fnm/node-versions', 'installation/bin/claude'),
92
- ...under('n/versions/node', 'bin/claude'),
93
- ...under('.asdf/installs/nodejs', 'bin/claude'),
94
- ];
95
- }
96
-
97
- function defaultReaddir(dir) {
98
- try { return fs.readdirSync(dir); } catch { return []; }
99
- }
100
-
101
- // THE COPY THE CLAUDE APP KEEPS FOR ITSELF, one folder per version, newest
102
- // first. Somebody who has only ever used Claude Code inside the Claude app has
103
- // this and nothing else (w-9f6975906c, measured 2026-10-05: 2.1.209 there, and
104
- // it shared the CLI's sign-in). It is searched LAST, after the shell, because it
105
- // updates when the app does and a real install is fresher.
106
- export function appCopyPaths(home = os.homedir(), readdir = defaultReaddir) {
107
- const dir = path.join(home, 'Library/Application Support/Claude/claude-code');
108
- return readdir(dir)
109
- .filter((v) => /^\d+(\.\d+)*$/.test(v))
110
- .sort(newestFirst)
111
- .map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
112
- }
113
-
114
- function newestFirst(a, b) {
115
- const x = a.split('.').map(Number), y = b.split('.').map(Number);
116
- for (let i = 0; i < Math.max(x.length, y.length); i++) {
117
- if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
118
- }
119
- return 0;
120
- }
121
-
122
- // WHAT CLAUDE CODE LEAVES BEHIND. Any one of these means it has run on this
123
- // Mac. None of them is a binary and none of them is ever spawned; they exist
124
- // only to stop the app announcing an absence it cannot support.
125
- export function evidencePaths(home = os.homedir()) {
126
- return [
127
- path.join(home, '.claude.json'), // its own config file
128
- path.join(home, '.claude/settings.json'),
129
- path.join(home, '.claude/projects'), // one folder per project it has opened
130
- path.join(home, '.claude/statsig'),
131
- path.join(home, '.claude/.credentials.json'), // it has been logged in
132
- path.join(home, '.claude/history.jsonl'),
133
- path.join(home, '.local/share/claude'), // the installer's version store
134
- ];
135
- }
136
-
137
- // A path that is a symlink to nothing. `existsSync` follows the link and says
138
- // false, which is indistinguishable from nothing being there at all, and the
139
- // two mean opposite things: an update that has swapped the versioned file out
140
- // from under ~/.local/bin/claude leaves exactly this for a moment.
141
- function defaultDangles(p) {
142
- try { return !!fs.lstatSync(p) && !fs.existsSync(p); } catch { return false; }
143
- }
144
-
145
- export function installEvidence({ home = os.homedir(), exists = fs.existsSync, dangles = defaultDangles } = {}) {
146
- for (const p of evidencePaths(home)) if (exists(p)) return p;
147
- for (const p of candidatePaths(home)) if (dangles(p)) return p;
148
- return null;
149
- }
25
+ import {
26
+ ENGINES, findEngine, resolveEngine, forgetEngine, installEvidence as evidenceFor,
27
+ versionedBinPaths, askShell, shellProbes,
28
+ } from './engine-bin.mjs';
150
29
 
151
- export const INSTALL_URL = 'https://code.claude.com/docs/en/setup';
30
+ export { askShell, shellProbes };
152
31
 
153
- // The shells worth asking, in order, and never the same one twice. Hers first
154
- // because it is the one that has her PATH in it; the other two because a
155
- // $SHELL that will not start is a fact about $SHELL and not about Claude Code.
156
- export function shellProbes(env = process.env) {
157
- const preferred = env.SHELL || '/bin/zsh';
158
- const out = [[preferred, '-lic', 4000], [preferred, '-lc', 2500]];
159
- for (const other of ['/bin/zsh', '/bin/bash']) {
160
- if (other !== preferred) out.push([other, '-lic', 2500]);
161
- }
162
- return out;
163
- }
32
+ const CLAUDE = ENGINES.claude;
164
33
 
165
- // Ask the user's own login shell, and go on asking until one of them answers.
166
- // Anything that is not an absolute path to a file that exists is treated as no
167
- // path: `command -v` also prints shell functions and aliases, and a function
168
- // name handed to spawn is a crash with a worse message than "not found".
169
- //
170
- // `answered` is the strict half. It is true only when every probe that ran came
171
- // back with a real exit status, and at least one did. One shell that timed out
172
- // alongside one that said no is not a machine we know anything about.
173
- //
174
- // `bin` is a parameter rather than the word `claude` baked into the command
175
- // because Codex is a second engine (her ask, 2026-08-25) and it is found in the
176
- // same way: same shells, same "two different silences" rule, same refusal to
177
- // read a shell function name as a path. It is DEFAULTED, so every existing
178
- // caller and every existing test asks about Claude Code as it always did, and
179
- // that matters more here than it would anywhere else: this is the binary the
180
- // app will not open an inbox without.
181
- /**
182
- * WHAT MAY BE HANDED TO A LOGIN SHELL, AND WHY THIS IS CHECKED AT ALL.
183
- *
184
- * The probe below interpolates `bin` into a string a LOGIN, INTERACTIVE shell
185
- * executes, with her profile loaded, as her. Both callers today pass a literal
186
- * ('claude' here, 'codex' in main/codex-bin.mjs), so nothing is exploitable
187
- * now. But the entire direction of the second-engine work is a binary somebody
188
- * can configure, and a `codexBin` read out of zero.config.json reaching this
189
- * argument is one plausible slice away; at that point `command -v x; curl | sh`
190
- * is a string in a file that runs itself.
191
- *
192
- * This function looks up program NAMES, so a name is all it takes. An allowlist
193
- * rather than quoting, because quoting is a thing a later edit can undo without
194
- * noticing and a shape check is not.
195
- */
196
- const PROGRAM_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
34
+ export const INSTALL_URL = CLAUDE.installUrl;
197
35
 
198
- export function askShell({ env = process.env, exists = fs.existsSync, run = defaultRun, bin = 'claude' } = {}) {
199
- if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
200
- throw new Error(`askShell looks up a program name, and ${JSON.stringify(bin)} is not one`);
201
- }
202
- let said = false;
203
- let inconclusive = false;
204
- for (const [shell, flags, timeout] of shellProbes(env)) {
205
- let out = '';
206
- try {
207
- out = run(shell, [flags, `command -v ${bin}`], timeout);
208
- } catch (e) {
209
- // TWO DIFFERENT SILENCES, AND ONLY ONE OF THEM IS AN ANSWER.A shell that
210
- // ran and exited non-zero has told us claude is not on PATH, and that is
211
- // an answer. A shell that could not be started, or that ran past the
212
- // timeout, has told us nothing at all, and reporting nothing as "not
213
- // installed" is what put a line about missing software on the screen of
214
- // people who have it.
215
- if (exitedSayingNo(e)) said = true; else inconclusive = true;
216
- continue;
217
- }
218
- const line = String(out).split('\n').map((s) => s.trim()).filter(Boolean).pop();
219
- if (line && line.startsWith('/') && exists(line)) return { path: line, answered: true };
220
- said = true;
221
- }
222
- return { path: null, answered: said && !inconclusive };
36
+ /** Every normal home for this binary, in order; the first that exists wins. */
37
+ export function candidatePaths(home = os.homedir()) {
38
+ return CLAUDE.candidates(home, () => []);
223
39
  }
224
40
 
225
- // The shell ran to completion and returned a non-zero exit code, which for
226
- // `command -v` is how "not on PATH" is spelt. A timeout (SIGTERM, ETIMEDOUT)
227
- // and a shell that does not exist (ENOENT) both arrive here too, and neither
228
- // of them is a fact about Claude Code.
229
- function exitedSayingNo(e) {
230
- if (!e || typeof e !== 'object') return false;
231
- if (e.signal || e.code === 'ETIMEDOUT' || e.code === 'ENOENT') return false;
232
- return typeof e.status === 'number';
41
+ /** One bin directory per installed Node, read rather than guessed. */
42
+ export function versionedPaths(home = os.homedir(), readdir = undefined) {
43
+ return versionedBinPaths('claude', home, readdir);
233
44
  }
234
45
 
235
- // askShell used to answer with a path or null, and both a real answer and a
236
- // failed probe were null. Anything still handing back the old shape is read as
237
- // an answer, because that is what it always meant.
238
- function shellSaid(result) {
239
- if (typeof result === 'string') return { path: result, answered: true };
240
- if (!result || typeof result !== 'object') return { path: null, answered: true };
241
- return { path: result.path ?? null, answered: result.answered !== false };
46
+ /** The copy the Claude desktop app keeps for itself, newest version first. */
47
+ export function appCopyPaths(home = os.homedir(), readdir = undefined) {
48
+ return CLAUDE.appCopies(home, readdir);
242
49
  }
243
50
 
244
- function defaultRun(shell, args, timeout = 4000) {
245
- return execFileSync(shell, args, {
246
- encoding: 'utf8',
247
- // stdin ignored on purpose: an interactive shell handed a live terminal can
248
- // sit waiting for input, and this runs on the boot path.
249
- stdio: ['ignore', 'pipe', 'ignore'],
250
- timeout,
251
- });
51
+ /** What Claude Code leaves behind. None of it is ever spawned. */
52
+ export function evidencePaths(home = os.homedir()) {
53
+ return CLAUDE.evidence(home, {});
252
54
  }
253
55
 
254
- // The whole search, as a pure function of its inputs so a test can put Claude
255
- // Code anywhere without touching the machine it runs on.
256
- //
257
- // Returns { path, found, certain, from, searched, evidence }. `found` is the
258
- // only claim about the world: it means a file is really there. `certain` says
259
- // whether we are allowed to tell somebody it is NOT there, so a screen can stay
260
- // quiet rather than announce an absence it has not established. They come apart
261
- // in exactly one case that is not a failure, and it is the case worth keeping
262
- // honest: she has set `claudeBin` to a path that does not exist, so the path
263
- // stays HERS (anything that spawns it should fail naming the path she chose,
264
- // not one we picked) while the screen says it was not found and offers the
265
- // install link, the same as any other machine.
266
- export function findClaudeBin({
267
- configured = null,
268
- home = os.homedir(),
269
- exists = fs.existsSync,
270
- shellLookup = askShell,
271
- readdir = defaultReaddir,
272
- dangles = defaultDangles,
273
- } = {}) {
274
- if (configured) {
275
- return {
276
- path: configured, found: exists(configured), certain: true, from: 'settings',
277
- searched: [], evidence: null,
278
- };
279
- }
280
-
281
- const searched = [...candidatePaths(home), ...versionedPaths(home, readdir)];
282
- for (const candidate of searched) {
283
- if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'disk', searched, evidence: null };
284
- }
285
-
286
- const fromShell = shellSaid(shellLookup());
287
- if (fromShell.path) return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
288
-
289
- for (const candidate of appCopyPaths(home, readdir)) {
290
- if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
291
- }
292
-
293
- // NOT FOUND, AND WHETHER THAT IS KNOWN. Every cheap path missed and every
294
- // shell we could reach missed, so the last question is whether this Mac shows
295
- // any sign of Claude Code anyway. If it does, we looked in the wrong places
296
- // rather than at an empty machine, and nothing may be said out loud.
297
- const evidence = installEvidence({ home, exists, dangles });
298
- return {
299
- path: null,
300
- found: false,
301
- certain: fromShell.answered && !evidence,
302
- from: null,
303
- searched,
304
- evidence,
305
- };
56
+ export function installEvidence({ home = os.homedir(), exists, dangles } = {}) {
57
+ return evidenceFor('claude', { home, env: {}, exists, dangles });
306
58
  }
307
59
 
308
- /* ------------------------------- the cache -------------------------------- */
309
- // Only the SHELL step is remembered, and only briefly. Nothing else is: the
310
- // path checks are stat calls, and caching them is how the settings screen ends
311
- // up telling someone to install Claude Code and then still saying "not found"
312
- // after they have. The sentence on that screen makes a promise and a cached
313
- // answer breaks it.
314
- //
315
- // The shell is the one part worth holding, because it is up to three
316
- // subprocesses and a screen she toggles a switch on reads settings again on
317
- // every write. Thirty seconds is long enough that a burst of writes pays for it
318
- // once, and short enough that it is never the reason someone is still stuck.
319
-
320
- const SHELL_TTL = 30_000;
321
- let shellAnswer = null;
322
- let shellAskedAt = 0;
323
-
324
- function rememberedShellLookup(now, lookup) {
325
- if (shellAskedAt && now - shellAskedAt < SHELL_TTL) return shellAnswer;
326
- shellAnswer = lookup();
327
- shellAskedAt = now;
328
- return shellAnswer;
60
+ /** The whole search. See main/engine-bin.mjs for what each answer means. */
61
+ export function findClaudeBin(where = {}) {
62
+ return findEngine('claude', where);
329
63
  }
330
64
 
331
- export function resolveClaudeBin(configured = null, { now = Date.now(), shellLookup = askShell, ...where } = {}) {
332
- return findClaudeBin({ ...where, configured, shellLookup: () => rememberedShellLookup(now, shellLookup) });
65
+ /** The same search with the shell step held for thirty seconds. */
66
+ export function resolveClaudeBin(configured = null, where = {}) {
67
+ return resolveEngine('claude', configured, where);
333
68
  }
334
69
 
335
70
  export function forgetClaudeBin() {
336
- shellAnswer = null;
337
- shellAskedAt = 0;
71
+ forgetEngine('claude');
338
72
  }
@@ -16,12 +16,18 @@ function turnClosed(message) {
16
16
  // before the session is closed anyway. Measured wakes arrive in about a second.
17
17
  const WAKE_GRACE_MS = 2 * 60 * 1000;
18
18
 
19
- export function claudeStreamArgs(args) {
19
+ // THE PROMPT LEAVES THE LINE WHEN REPLIES ARRIVE ON STDIN. Both the print flag
20
+ // and the input-format flags are read off the engine's `run` definition, so an
21
+ // engine of this family that spells either of them differently is a definition
22
+ // and not a change here. An engine with no `input` cannot take corrections on
23
+ // stdin, and gets its line back as it was.
24
+ export function streamInputArgs(run, args) {
25
+ if (!run?.input) return [...args];
20
26
  const copy = [...args];
21
- const index = copy.indexOf('-p');
27
+ const index = copy.indexOf(run.print);
22
28
  if (index < 0) throw Error('Missing print prompt');
23
29
  copy.splice(index + 1, 1);
24
- return [...copy, '--input-format', 'stream-json', '--replay-user-messages'];
30
+ return [...copy, ...run.input];
25
31
  }
26
32
 
27
33
  // Replay-user-messages acknowledges the exact UUID when Claude consumes it.
@@ -0,0 +1,54 @@
1
+ // An npm Claude Code from before the package shipped a native binary is a
2
+ // `#!/usr/bin/env node` script. Opened from the Dock, the app's PATH is
3
+ // launchd's four folders, and with nvm lazy-loaded no profile adds node, so the
4
+ // app found that Claude Code and every worker died with
5
+ // `env: node: No such file or directory`
6
+ // (tests/an-old-npm-claude-code-starts-as-a-worker-from-the-dock).
7
+ //
8
+ // Narrower than main/codex-launch-env.mjs on purpose. A worker's PATH is the
9
+ // PATH its agent's own commands run under, so it is changed only when this
10
+ // binary needs node and none is on it, and the node found is added at the end,
11
+ // where it cannot shadow anything the person's own PATH chose.
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { candidatePaths, versionedPaths } from './claude-bin.mjs';
16
+
17
+ function executable(file) {
18
+ try {
19
+ fs.accessSync(file, fs.constants.X_OK);
20
+ return fs.statSync(file).isFile();
21
+ } catch { return false; }
22
+ }
23
+
24
+ /** Whether `bin`, followed through its links, is a script run by node. */
25
+ function runsOnNode(bin) {
26
+ try {
27
+ const fd = fs.openSync(fs.realpathSync(bin), 'r');
28
+ const head = Buffer.alloc(128);
29
+ const read = fs.readSync(fd, head, 0, head.length, 0);
30
+ fs.closeSync(fd);
31
+ const first = head.subarray(0, read).toString('utf8').split('\n')[0];
32
+ return /^#!.*\bnode\b/.test(first);
33
+ } catch { return false; }
34
+ }
35
+
36
+ export function claudeLaunchEnv(bin, source = process.env, {
37
+ home = source.HOME || os.homedir(), exists = executable, readdir,
38
+ } = {}) {
39
+ if (!bin || !runsOnNode(bin)) return { ...source };
40
+ const inherited = (source.PATH || '').split(path.delimiter).filter(Boolean);
41
+ if (inherited.some(dir => exists(path.join(dir, 'node')))) return { ...source };
42
+ // The node npm installed it with sits beside the link, or beside the real
43
+ // file's package; then the places a Node install usually lives.
44
+ let real = bin;
45
+ try { real = fs.realpathSync(bin); } catch { /* keep the link */ }
46
+ const directories = [...new Set([
47
+ path.dirname(bin), path.dirname(real),
48
+ ...candidatePaths(home).map(path.dirname),
49
+ ...versionedPaths(home, readdir).map(path.dirname),
50
+ ])];
51
+ const nodeDir = directories.find(dir => exists(path.join(dir, 'node')));
52
+ if (!nodeDir) return { ...source };
53
+ return { ...source, PATH: [...inherited, nodeDir].join(path.delimiter) };
54
+ }