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.
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +25 -1
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// WHERE A CODING AGENT ACTUALLY IS, on a machine that is not ours.
|
|
2
|
+
//
|
|
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.
|
|
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.
|
|
15
|
+
//
|
|
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`).
|
|
52
|
+
|
|
53
|
+
import fs from 'node:fs';
|
|
54
|
+
import os from 'node:os';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
import { execFileSync } from 'node:child_process';
|
|
57
|
+
|
|
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
|
+
*/
|
|
68
|
+
export function sharedBinDirs(home = os.homedir()) {
|
|
69
|
+
return [
|
|
70
|
+
'/opt/homebrew/bin', // Homebrew, Apple Silicon
|
|
71
|
+
'/usr/local/bin', // Homebrew on Intel, and npm -g
|
|
72
|
+
path.join(home, '.bun/bin'), // bun
|
|
73
|
+
path.join(home, '.volta/bin'), // volta
|
|
74
|
+
path.join(home, '.asdf/shims'), // asdf, through its shims
|
|
75
|
+
path.join(home, '.npm-global/bin'), // the two npm prefixes people
|
|
76
|
+
path.join(home, '.npm-packages/bin'), // actually write in a guide
|
|
77
|
+
path.join(home, '.yarn/bin'), // yarn global
|
|
78
|
+
path.join(home, 'Library/pnpm'), // pnpm's own bin on macOS
|
|
79
|
+
path.join(home, '.local/share/pnpm'), // pnpm's own bin on Linux
|
|
80
|
+
path.join(home, '.deno/bin'), // deno
|
|
81
|
+
path.join(home, 'bin'), // a hand-rolled ~/bin on PATH
|
|
82
|
+
'/opt/local/bin', // MacPorts
|
|
83
|
+
'/usr/bin', // packaged by something system-wide
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
export function versionedBinPaths(bin, home = os.homedir(), readdir = defaultReaddir) {
|
|
94
|
+
const under = (dir, tail) => readdir(path.join(home, dir)).map((v) => path.join(home, dir, v, tail));
|
|
95
|
+
return [
|
|
96
|
+
...under('.nvm/versions/node', `bin/${bin}`),
|
|
97
|
+
...under('.local/share/fnm/node-versions', `installation/bin/${bin}`),
|
|
98
|
+
...under('Library/Application Support/fnm/node-versions', `installation/bin/${bin}`),
|
|
99
|
+
...under('n/versions/node', `bin/${bin}`),
|
|
100
|
+
...under('.asdf/installs/nodejs', `bin/${bin}`),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultReaddir(dir) {
|
|
105
|
+
try { return fs.readdirSync(dir); } catch { return []; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
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.
|
|
114
|
+
*/
|
|
115
|
+
function defaultDangles(p) {
|
|
116
|
+
try { return !!fs.lstatSync(p) && !fs.existsSync(p); } catch { return false; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Newest version folder first, by number and not by spelling. */
|
|
120
|
+
function newestFirst(a, b) {
|
|
121
|
+
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
122
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
123
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
|
|
124
|
+
}
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------- the registry ----------------------------- */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* WHAT AN ENGINE IS, and the whole of what a new harness has to say:
|
|
132
|
+
*
|
|
133
|
+
* bin the program name, which is also its key here
|
|
134
|
+
* name what it is called on a screen
|
|
135
|
+
* 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
|
|
138
|
+
* 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
|
|
141
|
+
*/
|
|
142
|
+
function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidence }) {
|
|
143
|
+
return {
|
|
144
|
+
bin,
|
|
145
|
+
name,
|
|
146
|
+
installUrl,
|
|
147
|
+
ownPaths,
|
|
148
|
+
appCopies,
|
|
149
|
+
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.
|
|
152
|
+
candidates(home = os.homedir(), readdir = defaultReaddir) {
|
|
153
|
+
return [
|
|
154
|
+
...ownPaths(home),
|
|
155
|
+
...sharedBinDirs(home).map((dir) => path.join(dir, bin)),
|
|
156
|
+
...versionedBinPaths(bin, home, readdir),
|
|
157
|
+
];
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const ENGINES = {
|
|
163
|
+
/**
|
|
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.
|
|
171
|
+
*
|
|
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.
|
|
178
|
+
*/
|
|
179
|
+
claude: engine({
|
|
180
|
+
bin: 'claude',
|
|
181
|
+
name: 'Claude Code',
|
|
182
|
+
installUrl: 'https://code.claude.com/docs/en/setup',
|
|
183
|
+
ownPaths: (home) => [
|
|
184
|
+
path.join(home, '.local/bin/claude'), // the installer
|
|
185
|
+
path.join(home, '.claude/local/claude'), // claude migrate-installer
|
|
186
|
+
],
|
|
187
|
+
appCopies: (home, readdir = defaultReaddir) => {
|
|
188
|
+
const dir = path.join(home, 'Library/Application Support/Claude/claude-code');
|
|
189
|
+
return readdir(dir)
|
|
190
|
+
.filter((v) => /^\d+(\.\d+)*$/.test(v))
|
|
191
|
+
.sort(newestFirst)
|
|
192
|
+
.map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
|
|
193
|
+
},
|
|
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.
|
|
196
|
+
evidence: (home) => [
|
|
197
|
+
path.join(home, '.claude.json'), // its own config file
|
|
198
|
+
path.join(home, '.claude/settings.json'),
|
|
199
|
+
path.join(home, '.claude/projects'), // one folder per project it has opened
|
|
200
|
+
path.join(home, '.claude/statsig'),
|
|
201
|
+
path.join(home, '.claude/.credentials.json'), // it has been logged in
|
|
202
|
+
path.join(home, '.claude/history.jsonl'),
|
|
203
|
+
path.join(home, '.local/share/claude'), // the installer's version store
|
|
204
|
+
],
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
/**
|
|
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.
|
|
230
|
+
*
|
|
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.
|
|
238
|
+
*/
|
|
239
|
+
codex: engine({
|
|
240
|
+
bin: 'codex',
|
|
241
|
+
name: 'Codex',
|
|
242
|
+
installUrl: 'https://learn.chatgpt.com/docs/codex/cli',
|
|
243
|
+
ownPaths: (home) => [
|
|
244
|
+
path.join(home, '.local/bin/codex'), // the shell installer's link
|
|
245
|
+
path.join(home, '.codex/packages/standalone/current/bin/codex'),
|
|
246
|
+
path.join(home, '.codex/packages/standalone/current/codex'),
|
|
247
|
+
],
|
|
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`.
|
|
252
|
+
appCopies: (home) => [
|
|
253
|
+
'ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
|
|
254
|
+
'Codex.app/Contents/Resources/codex',
|
|
255
|
+
].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.
|
|
259
|
+
evidence: (home, env = process.env) => {
|
|
260
|
+
const homes = [env?.CODEX_HOME, path.join(home, '.codex')].filter(Boolean);
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const dir of new Set(homes)) {
|
|
263
|
+
out.push(
|
|
264
|
+
path.join(dir, 'auth.json'), // it has been logged in
|
|
265
|
+
path.join(dir, 'config.toml'),
|
|
266
|
+
path.join(dir, 'sessions'), // one folder tree of past runs
|
|
267
|
+
path.join(dir, 'history.jsonl'),
|
|
268
|
+
path.join(dir, 'packages/standalone/releases'), // the installer's version store
|
|
269
|
+
dir, // and the directory itself, last
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export function engineKeys() {
|
|
278
|
+
return Object.keys(ENGINES);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function engineFor(key) {
|
|
282
|
+
const found = ENGINES[key];
|
|
283
|
+
if (!found) throw new Error(`no coding agent is declared as ${JSON.stringify(key)}`);
|
|
284
|
+
return found;
|
|
285
|
+
}
|
|
286
|
+
|
|
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.
|
|
292
|
+
export function shellProbes(env = process.env) {
|
|
293
|
+
const preferred = env.SHELL || '/bin/zsh';
|
|
294
|
+
const out = [[preferred, '-lic', 4000], [preferred, '-lc', 2500]];
|
|
295
|
+
for (const other of ['/bin/zsh', '/bin/bash']) {
|
|
296
|
+
if (other !== preferred) out.push([other, '-lic', 2500]);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
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.
|
|
314
|
+
*/
|
|
315
|
+
const PROGRAM_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
316
|
+
|
|
317
|
+
/**
|
|
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.
|
|
332
|
+
*/
|
|
333
|
+
export function askShell({ env = process.env, exists = fs.existsSync, run = defaultRun, bin = 'claude' } = {}) {
|
|
334
|
+
if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
|
|
335
|
+
throw new Error(`askShell looks up a program name, and ${JSON.stringify(bin)} is not one`);
|
|
336
|
+
}
|
|
337
|
+
let said = false;
|
|
338
|
+
let inconclusive = false;
|
|
339
|
+
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;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const line = String(out).split('\n').map((s) => s.trim()).filter(Boolean).pop();
|
|
354
|
+
if (line && line.startsWith('/') && exists(line)) return { path: line, answered: true };
|
|
355
|
+
said = true;
|
|
356
|
+
}
|
|
357
|
+
return { path: null, answered: said && !inconclusive };
|
|
358
|
+
}
|
|
359
|
+
|
|
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.
|
|
364
|
+
function exitedSayingNo(e) {
|
|
365
|
+
if (!e || typeof e !== 'object') return false;
|
|
366
|
+
if (e.signal || e.code === 'ETIMEDOUT' || e.code === 'ENOENT') return false;
|
|
367
|
+
return typeof e.status === 'number';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
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,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
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.
|
|
383
|
+
function shellSaid(result) {
|
|
384
|
+
if (typeof result === 'string') return { path: result, answered: true };
|
|
385
|
+
if (!result || typeof result !== 'object') return { path: null, answered: true };
|
|
386
|
+
return { path: result.path ?? null, answered: result.answered !== false };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
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.
|
|
415
|
+
*/
|
|
416
|
+
export function askShellForAll(bins, { env = process.env, exists = fs.existsSync, run = defaultRun } = {}) {
|
|
417
|
+
for (const bin of bins) {
|
|
418
|
+
if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
|
|
419
|
+
throw new Error(`askShellForAll looks up program names, and ${JSON.stringify(bin)} is not one`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const command = bins.map((b) => `command -v ${b}`).join('; ');
|
|
423
|
+
for (const [shell, flags, timeout] of shellProbes(env)) {
|
|
424
|
+
let out = '';
|
|
425
|
+
try { out = run(shell, [flags, command], timeout); } catch { continue; }
|
|
426
|
+
const found = {};
|
|
427
|
+
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
|
+
if (!line.startsWith('/') || !exists(line)) continue;
|
|
431
|
+
const bin = bins.find((b) => path.basename(line) === b);
|
|
432
|
+
if (bin && !found[bin]) found[bin] = line;
|
|
433
|
+
}
|
|
434
|
+
// A shell that ran has had its turn, whatever it did or did not print.
|
|
435
|
+
return found;
|
|
436
|
+
}
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
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.
|
|
444
|
+
*/
|
|
445
|
+
export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
|
|
446
|
+
let found = {};
|
|
447
|
+
try { found = askShellForAll(engineKeys().map((k) => ENGINES[k].bin), how); } catch { return; }
|
|
448
|
+
for (const key of engineKeys()) {
|
|
449
|
+
const where = found[ENGINES[key].bin];
|
|
450
|
+
if (where) held.set(key, { answer: { path: where, answered: true }, at: now });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
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
|
+
*/
|
|
462
|
+
function askedShell(lookup) {
|
|
463
|
+
try { return shellSaid(lookup()); } catch { return { path: null, answered: false }; }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* -------------------------------- the search ------------------------------ */
|
|
467
|
+
|
|
468
|
+
/**
|
|
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.
|
|
473
|
+
*/
|
|
474
|
+
export function installEvidence(key, {
|
|
475
|
+
home = os.homedir(), env = process.env, exists = fs.existsSync, dangles = defaultDangles,
|
|
476
|
+
} = {}) {
|
|
477
|
+
const spec = engineFor(key);
|
|
478
|
+
for (const p of spec.evidence(home, env)) if (exists(p)) return p;
|
|
479
|
+
for (const p of spec.candidates(home, () => [])) if (dangles(p)) return p;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
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.
|
|
496
|
+
*/
|
|
497
|
+
export function findEngine(key, {
|
|
498
|
+
configured = null,
|
|
499
|
+
home = os.homedir(),
|
|
500
|
+
env = process.env,
|
|
501
|
+
exists = fs.existsSync,
|
|
502
|
+
shellLookup = null,
|
|
503
|
+
readdir = defaultReaddir,
|
|
504
|
+
dangles = defaultDangles,
|
|
505
|
+
} = {}) {
|
|
506
|
+
const spec = engineFor(key);
|
|
507
|
+
if (configured) {
|
|
508
|
+
return {
|
|
509
|
+
path: configured, found: exists(configured), certain: true, from: 'settings',
|
|
510
|
+
searched: [], evidence: null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
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.
|
|
520
|
+
const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
|
|
521
|
+
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
|
+
const searched = spec.candidates(home, readdir);
|
|
525
|
+
if (fromShell.path) {
|
|
526
|
+
return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// THEN THE LIST, which is the GUI launch with launchd's thin PATH.
|
|
530
|
+
for (const candidate of searched) {
|
|
531
|
+
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'disk', searched, evidence: null };
|
|
532
|
+
}
|
|
533
|
+
|
|
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
|
+
for (const candidate of spec.appCopies(home, readdir)) {
|
|
537
|
+
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
|
|
538
|
+
}
|
|
539
|
+
|
|
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.
|
|
544
|
+
const evidence = installEvidence(key, { home, env, exists, dangles });
|
|
545
|
+
return {
|
|
546
|
+
path: null,
|
|
547
|
+
found: false,
|
|
548
|
+
certain: fromShell.answered && !evidence,
|
|
549
|
+
from: null,
|
|
550
|
+
searched,
|
|
551
|
+
evidence,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
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.
|
|
569
|
+
|
|
570
|
+
const SHELL_TTL = 30_000;
|
|
571
|
+
const held = new Map();
|
|
572
|
+
|
|
573
|
+
export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, ...where } = {}) {
|
|
574
|
+
const spec = engineFor(key);
|
|
575
|
+
const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
|
|
576
|
+
const remembered = () => {
|
|
577
|
+
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;
|
|
588
|
+
};
|
|
589
|
+
return findEngine(key, { ...where, configured, shellLookup: remembered });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function forgetEngine(key) {
|
|
593
|
+
engineFor(key); // an engine nobody declared is a mistake here too, not a no-op
|
|
594
|
+
held.delete(key);
|
|
595
|
+
}
|
package/main/engine-setup.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { harnessFor } from './harnesses.mjs';
|
|
1
2
|
// SETTING UP SOMEBODY'S PLAN FOR THEM (w-9f6975906c, picked 2026-10-05).
|
|
2
3
|
//
|
|
3
4
|
// A person with a Claude or ChatGPT plan and no Claude Code or Codex used to
|
|
@@ -25,28 +26,16 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
25
26
|
import { terminalEnv } from './task-terminals.mjs';
|
|
26
27
|
import { codexLaunchEnv } from './codex-launch-env.mjs';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
claude: { shell: '/bin/bash', url: 'https://claude.ai/install.sh', pipe: 'bash' },
|
|
30
|
-
codex: { shell: '/bin/sh', url: 'https://chatgpt.com/codex/install.sh', pipe: 'sh' },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function installCommand(engine) {
|
|
34
|
-
const i = INSTALLERS[engine];
|
|
35
|
-
return { file: i.shell, args: ['-c', `curl -fsSL ${i.url} | ${i.pipe}`] };
|
|
36
|
-
}
|
|
29
|
+
export function installCommand(engine) { return harnessFor(engine).install(); }
|
|
37
30
|
|
|
38
31
|
/** The plan, never the API console: `--claudeai` is Claude Code's own name for it. */
|
|
39
32
|
export function signInCommand(engine, bin) {
|
|
40
|
-
return engine
|
|
41
|
-
? { file: bin, args: ['auth', 'login', '--claudeai'] }
|
|
42
|
-
: { file: bin, args: ['login'] };
|
|
33
|
+
return harnessFor(engine).signIn(bin);
|
|
43
34
|
}
|
|
44
35
|
|
|
45
36
|
/** Exit 0 is signed in and 1 is not, for both (measured 2026-10-05). */
|
|
46
37
|
export function statusCommand(engine, bin) {
|
|
47
|
-
return engine
|
|
48
|
-
? { file: bin, args: ['auth', 'status', '--json'] }
|
|
49
|
-
: { file: bin, args: ['login', 'status'] };
|
|
38
|
+
return harnessFor(engine).signInStatus(bin);
|
|
50
39
|
}
|
|
51
40
|
|
|
52
41
|
const LOG_LIMIT = 64 * 1024;
|
package/main/feedback.mjs
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// to as a secret (`FEEDBACK_TO`, cloud/supabase/functions/feedback), so the
|
|
6
6
|
// address is in no file of this public repository and no install can show it.
|
|
7
7
|
//
|
|
8
|
-
// WHERE IT GOES
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// WHERE IT GOES SHIPS IN THE CODE (w-58e758c81e, 2026-10-09). It used to be
|
|
9
|
+
// baked only into the packaged Mac app, so the npm package and a git clone,
|
|
10
|
+
// the two installs the README offers, told everyone who pressed Send that
|
|
11
|
+
// feedback was not set up, and lost what they wrote. FEEDBACK_ADDRESS is the
|
|
12
|
+
// server function, not an inbox. A `feedbackUrl` in zero.config.json, the
|
|
13
|
+
// FEEDBACK_URL variable, or a `bakedFeedbackUrl` in a packaged package.json
|
|
14
|
+
// still outrank it.
|
|
12
15
|
//
|
|
13
16
|
// It is not behind the diagnostics switch. That switch is about what Agentbox
|
|
14
17
|
// sends on its own; this is something a person typed and pressed Send on.
|
|
@@ -16,11 +19,13 @@ import { createRequire } from 'node:module';
|
|
|
16
19
|
import { checkFeedback } from '../shared/feedback.mjs';
|
|
17
20
|
import { Name, readEnv } from '../shared/product-name.mjs';
|
|
18
21
|
|
|
22
|
+
export const FEEDBACK_ADDRESS = 'https://lywvthwkhkfzhqivvond.supabase.co/functions/v1/feedback';
|
|
23
|
+
|
|
19
24
|
function bakedUrl() {
|
|
20
25
|
try {
|
|
21
|
-
return createRequire(import.meta.url)('../package.json')?.bakedFeedbackUrl ??
|
|
26
|
+
return createRequire(import.meta.url)('../package.json')?.bakedFeedbackUrl ?? FEEDBACK_ADDRESS;
|
|
22
27
|
} catch {
|
|
23
|
-
return
|
|
28
|
+
return FEEDBACK_ADDRESS;
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
|