agentbox-app 0.1.13 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/engine-bin.mjs
CHANGED
|
@@ -1,70 +1,26 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Where each coding agent is installed: one registry and one search for every
|
|
2
|
+
// engine the app can run. A new engine is an entry in ENGINES, not a file.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// engine here and run this. A third harness is an ENTRY, not a file.
|
|
4
|
+
// Never tell somebody their coding agent is missing unless we are certain.
|
|
5
|
+
// `found` says a runnable file is really there and decides what gets spawned.
|
|
6
|
+
// `certain` says we may tell them it is missing, and is meant to be hard to earn.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// entitled to SAY it is missing, and most of this file exists to make `certain`
|
|
14
|
-
// hard to earn.
|
|
8
|
+
// The user's login shell is asked first, because it has their real PATH and
|
|
9
|
+
// profile. The fixed list is for when it cannot answer: a double-clicked
|
|
10
|
+
// Electron app inherits launchd's PATH (/usr/bin:/bin:/usr/sbin:/sbin), which
|
|
11
|
+
// has no nvm, volta or Homebrew. A shell probe that throws falls through to the
|
|
12
|
+
// list and does not count as an answer.
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// Both old finders tried the hand-written list FIRST and asked the person's own
|
|
21
|
-
// login shell THIRD. The shell is the only source that actually KNOWS where
|
|
22
|
-
// their tools are -- it is their PATH, with their profile loaded, as them -- and
|
|
23
|
-
// it was the fallback. So every new installer, version manager and distro was a
|
|
24
|
-
// patch we ship and a release they wait for. Measured on this repository
|
|
25
|
-
// 2026-10-08: of the 7 commits those two files ever had, three are literally
|
|
26
|
-
// "one more install location".
|
|
27
|
-
//
|
|
28
|
-
// THE SHELL GOES FIRST NOW. The list stays, demoted to what it was really
|
|
29
|
-
// written for: a GUI launch with a thin PATH. A double-clicked Electron app
|
|
30
|
-
// inherits launchd's PATH, which is /usr/bin:/bin:/usr/sbin:/sbin and has never
|
|
31
|
-
// heard of nvm, volta or Homebrew, so a login shell that times out or will not
|
|
32
|
-
// start leaves the list as the only thing that can answer.
|
|
33
|
-
//
|
|
34
|
-
// WHAT THAT COSTS, measured on this Mac the same day: `zsh -lic 'command -v
|
|
35
|
-
// claude'` takes 0.38-0.43 s, so a machine whose list would have hit on the
|
|
36
|
-
// first stat call now pays about 0.4 s per engine at boot instead of nothing.
|
|
37
|
-
// Two engines, once, behind a thirty second memory. That is the price of not
|
|
38
|
-
// shipping a release every time somebody installs their tools a new way.
|
|
39
|
-
//
|
|
40
|
-
// AND A LOOKUP THAT THROWS IS NOT AN ABSENCE. The shell step used never to run
|
|
41
|
-
// at all on a machine whose list had the binary, so nothing downstream of it was
|
|
42
|
-
// ever reached by a throw. It runs first now, and the app refuses to open an
|
|
43
|
-
// inbox without Claude Code, so a probe that blows up falls through to the list
|
|
44
|
-
// rather than taking Claude Code away from somebody who has it. It still does
|
|
45
|
-
// not count as an answer, so it cannot earn the sentence either.
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
//
|
|
48
|
-
// Nothing here overrides a path somebody set. An explicit `claudeBin` or
|
|
49
|
-
// `codexBin` wins untouched and is never searched around, because that promise
|
|
50
|
-
// is what makes "point at it yourself" a real way out of a search that missed
|
|
51
|
-
// (main/ipc.mjs, `zero:engine-bin`).
|
|
14
|
+
// A path somebody set (`claudeBin`, `codexBin`) wins and is never searched
|
|
15
|
+
// around; that is what makes "point at it yourself" work (main/ipc.mjs,
|
|
16
|
+
// `zero:engine-bin`).
|
|
52
17
|
|
|
53
18
|
import fs from 'node:fs';
|
|
54
19
|
import os from 'node:os';
|
|
55
20
|
import path from 'node:path';
|
|
56
|
-
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
57
22
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* EVERY PACKAGE MANAGER'S DEFAULT BIN DIRECTORY, in order, for any tool.
|
|
62
|
-
*
|
|
63
|
-
* This list was written TWICE, once per engine, fourteen entries each, and a
|
|
64
|
-
* path added to one and not the other is how one engine quietly stops being
|
|
65
|
-
* found where the other is. It is one list now and every engine's candidates
|
|
66
|
-
* are generated from it, so a new entry reaches every harness at once.
|
|
67
|
-
*/
|
|
23
|
+
/** Every package manager's default bin directory, in order, shared by all engines. */
|
|
68
24
|
export function sharedBinDirs(home = os.homedir()) {
|
|
69
25
|
return [
|
|
70
26
|
'/opt/homebrew/bin', // Homebrew, Apple Silicon
|
|
@@ -85,10 +41,8 @@ export function sharedBinDirs(home = os.homedir()) {
|
|
|
85
41
|
}
|
|
86
42
|
|
|
87
43
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* listed, and a directory that is not there contributes nothing. This is where
|
|
91
|
-
* a global npm install under any of them lands and nowhere else.
|
|
44
|
+
* One bin directory per installed Node. nvm, fnm, n and asdf keep one per
|
|
45
|
+
* version, and a global npm install under them lands only there.
|
|
92
46
|
*/
|
|
93
47
|
export function versionedBinPaths(bin, home = os.homedir(), readdir = defaultReaddir) {
|
|
94
48
|
const under = (dir, tail) => readdir(path.join(home, dir)).map((v) => path.join(home, dir, v, tail));
|
|
@@ -106,11 +60,9 @@ function defaultReaddir(dir) {
|
|
|
106
60
|
}
|
|
107
61
|
|
|
108
62
|
/**
|
|
109
|
-
* A
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* versioned file and swap that file on every update, so this is the shape of a
|
|
113
|
-
* tool mid-upgrade rather than an odd one.
|
|
63
|
+
* A symlink to nothing, which `existsSync` cannot tell from no file. Both
|
|
64
|
+
* engines' installers point a stable link at a versioned file and swap it on
|
|
65
|
+
* update, so this is a tool mid-upgrade, not a missing one.
|
|
114
66
|
*/
|
|
115
67
|
function defaultDangles(p) {
|
|
116
68
|
try { return !!fs.lstatSync(p) && !fs.existsSync(p); } catch { return false; }
|
|
@@ -125,19 +77,16 @@ function newestFirst(a, b) {
|
|
|
125
77
|
return 0;
|
|
126
78
|
}
|
|
127
79
|
|
|
128
|
-
/* ------------------------------- the registry ----------------------------- */
|
|
129
|
-
|
|
130
80
|
/**
|
|
131
|
-
*
|
|
81
|
+
* What a new engine has to declare:
|
|
132
82
|
*
|
|
133
83
|
* bin the program name, which is also its key here
|
|
134
84
|
* name what it is called on a screen
|
|
135
85
|
* installUrl where to get it, when we are sure it is not here
|
|
136
|
-
* ownPaths where the tool's
|
|
137
|
-
* package managers because it is the answer the tool produces
|
|
86
|
+
* ownPaths where the tool's own installer puts it, tried first
|
|
138
87
|
* appCopies a copy carried inside a desktop app, tried last of all
|
|
139
|
-
* evidence
|
|
140
|
-
*
|
|
88
|
+
* evidence files it leaves behind; never spawned, and only ever stop us
|
|
89
|
+
* saying it is missing
|
|
141
90
|
*/
|
|
142
91
|
function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidence }) {
|
|
143
92
|
return {
|
|
@@ -147,8 +96,7 @@ function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidenc
|
|
|
147
96
|
ownPaths,
|
|
148
97
|
appCopies,
|
|
149
98
|
evidence,
|
|
150
|
-
//
|
|
151
|
-
// package manager's bin directory, then one directory per installed Node.
|
|
99
|
+
// The first that exists wins.
|
|
152
100
|
candidates(home = os.homedir(), readdir = defaultReaddir) {
|
|
153
101
|
return [
|
|
154
102
|
...ownPaths(home),
|
|
@@ -161,20 +109,12 @@ function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidenc
|
|
|
161
109
|
|
|
162
110
|
export const ENGINES = {
|
|
163
111
|
/**
|
|
164
|
-
*
|
|
165
|
-
* why its absence is the one a screen is ever allowed to shout about.
|
|
166
|
-
*
|
|
167
|
-
* `~/.local/bin/claude` is the installer's own path and `~/.claude/local/claude`
|
|
168
|
-
* is what `claude migrate-installer` leaves behind. The installer points the
|
|
169
|
-
* first at a versioned file and swaps that file on every update, so a dangling
|
|
170
|
-
* link there is a real state and not a hypothetical.
|
|
112
|
+
* Claude Code is required: the app will not open an inbox without it.
|
|
171
113
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* real install is fresher -- that copy was three months old beside a 2.1.289
|
|
177
|
-
* install on the same Mac.
|
|
114
|
+
* The Claude desktop app keeps its own copy, one folder per version, and it
|
|
115
|
+
* shares the CLI's sign-in, so someone who only uses Claude Code inside that
|
|
116
|
+
* app has nothing else. It is tried last because it updates with the app and
|
|
117
|
+
* lags a real install.
|
|
178
118
|
*/
|
|
179
119
|
claude: engine({
|
|
180
120
|
bin: 'claude',
|
|
@@ -191,8 +131,7 @@ export const ENGINES = {
|
|
|
191
131
|
.sort(newestFirst)
|
|
192
132
|
.map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
|
|
193
133
|
},
|
|
194
|
-
//
|
|
195
|
-
// machine. None is a binary and none is ever spawned.
|
|
134
|
+
// Any one of these means Claude Code has run on this machine.
|
|
196
135
|
evidence: (home) => [
|
|
197
136
|
path.join(home, '.claude.json'), // its own config file
|
|
198
137
|
path.join(home, '.claude/settings.json'),
|
|
@@ -205,36 +144,15 @@ export const ENGINES = {
|
|
|
205
144
|
}),
|
|
206
145
|
|
|
207
146
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* warning: it just leaves the second engine out of the map
|
|
211
|
-
* `shared/engines.mjs` reads.
|
|
212
|
-
*
|
|
213
|
-
* MEASURED ON A MAC 2026-09-04, because the 2026-08-25 original was written
|
|
214
|
-
* against codex-cli 0.144.0 and six of its assumptions had moved. codex-cli
|
|
215
|
-
* 0.148.0 at /opt/homebrew/bin/codex, a Homebrew CASK rather than a formula,
|
|
216
|
-
* whose bin entry is a SYMLINK into /opt/homebrew/Caskroom/codex/0.148.0/bin.
|
|
217
|
-
* `brew info --cask codex` the same minute: 0.148.0 installed against 0.152.1
|
|
218
|
-
* available, so the moment mid-upgrade where that link points at nothing is a
|
|
219
|
-
* live state and not a story.
|
|
220
|
-
*
|
|
221
|
-
* THE INSTALL METHOD THE DOCS NOW LEAD WITH, `chatgpt.com/codex/install.sh`,
|
|
222
|
-
* read 2026-09-04: `BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"`,
|
|
223
|
-
* `STANDALONE_ROOT="${CODEX_HOME:-$HOME/.codex}/packages/standalone"`. It links
|
|
224
|
-
* `~/.local/bin/codex` at a versioned file under that root. AND THE STORE KEEPS
|
|
225
|
-
* THE BINARY IN TWO PLACES -- the same script stages `<release>/bin/codex` and
|
|
226
|
-
* links `<release>/codex` beside it (lines 951 and 1021), and `current` is a
|
|
227
|
-
* symlink at the live release -- so both resolve on a machine set up the
|
|
228
|
-
* documented way, each costs one stat call, and listing only one is how the app
|
|
229
|
-
* tells somebody their Codex is missing while they are looking at it.
|
|
147
|
+
* Codex is optional, so its absence never produces a warning; it is just
|
|
148
|
+
* left out of the map `shared/engines.mjs` reads.
|
|
230
149
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* into
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* last and broad, and why nothing tries to enumerate what is inside it.
|
|
150
|
+
* Homebrew installs Codex as a cask whose bin entry is a symlink into the
|
|
151
|
+
* Caskroom, so it dangles mid-upgrade. The documented install.sh links
|
|
152
|
+
* `~/.local/bin/codex` into `$CODEX_HOME/packages/standalone`, and each
|
|
153
|
+
* release holds the binary at both `bin/codex` and `codex`, so both are
|
|
154
|
+
* listed. ~/.codex keeps its live state in sqlite files whose names carry a
|
|
155
|
+
* changing schema number, so the directory itself is evidence, last.
|
|
238
156
|
*/
|
|
239
157
|
codex: engine({
|
|
240
158
|
bin: 'codex',
|
|
@@ -245,17 +163,14 @@ export const ENGINES = {
|
|
|
245
163
|
path.join(home, '.codex/packages/standalone/current/bin/codex'),
|
|
246
164
|
path.join(home, '.codex/packages/standalone/current/codex'),
|
|
247
165
|
],
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// one the Codex desktop app carries (w-d5d632e503, its reported place rather
|
|
251
|
-
// than one measured here). A path that is not there costs one `exists`.
|
|
166
|
+
// The ChatGPT app's own copy, which shares the CLI's sign-in, and the Codex
|
|
167
|
+
// desktop app's (reported, not verified here).
|
|
252
168
|
appCopies: (home) => [
|
|
253
169
|
'ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
|
|
254
170
|
'Codex.app/Contents/Resources/codex',
|
|
255
171
|
].flatMap((p) => [path.join('/Applications', p), path.join(home, 'Applications', p)]),
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// and the whole job of this list is to make an absence hard to claim.
|
|
172
|
+
// Both homes when CODEX_HOME is set: an inherited CODEX_HOME does not prove
|
|
173
|
+
// ~/.codex is unused.
|
|
259
174
|
evidence: (home, env = process.env) => {
|
|
260
175
|
const homes = [env?.CODEX_HOME, path.join(home, '.codex')].filter(Boolean);
|
|
261
176
|
const out = [];
|
|
@@ -284,11 +199,8 @@ function engineFor(key) {
|
|
|
284
199
|
return found;
|
|
285
200
|
}
|
|
286
201
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// The shells worth asking, in order, and never the same one twice. Theirs first
|
|
290
|
-
// because it is the one that has their PATH in it; the other two because a
|
|
291
|
-
// $SHELL that will not start is a fact about $SHELL and not about the tool.
|
|
202
|
+
// Their $SHELL first, then zsh and bash, because a $SHELL that will not start
|
|
203
|
+
// says nothing about the tool.
|
|
292
204
|
export function shellProbes(env = process.env) {
|
|
293
205
|
const preferred = env.SHELL || '/bin/zsh';
|
|
294
206
|
const out = [[preferred, '-lic', 4000], [preferred, '-lc', 2500]];
|
|
@@ -299,55 +211,55 @@ export function shellProbes(env = process.env) {
|
|
|
299
211
|
}
|
|
300
212
|
|
|
301
213
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* entry's `bin`, which is a literal in this file, so nothing is exploitable --
|
|
307
|
-
* but a `codexBin` read out of zero.config.json reaching this argument is one
|
|
308
|
-
* plausible slice away, and at that point `command -v x; curl | sh` is a string
|
|
309
|
-
* in a file that runs itself.
|
|
310
|
-
*
|
|
311
|
-
* This function looks up program NAMES, so a name is all it takes. An allowlist
|
|
312
|
-
* rather than quoting, because quoting is a thing a later edit can undo without
|
|
313
|
-
* noticing and a shape check is not.
|
|
214
|
+
* `bin` is interpolated into a command their login shell runs, so only a plain
|
|
215
|
+
* program name is accepted. A config value reaching it would otherwise be shell
|
|
216
|
+
* injection. An allowlist rather than quoting, because a later edit can undo
|
|
217
|
+
* quoting without anyone noticing.
|
|
314
218
|
*/
|
|
315
219
|
const PROGRAM_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
316
220
|
|
|
317
221
|
/**
|
|
318
|
-
* Ask
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* `answered` is the strict half. It is true only when every probe that ran came
|
|
324
|
-
* back with a real exit status, and at least one did. One shell that timed out
|
|
325
|
-
* alongside one that said no is not a machine we know anything about.
|
|
326
|
-
*
|
|
327
|
-
* It must be a LOGIN, INTERACTIVE shell, because the version managers put
|
|
328
|
-
* themselves on PATH from `.zprofile` and `.zshrc` and nowhere else. That is the
|
|
329
|
-
* whole reason an npm install is obvious in a terminal and invisible to the app.
|
|
330
|
-
* It is asked more than once because ONE probe that fails to start is not a fact
|
|
331
|
-
* about anybody's machine.
|
|
222
|
+
* Ask their login shell until one answers. Only an absolute path to a file that
|
|
223
|
+
* exists counts, because `command -v` also prints functions and aliases.
|
|
224
|
+
* `answered` is true only when at least one probe ran and every probe returned
|
|
225
|
+
* a real exit status. Login and interactive (`-lic`) because version managers
|
|
226
|
+
* add themselves to PATH only in `.zprofile` and `.zshrc`.
|
|
332
227
|
*/
|
|
333
228
|
export function askShell({ env = process.env, exists = fs.existsSync, run = defaultRun, bin = 'claude' } = {}) {
|
|
229
|
+
const asking = shellQuestion({ env, exists, bin });
|
|
230
|
+
for (let step = asking.next(); ; ) {
|
|
231
|
+
if (step.done) return step.value;
|
|
232
|
+
let reply;
|
|
233
|
+
try { reply = { out: run(...step.value) }; } catch (err) { reply = { err }; }
|
|
234
|
+
step = asking.next(reply);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `askShell` without holding the window: each probe is awaited. */
|
|
239
|
+
export async function askShellLater({ env = process.env, exists = fs.existsSync, run = shellRunnerLater(process.env), bin = 'claude' } = {}) {
|
|
240
|
+
const asking = shellQuestion({ env, exists, bin });
|
|
241
|
+
for (let step = asking.next(); ; ) {
|
|
242
|
+
if (step.done) return step.value;
|
|
243
|
+
let reply;
|
|
244
|
+
try { reply = { out: await run(...step.value) }; } catch (err) { reply = { err }; }
|
|
245
|
+
step = asking.next(reply);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The probes and what their replies mean, written once for both askers: it
|
|
250
|
+
// yields each probe to run and is handed back `{ out }` or `{ err }`.
|
|
251
|
+
function* shellQuestion({ env, exists, bin }) {
|
|
334
252
|
if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
|
|
335
253
|
throw new Error(`askShell looks up a program name, and ${JSON.stringify(bin)} is not one`);
|
|
336
254
|
}
|
|
337
255
|
let said = false;
|
|
338
256
|
let inconclusive = false;
|
|
339
257
|
for (const [shell, flags, timeout] of shellProbes(env)) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// ran and exited non-zero has told us the tool is not on PATH, and that is
|
|
346
|
-
// an answer. A shell that could not be started, or that ran past the
|
|
347
|
-
// timeout, has told us nothing at all, and reporting nothing as "not
|
|
348
|
-
// installed" is what put a line about missing software on the screen of
|
|
349
|
-
// people who have it.
|
|
350
|
-
if (exitedSayingNo(e)) said = true; else inconclusive = true;
|
|
258
|
+
const { out, err } = yield [shell, [flags, `command -v ${bin}`], timeout];
|
|
259
|
+
if (err) {
|
|
260
|
+
// A non-zero exit means not on PATH. A shell that would not start or
|
|
261
|
+
// timed out has told us nothing, and must not count as "not installed".
|
|
262
|
+
if (exitedSayingNo(err)) said = true; else inconclusive = true;
|
|
351
263
|
continue;
|
|
352
264
|
}
|
|
353
265
|
const line = String(out).split('\n').map((s) => s.trim()).filter(Boolean).pop();
|
|
@@ -357,29 +269,95 @@ export function askShell({ env = process.env, exists = fs.existsSync, run = defa
|
|
|
357
269
|
return { path: null, answered: said && !inconclusive };
|
|
358
270
|
}
|
|
359
271
|
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// and a shell that does not exist (ENOENT) both arrive here too, and neither
|
|
363
|
-
// of them is a fact about the tool.
|
|
272
|
+
// A timeout and a missing shell also throw here, and neither is a fact about
|
|
273
|
+
// the tool.
|
|
364
274
|
function exitedSayingNo(e) {
|
|
365
275
|
if (!e || typeof e !== 'object') return false;
|
|
366
276
|
if (e.signal || e.code === 'ETIMEDOUT' || e.code === 'ENOENT') return false;
|
|
367
277
|
return typeof e.status === 'number';
|
|
368
278
|
}
|
|
369
279
|
|
|
280
|
+
/**
|
|
281
|
+
* A SHELL THAT NEVER REACHED THE QUESTION HAS NOT ANSWERED IT (2026-10-15).
|
|
282
|
+
*
|
|
283
|
+
* `[ -z "$TMUX" ] && exec tmux` in .zshrc replaces the shell before `command
|
|
284
|
+
* -v` runs, and tmux with no terminal says "open terminal failed: not a
|
|
285
|
+
* terminal" and exits 1, which is exactly how `command -v` spells "not on
|
|
286
|
+
* PATH". Measured with the real /bin/zsh: a claude that .zshrc put on PATH came
|
|
287
|
+
* back `{ answered: true }`, so the setup screen was entitled to call it
|
|
288
|
+
* missing. `exec fish` was the same with exit 0 and nothing printed.
|
|
289
|
+
*
|
|
290
|
+
* So the runner says a word of its own first and only hands back what comes
|
|
291
|
+
* after it. Without that word the shell never got to the question, and what is
|
|
292
|
+
* thrown carries no exit status, which `exitedSayingNo` reads as no answer.
|
|
293
|
+
* The question itself is unchanged, so a `run` handed in by a test sees the
|
|
294
|
+
* same `command -v x` it always did.
|
|
295
|
+
*/
|
|
296
|
+
const REACHED = 'agentbox-shell-reached';
|
|
297
|
+
const notReached = () => Object.assign(new Error('the shell profile ended the shell before the question ran'), { code: 'ENOTREACHED' });
|
|
298
|
+
function afterReached(stdout) {
|
|
299
|
+
const lines = String(stdout ?? '').split('\n');
|
|
300
|
+
const at = lines.map((l) => l.trim()).lastIndexOf(REACHED);
|
|
301
|
+
return at < 0 ? null : lines.slice(at + 1).join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function shellRunner(env = process.env) {
|
|
305
|
+
return (shell, [flags, command], timeout = 4000) => {
|
|
306
|
+
let out;
|
|
307
|
+
try {
|
|
308
|
+
out = execFileSync(shell, [flags, `echo ${REACHED}; ${command}`], {
|
|
309
|
+
encoding: 'utf8',
|
|
310
|
+
// stdin ignored on purpose: an interactive shell handed a live terminal can
|
|
311
|
+
// sit waiting for input, and this runs on the boot path.
|
|
312
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
313
|
+
timeout,
|
|
314
|
+
// Not SIGTERM: a shell's exit hook can wait forever on it, and this
|
|
315
|
+
// spawn blocks the whole app until the shell has exited (2026-10-16).
|
|
316
|
+
killSignal: 'SIGKILL',
|
|
317
|
+
env,
|
|
318
|
+
});
|
|
319
|
+
} catch (e) {
|
|
320
|
+
if (typeof e?.status === 'number' && !e.signal && afterReached(e.stdout) === null) throw notReached();
|
|
321
|
+
throw e;
|
|
322
|
+
}
|
|
323
|
+
const answer = afterReached(out);
|
|
324
|
+
if (answer === null) throw notReached();
|
|
325
|
+
return answer;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
370
329
|
function defaultRun(shell, args, timeout = 4000) {
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
330
|
+
return shellRunner(process.env)(shell, args, timeout);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `shellRunner` as a promise, so the window keeps running while the shell
|
|
335
|
+
* starts. Its errors are reshaped into what execFileSync throws, which is what
|
|
336
|
+
* `exitedSayingNo` reads: a timeout carries ETIMEDOUT, a plain non-zero exit a
|
|
337
|
+
* numeric `status`.
|
|
338
|
+
*/
|
|
339
|
+
export function shellRunnerLater(env = process.env) {
|
|
340
|
+
return (shell, [flags, command], timeout = 4000) => new Promise((resolve, reject) => {
|
|
341
|
+
const child = execFile(shell, [flags, `echo ${REACHED}; ${command}`], {
|
|
342
|
+
encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, env,
|
|
343
|
+
}, (err, stdout) => {
|
|
344
|
+
if (!err) {
|
|
345
|
+
const answer = afterReached(stdout);
|
|
346
|
+
return answer === null ? reject(notReached()) : resolve(answer);
|
|
347
|
+
}
|
|
348
|
+
if (typeof err.code === 'number' && !err.signal) {
|
|
349
|
+
if (afterReached(stdout) === null) return reject(notReached());
|
|
350
|
+
return reject(Object.assign(err, { status: err.code, code: undefined }));
|
|
351
|
+
}
|
|
352
|
+
if (err.killed && err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') err.code = 'ETIMEDOUT';
|
|
353
|
+
reject(err);
|
|
354
|
+
});
|
|
355
|
+
// The 'ignore' the waiting runner gives stdin: nothing will ever be typed.
|
|
356
|
+
child.stdin?.end();
|
|
377
357
|
});
|
|
378
358
|
}
|
|
379
359
|
|
|
380
|
-
//
|
|
381
|
-
// failed probe were null. Anything still handing back the old shape is read as
|
|
382
|
-
// an answer, because that is what it always meant.
|
|
360
|
+
// A lookup that returns a bare path or null (the old shape) counts as answered.
|
|
383
361
|
function shellSaid(result) {
|
|
384
362
|
if (typeof result === 'string') return { path: result, answered: true };
|
|
385
363
|
if (!result || typeof result !== 'object') return { path: null, answered: true };
|
|
@@ -387,31 +365,11 @@ function shellSaid(result) {
|
|
|
387
365
|
}
|
|
388
366
|
|
|
389
367
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* inside `createWindow` and BEFORE `new BrowserWindow`, so that was not time in
|
|
396
|
-
* the background: it was a second and a half of nothing on screen, added to
|
|
397
|
-
* every launch, and it was the price of asking the shell first.
|
|
398
|
-
*
|
|
399
|
-
* THE COST IS THE SHELL STARTING, NOT THE QUESTION. A login, interactive shell
|
|
400
|
-
* reads the whole profile -- which is the entire reason it can answer at all --
|
|
401
|
-
* and that costs about the same whether it is asked about one program or six.
|
|
402
|
-
* Asking it once per engine paid it once per engine.
|
|
403
|
-
*
|
|
404
|
-
* So this asks about all of them in one command and `warmShellAnswers` seeds
|
|
405
|
-
* every engine's cache from it.
|
|
406
|
-
*
|
|
407
|
-
* IT ONLY EVER ANSWERS POSITIVELY, which is what makes it safe to put in front
|
|
408
|
-
* of the probe that was already here rather than in place of it. A path is
|
|
409
|
-
* taken only when it is absolute, really on the disk, and really named after
|
|
410
|
-
* the engine that asked -- the lines come back in whatever order the shell
|
|
411
|
-
* prints them, and a path handed to the wrong engine would spawn the wrong
|
|
412
|
-
* agent. Nothing here ever records an ABSENCE: `answered` is the subtle half of
|
|
413
|
-
* this file's rules, and the per-engine probe stays the only thing allowed to
|
|
414
|
-
* settle one. So the worst a bad combined answer can do is nothing at all.
|
|
368
|
+
* One login shell asked once for every engine. Starting the shell is the cost,
|
|
369
|
+
* not the question, and `loadConfig` runs before the window opens, so asking
|
|
370
|
+
* per engine delayed every launch. A path is taken only when it is absolute,
|
|
371
|
+
* exists, and is named after an engine, since lines come back in any order.
|
|
372
|
+
* This never records an absence; only the per-engine probe may settle one.
|
|
415
373
|
*/
|
|
416
374
|
export function askShellForAll(bins, { env = process.env, exists = fs.existsSync, run = defaultRun } = {}) {
|
|
417
375
|
for (const bin of bins) {
|
|
@@ -425,22 +383,18 @@ export function askShellForAll(bins, { env = process.env, exists = fs.existsSync
|
|
|
425
383
|
try { out = run(shell, [flags, command], timeout); } catch { continue; }
|
|
426
384
|
const found = {};
|
|
427
385
|
for (const line of String(out).split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
428
|
-
// A function body, an alias line or a bare name is not a path, and a path
|
|
429
|
-
// belongs to the engine whose name it ends with or to nobody.
|
|
430
386
|
if (!line.startsWith('/') || !exists(line)) continue;
|
|
431
387
|
const bin = bins.find((b) => path.basename(line) === b);
|
|
432
388
|
if (bin && !found[bin]) found[bin] = line;
|
|
433
389
|
}
|
|
434
|
-
// A shell that ran has had its turn, whatever it did or did not print.
|
|
435
390
|
return found;
|
|
436
391
|
}
|
|
437
392
|
return {};
|
|
438
393
|
}
|
|
439
394
|
|
|
440
395
|
/**
|
|
441
|
-
* Seed every engine's
|
|
442
|
-
*
|
|
443
|
-
* exactly what used to happen for all of them.
|
|
396
|
+
* Seed every engine's cached shell answer from one probe. If the shell fails,
|
|
397
|
+
* each engine falls back to its own probe.
|
|
444
398
|
*/
|
|
445
399
|
export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
|
|
446
400
|
let found = {};
|
|
@@ -451,25 +405,14 @@ export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
|
|
|
451
405
|
}
|
|
452
406
|
}
|
|
453
407
|
|
|
454
|
-
|
|
455
|
-
* THE SHELL STEP, AND IT MAY NOT TAKE THE SEARCH DOWN WITH IT.
|
|
456
|
-
*
|
|
457
|
-
* It runs FIRST now, so it is the one step whose failure would reach everything
|
|
458
|
-
* after it. A lookup that throws is read as the shell having said nothing: the
|
|
459
|
-
* list still gets its turn, and the throw still does not count as an answer, so
|
|
460
|
-
* it cannot earn the sentence about missing software either.
|
|
461
|
-
*/
|
|
408
|
+
// A lookup that throws is read as no answer, so the list still gets its turn.
|
|
462
409
|
function askedShell(lookup) {
|
|
463
410
|
try { return shellSaid(lookup()); } catch { return { path: null, answered: false }; }
|
|
464
411
|
}
|
|
465
412
|
|
|
466
|
-
/* -------------------------------- the search ------------------------------ */
|
|
467
|
-
|
|
468
413
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* support. A dangling candidate counts the same way, because an update that has
|
|
472
|
-
* swapped the versioned file out from under a stable link leaves exactly that.
|
|
414
|
+
* The first trace the tool left behind, or null. Never spawned; it only stops
|
|
415
|
+
* the app saying the tool is missing. A dangling candidate counts too.
|
|
473
416
|
*/
|
|
474
417
|
export function installEvidence(key, {
|
|
475
418
|
home = os.homedir(), env = process.env, exists = fs.existsSync, dangles = defaultDangles,
|
|
@@ -481,18 +424,10 @@ export function installEvidence(key, {
|
|
|
481
424
|
}
|
|
482
425
|
|
|
483
426
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* claim about the world: it means a file is really there. `certain` says whether
|
|
489
|
-
* we are allowed to tell somebody it is NOT there, so a screen can stay quiet
|
|
490
|
-
* rather than announce an absence it has not established. They come apart in
|
|
491
|
-
* exactly one case that is not a failure, and it is the case worth keeping
|
|
492
|
-
* honest: somebody has set a path that does not exist, so the path stays THEIRS
|
|
493
|
-
* (anything that spawns it should fail naming the path they chose, not one we
|
|
494
|
-
* picked) while the screen says it was not found and offers the install link,
|
|
495
|
-
* the same as any other machine.
|
|
427
|
+
* The whole search, with every machine fact injectable for tests. Returns
|
|
428
|
+
* { path, found, certain, from, searched, evidence }. A configured path that
|
|
429
|
+
* does not exist is still returned as the path, so a spawn fails naming the
|
|
430
|
+
* path they chose, with found false and certain true.
|
|
496
431
|
*/
|
|
497
432
|
export function findEngine(key, {
|
|
498
433
|
configured = null,
|
|
@@ -511,36 +446,25 @@ export function findEngine(key, {
|
|
|
511
446
|
};
|
|
512
447
|
}
|
|
513
448
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
// both old finders' defaults did: a test that wants to put a tool somewhere
|
|
517
|
-
// hands in `shellLookup`, and one that hands in only a fake `exists` is
|
|
518
|
-
// asking about the LIST, so feeding its fake into the shell step would
|
|
519
|
-
// silently answer a different question.
|
|
449
|
+
// The default probe uses the real environment and disk, not the injected
|
|
450
|
+
// `exists`: a test faking only `exists` is asking about the list.
|
|
520
451
|
const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
|
|
521
452
|
const fromShell = askedShell(lookup);
|
|
522
|
-
// Computed either way, because a caller that wants to say where we looked
|
|
523
|
-
// wants it most on the answer it is going to draw.
|
|
524
453
|
const searched = spec.candidates(home, readdir);
|
|
525
454
|
if (fromShell.path) {
|
|
526
455
|
return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
|
|
527
456
|
}
|
|
528
457
|
|
|
529
|
-
// THEN THE LIST, which is the GUI launch with launchd's thin PATH.
|
|
530
458
|
for (const candidate of searched) {
|
|
531
459
|
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'disk', searched, evidence: null };
|
|
532
460
|
}
|
|
533
461
|
|
|
534
|
-
// THEN A DESKTOP APP'S OWN COPY, last because it moves with that app's
|
|
535
|
-
// releases and a real install anywhere is fresher.
|
|
536
462
|
for (const candidate of spec.appCopies(home, readdir)) {
|
|
537
463
|
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
|
|
538
464
|
}
|
|
539
465
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
// any sign of the tool anyway. If it does, we looked in the wrong places
|
|
543
|
-
// rather than at an empty machine, and nothing may be said out loud.
|
|
466
|
+
// Any trace of the tool means we looked in the wrong places, so we may not
|
|
467
|
+
// say it is missing.
|
|
544
468
|
const evidence = installEvidence(key, { home, env, exists, dangles });
|
|
545
469
|
return {
|
|
546
470
|
path: null,
|
|
@@ -552,44 +476,70 @@ export function findEngine(key, {
|
|
|
552
476
|
};
|
|
553
477
|
}
|
|
554
478
|
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
// The shell is the one part worth holding, because it is up to three
|
|
563
|
-
// subprocesses and a screen somebody toggles a switch on reads settings again on
|
|
564
|
-
// every write. Thirty seconds is long enough that a burst of writes pays for it
|
|
565
|
-
// once, and short enough that it is never the reason someone is still stuck.
|
|
566
|
-
//
|
|
567
|
-
// PER ENGINE, because looking for Claude Code tells you nothing about Codex, and
|
|
568
|
-
// one shared window would have the second engine read the first one's answer.
|
|
479
|
+
// Only the shell answer is held, per engine. The path checks are cheap stat
|
|
480
|
+
// calls, and holding them would keep saying "not found" after someone
|
|
481
|
+
// installs. The shell is up to three subprocesses and blocks the window while
|
|
482
|
+
// it runs, so it is waited for only when nothing is held: at launch, and after
|
|
483
|
+
// Check again. An answer older than thirty seconds is still handed back at
|
|
484
|
+
// once, and the shell is asked again in the background for the next read.
|
|
569
485
|
|
|
570
486
|
const SHELL_TTL = 30_000;
|
|
571
487
|
const held = new Map();
|
|
488
|
+
const asking = new Map();
|
|
572
489
|
|
|
573
|
-
export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, ...where } = {}) {
|
|
490
|
+
export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, shellLookupLater = null, ...where } = {}) {
|
|
574
491
|
const spec = engineFor(key);
|
|
575
|
-
const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
|
|
576
492
|
const remembered = () => {
|
|
577
493
|
const last = held.get(key);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
494
|
+
if (!last) {
|
|
495
|
+
const answer = (shellLookup ?? (() => askShell({ bin: spec.bin })))();
|
|
496
|
+
held.set(key, { answer, at: now });
|
|
497
|
+
return answer;
|
|
498
|
+
}
|
|
499
|
+
// `age >= 0` too, because `warmShellAnswers` stamps real clock time and a
|
|
500
|
+
// caller with a synthetic `now` would otherwise read it as fresh forever.
|
|
501
|
+
const age = now - last.at;
|
|
502
|
+
const later = shellLookupLater ?? (() => askShellLater({ bin: spec.bin }));
|
|
503
|
+
if (!(age >= 0 && age < SHELL_TTL)) askAgain(key, later, now);
|
|
504
|
+
// A path held from an earlier answer may have been uninstalled since.
|
|
505
|
+
const gone = typeof last.answer?.path === 'string' && !(where.exists ?? fs.existsSync)(last.answer.path);
|
|
506
|
+
if (gone) { askAgain(key, later, now); return { path: null, answered: false }; }
|
|
507
|
+
return last.answer;
|
|
588
508
|
};
|
|
589
509
|
return findEngine(key, { ...where, configured, shellLookup: remembered });
|
|
590
510
|
}
|
|
591
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Ask the shell again off the window's thread and settle once the answer is
|
|
514
|
+
* held, for a caller that promised a fresh look (Check again, setup, the
|
|
515
|
+
* update check). Never rejects.
|
|
516
|
+
*/
|
|
517
|
+
export function lookAgain(key, { now = Date.now(), shellLookupLater = null } = {}) {
|
|
518
|
+
const spec = engineFor(key);
|
|
519
|
+
return askAgain(key, shellLookupLater ?? (() => askShellLater({ bin: spec.bin })), now);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function askAgain(key, lookup, now) {
|
|
523
|
+
if (asking.has(key)) return asking.get(key);
|
|
524
|
+
const turn = new Promise((resolve) => resolve(lookup())).then(
|
|
525
|
+
(answer) => {
|
|
526
|
+
if (asking.get(key) !== turn) return;
|
|
527
|
+
// A shell that timed out under load says nothing, and must not replace
|
|
528
|
+
// an answer that did say something.
|
|
529
|
+
const said = shellSaid(answer);
|
|
530
|
+
const had = held.get(key);
|
|
531
|
+
if (said.path || said.answered || !had) held.set(key, { answer, at: now });
|
|
532
|
+
else held.set(key, { ...had, at: now });
|
|
533
|
+
},
|
|
534
|
+
() => { /* the held answer stands, and the next read asks again */ },
|
|
535
|
+
).finally(() => { if (asking.get(key) === turn) asking.delete(key); });
|
|
536
|
+
asking.set(key, turn);
|
|
537
|
+
return turn;
|
|
538
|
+
}
|
|
539
|
+
|
|
592
540
|
export function forgetEngine(key) {
|
|
593
|
-
engineFor(key); //
|
|
541
|
+
engineFor(key); // throws for an undeclared engine
|
|
594
542
|
held.delete(key);
|
|
543
|
+
// An answer already on its way was asked for before this, so it is dropped.
|
|
544
|
+
asking.delete(key);
|
|
595
545
|
}
|