agentbox-app 0.1.12 → 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/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +311 -260
- package/main/agent-updates.mjs +9 -22
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +42 -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-bin.mjs +45 -311
- package/main/claude-input.mjs +57 -7
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +64 -16
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +94 -59
- 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-bin.mjs +51 -249
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +246 -506
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +112 -11
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +83 -5
- package/main/codex.mjs +95 -363
- package/main/config.mjs +37 -5
- package/main/corner-tag.mjs +810 -0
- package/main/crash-report.mjs +26 -3
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +545 -0
- package/main/engine-setup.mjs +25 -24
- package/main/feedback.mjs +14 -31
- 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-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +105 -0
- package/main/harnesses/codex.mjs +72 -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 +96 -0
- package/main/ipc.mjs +520 -135
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/local-app-frames.mjs +82 -0
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +248 -37
- package/main/memory-gate-history.mjs +85 -20
- package/main/memory-gate-server.mjs +129 -11
- package/main/memory-gate.mjs +62 -8
- package/main/memory-log.mjs +124 -0
- 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 +64 -5
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +571 -0
- 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-address.mjs +28 -0
- package/main/relay-client.mjs +314 -0
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +18 -47
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/runs-on.mjs +32 -35
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +155 -9
- package/main/settings.mjs +364 -50
- package/main/shell-path.mjs +234 -0
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +134 -20
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +45 -11
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/start-hold.mjs +73 -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 +227 -53
- package/main/supervisor.mjs +3865 -699
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +897 -105
- package/main/task-terminals.mjs +147 -41
- 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/updater.mjs +71 -9
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +365 -0
- 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 +30 -5
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-CoWB6YUK.js +1 -0
- package/renderer/dist/assets/arc-Ch0NrCul.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-CXz9M5tQ.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-DchGUuHH.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-Cip-zNyr.js +38 -0
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-BoXid_2r.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-Cpez1rRz.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DKA84YkE.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-Dg-SumPG.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-06TCvPm1.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-CMEBy1Td.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-BJ2ObmV-.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CGWOYXUE.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CiZp07XC.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-DJNIX719.js +217 -0
- 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-CbkyZOUH.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-78iOroZO.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-GcCopMs7.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DHLlXLkz.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-DuZSUHcH.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-Ckxu3cqH.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-CsTw9Ycn.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-CTXd8qVg.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-CJs1T_9m.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-CvjI23mf.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-d_IeAzOJ.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-zo9KFP3N.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-DqfYf5Dt.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-DAWsMLmo.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-4ml_bhwR.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-B_WcqSId.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-CI-jYqzc.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-B56VWRlV.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-D1-p09Lr.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-Cv3w7JoN.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-C35V2Spi.js +1 -0
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/mermaid.core-BzGT5Vo9.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-BZUfuymC.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-DFh9XNGe.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-C65tPVsy.js +39 -0
- package/renderer/dist/assets/product-name-CSNW6d7s.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-Cushj_nk.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-DZGebGx5.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-DpAB0MNo.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-D-29rHra.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-FfOQAZol.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-C_n6fDBY.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-BZjenzJG.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-DxaMU9vX.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-DTg5HJKz.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-Bc3MZDsl.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-GIVGk9Id.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-NmJnbQOg.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-p-kfGfnW.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/after-install.mjs +30 -8
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- 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 +62 -34
- package/shared/agents.d.mts +1 -0
- 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 +45 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- 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 +36 -0
- package/shared/corner-tag.mjs +396 -0
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +76 -300
- 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 +111 -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 +31 -0
- package/shared/notify-rules.mjs +55 -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/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +4 -1
- package/shared/rank.mjs +82 -19
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- 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 -13
- package/shared/runs-on.mjs +31 -52
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +24 -10
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +484 -30
- 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/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/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +12 -0
- package/shared/usage.mjs +56 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -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 +150 -29
- package/shared/work-lines.mjs +93 -245
- 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
package/main/crash-report.mjs
CHANGED
|
@@ -63,15 +63,38 @@ export function report(kind, err, extra = {}) {
|
|
|
63
63
|
return write(payload);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// THE ONE KIND WHOSE PROCESS DOES NOT SURVIVE BEING REPORTED. `onUncaught`
|
|
67
|
+
// re-throws, by the decision three lines below where it does it, so the main
|
|
68
|
+
// process stops. posthog-node's `capture` only buffers (flushAt 20,
|
|
69
|
+
// flushInterval 10s), and the `will-quit` flush never runs for a process that
|
|
70
|
+
// died, so for this kind the file on disk is the only copy that can reach
|
|
71
|
+
// anybody. It is therefore kept even when the live send said it took the
|
|
72
|
+
// report: one report counted twice is a worse number, and no report at all is
|
|
73
|
+
// a worse app.
|
|
74
|
+
const DIES_REPORTING = new Set(['main-uncaught']);
|
|
75
|
+
|
|
66
76
|
function write(payload) {
|
|
67
77
|
try {
|
|
68
78
|
fs.mkdirSync(ctx.dir, { recursive: true });
|
|
69
79
|
const file = path.join(ctx.dir, `crash-${payload.ts.replace(/[:.]/g, '-')}-${Math.floor(Math.random() * 1e6)}.json`);
|
|
70
80
|
fs.writeFileSync(file, JSON.stringify(payload, null, 2));
|
|
71
81
|
prune();
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
82
|
+
// ON DISK FIRST, ALWAYS, because this can run while the process is dying
|
|
83
|
+
// and there is no time to wait on a socket. No await, no retry.
|
|
84
|
+
//
|
|
85
|
+
// AND THEN TAKEN BACK OFF IT IF IT WENT. The queue is for a report nobody
|
|
86
|
+
// has sent yet; a report that HAS been sent and stays in it is sent a
|
|
87
|
+
// second time by the next launch's `drainCrashes`, which is 6 of the 21
|
|
88
|
+
// crash events since the Oct 6 launch being 6 crashes counted twice, and
|
|
89
|
+
// three of them filed against the version that drained them rather than
|
|
90
|
+
// the one they happened on (PostHog, 2026-10-08).
|
|
91
|
+
if (transport) {
|
|
92
|
+
let took = false;
|
|
93
|
+
try { took = transport(payload) !== false; } catch { took = false; }
|
|
94
|
+
if (took && !DIES_REPORTING.has(payload.kind)) {
|
|
95
|
+
try { fs.unlinkSync(file); } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
75
98
|
return file;
|
|
76
99
|
} catch { return null; }
|
|
77
100
|
}
|
package/main/dev-window.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The change that hurts is one to App.tsx that adds, removes or moves a
|
|
9
9
|
// hook, which is what a branch switch does to it. React Fast Refresh keeps a
|
|
10
10
|
// component's state only while its hooks match, so App is rebuilt from nothing:
|
|
11
|
-
// `snap` is null again, App.tsx draws
|
|
11
|
+
// `snap` is null again, App.tsx draws its boot screen (`<Booting />`), and
|
|
12
12
|
// every child of App is thrown away and remade. The window drops back to the
|
|
13
13
|
// loading page, and an open artifact closes and reopens. The
|
|
14
14
|
// page never navigates, which is why nothing in the dev server's log looked bad
|
|
@@ -0,0 +1,545 @@
|
|
|
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.
|
|
3
|
+
//
|
|
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
|
+
//
|
|
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.
|
|
13
|
+
//
|
|
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`).
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
/** Every package manager's default bin directory, in order, shared by all engines. */
|
|
24
|
+
export function sharedBinDirs(home = os.homedir()) {
|
|
25
|
+
return [
|
|
26
|
+
'/opt/homebrew/bin', // Homebrew, Apple Silicon
|
|
27
|
+
'/usr/local/bin', // Homebrew on Intel, and npm -g
|
|
28
|
+
path.join(home, '.bun/bin'), // bun
|
|
29
|
+
path.join(home, '.volta/bin'), // volta
|
|
30
|
+
path.join(home, '.asdf/shims'), // asdf, through its shims
|
|
31
|
+
path.join(home, '.npm-global/bin'), // the two npm prefixes people
|
|
32
|
+
path.join(home, '.npm-packages/bin'), // actually write in a guide
|
|
33
|
+
path.join(home, '.yarn/bin'), // yarn global
|
|
34
|
+
path.join(home, 'Library/pnpm'), // pnpm's own bin on macOS
|
|
35
|
+
path.join(home, '.local/share/pnpm'), // pnpm's own bin on Linux
|
|
36
|
+
path.join(home, '.deno/bin'), // deno
|
|
37
|
+
path.join(home, 'bin'), // a hand-rolled ~/bin on PATH
|
|
38
|
+
'/opt/local/bin', // MacPorts
|
|
39
|
+
'/usr/bin', // packaged by something system-wide
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export function versionedBinPaths(bin, home = os.homedir(), readdir = defaultReaddir) {
|
|
48
|
+
const under = (dir, tail) => readdir(path.join(home, dir)).map((v) => path.join(home, dir, v, tail));
|
|
49
|
+
return [
|
|
50
|
+
...under('.nvm/versions/node', `bin/${bin}`),
|
|
51
|
+
...under('.local/share/fnm/node-versions', `installation/bin/${bin}`),
|
|
52
|
+
...under('Library/Application Support/fnm/node-versions', `installation/bin/${bin}`),
|
|
53
|
+
...under('n/versions/node', `bin/${bin}`),
|
|
54
|
+
...under('.asdf/installs/nodejs', `bin/${bin}`),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultReaddir(dir) {
|
|
59
|
+
try { return fs.readdirSync(dir); } catch { return []; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
function defaultDangles(p) {
|
|
68
|
+
try { return !!fs.lstatSync(p) && !fs.existsSync(p); } catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Newest version folder first, by number and not by spelling. */
|
|
72
|
+
function newestFirst(a, b) {
|
|
73
|
+
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
74
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
75
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a new engine has to declare:
|
|
82
|
+
*
|
|
83
|
+
* bin the program name, which is also its key here
|
|
84
|
+
* name what it is called on a screen
|
|
85
|
+
* installUrl where to get it, when we are sure it is not here
|
|
86
|
+
* ownPaths where the tool's own installer puts it, tried first
|
|
87
|
+
* appCopies a copy carried inside a desktop app, tried last of all
|
|
88
|
+
* evidence files it leaves behind; never spawned, and only ever stop us
|
|
89
|
+
* saying it is missing
|
|
90
|
+
*/
|
|
91
|
+
function engine({ bin, name, installUrl, ownPaths, appCopies = () => [], evidence }) {
|
|
92
|
+
return {
|
|
93
|
+
bin,
|
|
94
|
+
name,
|
|
95
|
+
installUrl,
|
|
96
|
+
ownPaths,
|
|
97
|
+
appCopies,
|
|
98
|
+
evidence,
|
|
99
|
+
// The first that exists wins.
|
|
100
|
+
candidates(home = os.homedir(), readdir = defaultReaddir) {
|
|
101
|
+
return [
|
|
102
|
+
...ownPaths(home),
|
|
103
|
+
...sharedBinDirs(home).map((dir) => path.join(dir, bin)),
|
|
104
|
+
...versionedBinPaths(bin, home, readdir),
|
|
105
|
+
];
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const ENGINES = {
|
|
111
|
+
/**
|
|
112
|
+
* Claude Code is required: the app will not open an inbox without it.
|
|
113
|
+
*
|
|
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.
|
|
118
|
+
*/
|
|
119
|
+
claude: engine({
|
|
120
|
+
bin: 'claude',
|
|
121
|
+
name: 'Claude Code',
|
|
122
|
+
installUrl: 'https://code.claude.com/docs/en/setup',
|
|
123
|
+
ownPaths: (home) => [
|
|
124
|
+
path.join(home, '.local/bin/claude'), // the installer
|
|
125
|
+
path.join(home, '.claude/local/claude'), // claude migrate-installer
|
|
126
|
+
],
|
|
127
|
+
appCopies: (home, readdir = defaultReaddir) => {
|
|
128
|
+
const dir = path.join(home, 'Library/Application Support/Claude/claude-code');
|
|
129
|
+
return readdir(dir)
|
|
130
|
+
.filter((v) => /^\d+(\.\d+)*$/.test(v))
|
|
131
|
+
.sort(newestFirst)
|
|
132
|
+
.map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
|
|
133
|
+
},
|
|
134
|
+
// Any one of these means Claude Code has run on this machine.
|
|
135
|
+
evidence: (home) => [
|
|
136
|
+
path.join(home, '.claude.json'), // its own config file
|
|
137
|
+
path.join(home, '.claude/settings.json'),
|
|
138
|
+
path.join(home, '.claude/projects'), // one folder per project it has opened
|
|
139
|
+
path.join(home, '.claude/statsig'),
|
|
140
|
+
path.join(home, '.claude/.credentials.json'), // it has been logged in
|
|
141
|
+
path.join(home, '.claude/history.jsonl'),
|
|
142
|
+
path.join(home, '.local/share/claude'), // the installer's version store
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Codex is optional, so its absence never produces a warning; it is just
|
|
148
|
+
* left out of the map `shared/engines.mjs` reads.
|
|
149
|
+
*
|
|
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.
|
|
156
|
+
*/
|
|
157
|
+
codex: engine({
|
|
158
|
+
bin: 'codex',
|
|
159
|
+
name: 'Codex',
|
|
160
|
+
installUrl: 'https://learn.chatgpt.com/docs/codex/cli',
|
|
161
|
+
ownPaths: (home) => [
|
|
162
|
+
path.join(home, '.local/bin/codex'), // the shell installer's link
|
|
163
|
+
path.join(home, '.codex/packages/standalone/current/bin/codex'),
|
|
164
|
+
path.join(home, '.codex/packages/standalone/current/codex'),
|
|
165
|
+
],
|
|
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).
|
|
168
|
+
appCopies: (home) => [
|
|
169
|
+
'ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
|
|
170
|
+
'Codex.app/Contents/Resources/codex',
|
|
171
|
+
].flatMap((p) => [path.join('/Applications', p), path.join(home, 'Applications', p)]),
|
|
172
|
+
// Both homes when CODEX_HOME is set: an inherited CODEX_HOME does not prove
|
|
173
|
+
// ~/.codex is unused.
|
|
174
|
+
evidence: (home, env = process.env) => {
|
|
175
|
+
const homes = [env?.CODEX_HOME, path.join(home, '.codex')].filter(Boolean);
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const dir of new Set(homes)) {
|
|
178
|
+
out.push(
|
|
179
|
+
path.join(dir, 'auth.json'), // it has been logged in
|
|
180
|
+
path.join(dir, 'config.toml'),
|
|
181
|
+
path.join(dir, 'sessions'), // one folder tree of past runs
|
|
182
|
+
path.join(dir, 'history.jsonl'),
|
|
183
|
+
path.join(dir, 'packages/standalone/releases'), // the installer's version store
|
|
184
|
+
dir, // and the directory itself, last
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export function engineKeys() {
|
|
193
|
+
return Object.keys(ENGINES);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function engineFor(key) {
|
|
197
|
+
const found = ENGINES[key];
|
|
198
|
+
if (!found) throw new Error(`no coding agent is declared as ${JSON.stringify(key)}`);
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Their $SHELL first, then zsh and bash, because a $SHELL that will not start
|
|
203
|
+
// says nothing about the tool.
|
|
204
|
+
export function shellProbes(env = process.env) {
|
|
205
|
+
const preferred = env.SHELL || '/bin/zsh';
|
|
206
|
+
const out = [[preferred, '-lic', 4000], [preferred, '-lc', 2500]];
|
|
207
|
+
for (const other of ['/bin/zsh', '/bin/bash']) {
|
|
208
|
+
if (other !== preferred) out.push([other, '-lic', 2500]);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
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.
|
|
218
|
+
*/
|
|
219
|
+
const PROGRAM_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
220
|
+
|
|
221
|
+
/**
|
|
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`.
|
|
227
|
+
*/
|
|
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 }) {
|
|
252
|
+
if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
|
|
253
|
+
throw new Error(`askShell looks up a program name, and ${JSON.stringify(bin)} is not one`);
|
|
254
|
+
}
|
|
255
|
+
let said = false;
|
|
256
|
+
let inconclusive = false;
|
|
257
|
+
for (const [shell, flags, timeout] of shellProbes(env)) {
|
|
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;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const line = String(out).split('\n').map((s) => s.trim()).filter(Boolean).pop();
|
|
266
|
+
if (line && line.startsWith('/') && exists(line)) return { path: line, answered: true };
|
|
267
|
+
said = true;
|
|
268
|
+
}
|
|
269
|
+
return { path: null, answered: said && !inconclusive };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A timeout and a missing shell also throw here, and neither is a fact about
|
|
273
|
+
// the tool.
|
|
274
|
+
function exitedSayingNo(e) {
|
|
275
|
+
if (!e || typeof e !== 'object') return false;
|
|
276
|
+
if (e.signal || e.code === 'ETIMEDOUT' || e.code === 'ENOENT') return false;
|
|
277
|
+
return typeof e.status === 'number';
|
|
278
|
+
}
|
|
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
|
+
|
|
329
|
+
function defaultRun(shell, args, timeout = 4000) {
|
|
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();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// A lookup that returns a bare path or null (the old shape) counts as answered.
|
|
361
|
+
function shellSaid(result) {
|
|
362
|
+
if (typeof result === 'string') return { path: result, answered: true };
|
|
363
|
+
if (!result || typeof result !== 'object') return { path: null, answered: true };
|
|
364
|
+
return { path: result.path ?? null, answered: result.answered !== false };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
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.
|
|
373
|
+
*/
|
|
374
|
+
export function askShellForAll(bins, { env = process.env, exists = fs.existsSync, run = defaultRun } = {}) {
|
|
375
|
+
for (const bin of bins) {
|
|
376
|
+
if (typeof bin !== 'string' || !PROGRAM_NAME.test(bin)) {
|
|
377
|
+
throw new Error(`askShellForAll looks up program names, and ${JSON.stringify(bin)} is not one`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const command = bins.map((b) => `command -v ${b}`).join('; ');
|
|
381
|
+
for (const [shell, flags, timeout] of shellProbes(env)) {
|
|
382
|
+
let out = '';
|
|
383
|
+
try { out = run(shell, [flags, command], timeout); } catch { continue; }
|
|
384
|
+
const found = {};
|
|
385
|
+
for (const line of String(out).split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
386
|
+
if (!line.startsWith('/') || !exists(line)) continue;
|
|
387
|
+
const bin = bins.find((b) => path.basename(line) === b);
|
|
388
|
+
if (bin && !found[bin]) found[bin] = line;
|
|
389
|
+
}
|
|
390
|
+
return found;
|
|
391
|
+
}
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Seed every engine's cached shell answer from one probe. If the shell fails,
|
|
397
|
+
* each engine falls back to its own probe.
|
|
398
|
+
*/
|
|
399
|
+
export function warmShellAnswers({ now = Date.now(), ...how } = {}) {
|
|
400
|
+
let found = {};
|
|
401
|
+
try { found = askShellForAll(engineKeys().map((k) => ENGINES[k].bin), how); } catch { return; }
|
|
402
|
+
for (const key of engineKeys()) {
|
|
403
|
+
const where = found[ENGINES[key].bin];
|
|
404
|
+
if (where) held.set(key, { answer: { path: where, answered: true }, at: now });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A lookup that throws is read as no answer, so the list still gets its turn.
|
|
409
|
+
function askedShell(lookup) {
|
|
410
|
+
try { return shellSaid(lookup()); } catch { return { path: null, answered: false }; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
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.
|
|
416
|
+
*/
|
|
417
|
+
export function installEvidence(key, {
|
|
418
|
+
home = os.homedir(), env = process.env, exists = fs.existsSync, dangles = defaultDangles,
|
|
419
|
+
} = {}) {
|
|
420
|
+
const spec = engineFor(key);
|
|
421
|
+
for (const p of spec.evidence(home, env)) if (exists(p)) return p;
|
|
422
|
+
for (const p of spec.candidates(home, () => [])) if (dangles(p)) return p;
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
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.
|
|
431
|
+
*/
|
|
432
|
+
export function findEngine(key, {
|
|
433
|
+
configured = null,
|
|
434
|
+
home = os.homedir(),
|
|
435
|
+
env = process.env,
|
|
436
|
+
exists = fs.existsSync,
|
|
437
|
+
shellLookup = null,
|
|
438
|
+
readdir = defaultReaddir,
|
|
439
|
+
dangles = defaultDangles,
|
|
440
|
+
} = {}) {
|
|
441
|
+
const spec = engineFor(key);
|
|
442
|
+
if (configured) {
|
|
443
|
+
return {
|
|
444
|
+
path: configured, found: exists(configured), certain: true, from: 'settings',
|
|
445
|
+
searched: [], evidence: null,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
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.
|
|
451
|
+
const lookup = shellLookup ?? (() => askShell({ bin: spec.bin }));
|
|
452
|
+
const fromShell = askedShell(lookup);
|
|
453
|
+
const searched = spec.candidates(home, readdir);
|
|
454
|
+
if (fromShell.path) {
|
|
455
|
+
return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const candidate of searched) {
|
|
459
|
+
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'disk', searched, evidence: null };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const candidate of spec.appCopies(home, readdir)) {
|
|
463
|
+
if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Any trace of the tool means we looked in the wrong places, so we may not
|
|
467
|
+
// say it is missing.
|
|
468
|
+
const evidence = installEvidence(key, { home, env, exists, dangles });
|
|
469
|
+
return {
|
|
470
|
+
path: null,
|
|
471
|
+
found: false,
|
|
472
|
+
certain: fromShell.answered && !evidence,
|
|
473
|
+
from: null,
|
|
474
|
+
searched,
|
|
475
|
+
evidence,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
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.
|
|
485
|
+
|
|
486
|
+
const SHELL_TTL = 30_000;
|
|
487
|
+
const held = new Map();
|
|
488
|
+
const asking = new Map();
|
|
489
|
+
|
|
490
|
+
export function resolveEngine(key, configured = null, { now = Date.now(), shellLookup = null, shellLookupLater = null, ...where } = {}) {
|
|
491
|
+
const spec = engineFor(key);
|
|
492
|
+
const remembered = () => {
|
|
493
|
+
const last = held.get(key);
|
|
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;
|
|
508
|
+
};
|
|
509
|
+
return findEngine(key, { ...where, configured, shellLookup: remembered });
|
|
510
|
+
}
|
|
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
|
+
|
|
540
|
+
export function forgetEngine(key) {
|
|
541
|
+
engineFor(key); // throws for an undeclared engine
|
|
542
|
+
held.delete(key);
|
|
543
|
+
// An answer already on its way was asked for before this, so it is dropped.
|
|
544
|
+
asking.delete(key);
|
|
545
|
+
}
|