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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// TASK TERMINALS OUTLIVE THE APP. This process holds the shells. It is started
|
|
2
|
+
// detached, so quitting or restarting the app leaves them running; restarting
|
|
3
|
+
// the computer ends them like everything else. The app reconnects when it starts.
|
|
4
|
+
//
|
|
5
|
+
// Lines of JSON over a socket only this user can reach: `{id, op, args}` in,
|
|
6
|
+
// `{id, value}` or `{id, error}` back. The ops are TaskTerminals' own.
|
|
7
|
+
// With no terminal and no app connected for a minute it ends itself.
|
|
8
|
+
//
|
|
9
|
+
// ELECTRON_RUN_AS_NODE=1 <electron> main/terminal-host.mjs <socket>
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { nameSlug } from '../shared/product-name.mjs';
|
|
17
|
+
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// The app talks to any host speaking this, whichever version started it, so
|
|
20
|
+
// the terminals an older app left are still reachable after an update. Change
|
|
21
|
+
// it only with a client that still speaks the old one.
|
|
22
|
+
export const PROTOCOL = 1;
|
|
23
|
+
// A host started by older code keeps the terminals it has; new terminals open
|
|
24
|
+
// in a host made of the code the app is running now.
|
|
25
|
+
const MADE_OF = ['terminal-host.mjs', 'task-terminals.mjs', '../shared/terminal-state.mjs'];
|
|
26
|
+
export const HOST_ALREADY_THERE = 3;
|
|
27
|
+
const OPS = new Set(['open', 'read', 'write', 'resize', 'close', 'end', 'list']);
|
|
28
|
+
|
|
29
|
+
export function terminalHostStamp() {
|
|
30
|
+
const h = crypto.createHash('sha1');
|
|
31
|
+
for (const f of MADE_OF) { try { h.update(fs.readFileSync(path.join(HERE, f))); } catch { h.update(f); } }
|
|
32
|
+
return h.digest('hex').slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const prefix = (uid) => `${nameSlug}-terminals-${uid}-`;
|
|
36
|
+
|
|
37
|
+
// Short enough for a socket's path (104 bytes on macOS), and this user's own.
|
|
38
|
+
export function terminalHostSocket({ stamp = terminalHostStamp(), dir = os.tmpdir(), uid = process.getuid?.() ?? 0 } = {}) {
|
|
39
|
+
return path.join(dir, `${prefix(uid)}${stamp}.sock`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every host socket of this user's, newest code or not. */
|
|
43
|
+
export function terminalHostSockets({ dir = os.tmpdir(), uid = process.getuid?.() ?? 0 } = {}) {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readdirSync(dir).filter((f) => f.startsWith(prefix(uid)) && f.endsWith('.sock')).map((f) => path.join(dir, f));
|
|
46
|
+
} catch { return []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function serveTerminals({ sockPath, terminals, stamp = terminalHostStamp(), idleMs = 60_000, onIdle = () => {}, log = () => {} }) {
|
|
50
|
+
let clients = 0;
|
|
51
|
+
let idleTimer = null;
|
|
52
|
+
const idle = () => {
|
|
53
|
+
clearTimeout(idleTimer);
|
|
54
|
+
if (clients || terminals.sessions.size) return;
|
|
55
|
+
idleTimer = setTimeout(() => { if (!clients && !terminals.sessions.size) onIdle(); }, idleMs);
|
|
56
|
+
idleTimer.unref?.();
|
|
57
|
+
};
|
|
58
|
+
const watch = setInterval(idle, idleMs);
|
|
59
|
+
watch.unref?.();
|
|
60
|
+
const answer = async ({ op, args = [] }) => {
|
|
61
|
+
if (op === 'hello') return { protocol: PROTOCOL, stamp, pid: process.pid };
|
|
62
|
+
if (!OPS.has(op)) throw new Error(`Unknown terminal action: ${op}`);
|
|
63
|
+
// The host cannot read the store, so it never works out a folder itself:
|
|
64
|
+
// a terminal it no longer has is one the app opens afresh.
|
|
65
|
+
if (op === 'open' && !args[1] && !terminals.sessions.has(args[0])) throw new Error('Terminal session ended. Open a new terminal to continue.');
|
|
66
|
+
return terminals[op](...args);
|
|
67
|
+
};
|
|
68
|
+
const server = net.createServer((sock) => {
|
|
69
|
+
clients += 1;
|
|
70
|
+
clearTimeout(idleTimer);
|
|
71
|
+
let buf = '';
|
|
72
|
+
const send = (msg) => { if (!sock.destroyed) sock.write(`${JSON.stringify(msg)}\n`); };
|
|
73
|
+
sock.setEncoding('utf8');
|
|
74
|
+
sock.on('data', (d) => {
|
|
75
|
+
buf += d;
|
|
76
|
+
let n;
|
|
77
|
+
while ((n = buf.indexOf('\n')) >= 0) {
|
|
78
|
+
const line = buf.slice(0, n);
|
|
79
|
+
buf = buf.slice(n + 1);
|
|
80
|
+
let msg;
|
|
81
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
82
|
+
Promise.resolve().then(() => answer(msg))
|
|
83
|
+
.then((value) => send({ id: msg.id, value: value ?? null }), (err) => send({ id: msg.id, error: String(err?.message ?? err) }));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
sock.on('close', () => { clients -= 1; idle(); });
|
|
87
|
+
sock.on('error', () => {});
|
|
88
|
+
});
|
|
89
|
+
server.on('error', (err) => log(`terminal host: ${err.message}`));
|
|
90
|
+
// Node deletes the path a server listened on as it closes, so it listens on
|
|
91
|
+
// a name of its own and moves it into place, and on the way out takes away
|
|
92
|
+
// only a socket that is still its own.
|
|
93
|
+
let mine = null;
|
|
94
|
+
const own = `${sockPath}.${process.pid}`;
|
|
95
|
+
const ours = () => { try { return mine !== null && fs.statSync(sockPath).ino === mine; } catch { return false; } };
|
|
96
|
+
const started = new Promise((resolve) => {
|
|
97
|
+
const probe = net.connect(sockPath);
|
|
98
|
+
const timer = setTimeout(() => { probe.destroy(); resolve('taken'); }, 2_000);
|
|
99
|
+
probe.once('connect', () => { clearTimeout(timer); probe.destroy(); resolve('taken'); });
|
|
100
|
+
probe.once('error', () => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
try { fs.unlinkSync(own); } catch { /* not there */ }
|
|
103
|
+
server.listen(own, () => {
|
|
104
|
+
try { fs.chmodSync(own, 0o600); fs.renameSync(own, sockPath); mine = fs.statSync(sockPath).ino; } catch (err) { log(`terminal host: ${err.message}`); }
|
|
105
|
+
idle();
|
|
106
|
+
resolve('serving');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
server,
|
|
112
|
+
started,
|
|
113
|
+
close: () => started.then(() => new Promise((resolve) => {
|
|
114
|
+
clearTimeout(idleTimer);
|
|
115
|
+
clearInterval(watch);
|
|
116
|
+
if (ours()) { try { fs.unlinkSync(sockPath); } catch { /* gone */ } }
|
|
117
|
+
if (server.listening) server.close(() => resolve()); else resolve();
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Started as a program by the app (main/terminal-hosts.mjs).
|
|
123
|
+
if (path.basename(process.argv[1] || '') === 'terminal-host.mjs') {
|
|
124
|
+
const sockPath = process.argv[2];
|
|
125
|
+
// Loaded before the socket opens, so a host that cannot make a shell never
|
|
126
|
+
// answers and the app keeps its terminals itself.
|
|
127
|
+
(await import('node:module')).createRequire(import.meta.url)('node-pty');
|
|
128
|
+
const { TaskTerminals } = await import('./task-terminals.mjs');
|
|
129
|
+
const { readMacPressureWord } = await import('./leftovers.mjs');
|
|
130
|
+
const terminals = new TaskTerminals({ readPressure: process.platform === 'darwin' ? readMacPressureWord : null });
|
|
131
|
+
const host = serveTerminals({
|
|
132
|
+
sockPath,
|
|
133
|
+
terminals,
|
|
134
|
+
onIdle: () => host.close().then(() => process.exit(0)),
|
|
135
|
+
log: (m) => process.stderr.write(`${m}\n`),
|
|
136
|
+
});
|
|
137
|
+
if (await host.started === 'taken') process.exit(HOST_ALREADY_THERE);
|
|
138
|
+
// One shell's stray error is that shell's, not every terminal's.
|
|
139
|
+
process.on('uncaughtException', (err) => process.stderr.write(`terminal host: ${err?.stack || err}\n`));
|
|
140
|
+
process.on('unhandledRejection', (err) => process.stderr.write(`terminal host: ${err?.stack || err}\n`));
|
|
141
|
+
// The app going is not a reason to end, and nor is the terminal it ran from.
|
|
142
|
+
process.on('SIGHUP', () => {});
|
|
143
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// THE APP'S SIDE OF ./terminal-host.mjs: the same calls as TaskTerminals, each
|
|
2
|
+
// sent to whichever host holds that terminal. A new terminal opens in the host
|
|
3
|
+
// made of this app's code, started when there is none; one an older app left
|
|
4
|
+
// stays in the older host until it ends. If no host will start, terminals run
|
|
5
|
+
// inside the app as they used to, and the terminal's first line says so.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { PROTOCOL, HOST_ALREADY_THERE, terminalHostStamp, terminalHostSocket, terminalHostSockets } from './terminal-host.mjs';
|
|
12
|
+
import { ENDED } from './task-terminals.mjs';
|
|
13
|
+
import { SETTINGS_TERMINAL } from '../shared/settings-terminal.mjs';
|
|
14
|
+
import { NAME } from '../shared/product-name.mjs';
|
|
15
|
+
|
|
16
|
+
const ENDED_SESSION = 'Terminal session ended. Open a new terminal to continue.';
|
|
17
|
+
// Closing a task ends its terminal this long after, so a Z that reopens it
|
|
18
|
+
// keeps the terminal.
|
|
19
|
+
export const CLOSE_GRACE_MS = 2 * 60_000;
|
|
20
|
+
// A host that would not start is not tried again for this long.
|
|
21
|
+
const RETRY_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
export function launchTerminalHost(sockPath, { logFile = null } = {}) {
|
|
24
|
+
const out = logFile ? fs.openSync(logFile, 'a') : 'ignore';
|
|
25
|
+
try {
|
|
26
|
+
const proc = spawn(process.execPath, [fileURLToPath(new URL('./terminal-host.mjs', import.meta.url)), sockPath], {
|
|
27
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: ['ignore', out, out],
|
|
30
|
+
});
|
|
31
|
+
proc.on('error', () => {});
|
|
32
|
+
proc.unref();
|
|
33
|
+
return proc;
|
|
34
|
+
} finally {
|
|
35
|
+
if (typeof out === 'number') fs.closeSync(out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One socket to one host, every call with a deadline that ends it. */
|
|
40
|
+
export function connectTerminalHost(sockPath, { connectMs = 2_000, callMs = 5_000 } = {}) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const sock = net.connect(sockPath);
|
|
43
|
+
const pending = new Map();
|
|
44
|
+
let next = 1;
|
|
45
|
+
let buf = '';
|
|
46
|
+
const conn = {
|
|
47
|
+
sockPath,
|
|
48
|
+
closed: false,
|
|
49
|
+
call(op, ...args) {
|
|
50
|
+
if (conn.closed) return Promise.reject(new Error(ENDED_SESSION));
|
|
51
|
+
const id = next++;
|
|
52
|
+
return new Promise((res, rej) => {
|
|
53
|
+
const timer = setTimeout(() => { pending.delete(id); rej(new Error('The terminal did not answer.')); }, callMs);
|
|
54
|
+
pending.set(id, { res, rej, timer });
|
|
55
|
+
sock.write(`${JSON.stringify({ id, op, args })}\n`);
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
close() { sock.destroy(); },
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error('The terminal host did not answer.')); }, connectMs);
|
|
61
|
+
sock.setEncoding('utf8');
|
|
62
|
+
sock.once('connect', () => { clearTimeout(timer); resolve(conn); });
|
|
63
|
+
sock.on('data', (d) => {
|
|
64
|
+
buf += d;
|
|
65
|
+
let n;
|
|
66
|
+
while ((n = buf.indexOf('\n')) >= 0) {
|
|
67
|
+
let msg = null;
|
|
68
|
+
try { msg = JSON.parse(buf.slice(0, n)); } catch { /* not ours */ }
|
|
69
|
+
buf = buf.slice(n + 1);
|
|
70
|
+
const p = msg && pending.get(msg.id);
|
|
71
|
+
if (!p) continue;
|
|
72
|
+
pending.delete(msg.id);
|
|
73
|
+
clearTimeout(p.timer);
|
|
74
|
+
if (msg.error !== undefined) p.rej(new Error(msg.error)); else p.res(msg.value);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
sock.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
78
|
+
sock.on('close', () => {
|
|
79
|
+
conn.closed = true;
|
|
80
|
+
for (const p of pending.values()) { clearTimeout(p.timer); p.rej(new Error(ENDED_SESSION)); }
|
|
81
|
+
pending.clear();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a terminal's task is closed. Only a task the store says is done: one
|
|
88
|
+
* it cannot find may be another store's, and the sign-in terminal and an
|
|
89
|
+
* outside agent's belong to no task.
|
|
90
|
+
*/
|
|
91
|
+
export const taskClosed = (store) => (key) => {
|
|
92
|
+
const { product, id } = JSON.parse(key);
|
|
93
|
+
if (product === SETTINGS_TERMINAL.product || String(id).startsWith('agent:')) return false;
|
|
94
|
+
return store.readItem?.(product, id)?.status === 'done';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Terminals whose task has been closed for `graceMs` on every look since. `seen`
|
|
99
|
+
* carries when each was first seen closed from one look to the next.
|
|
100
|
+
*/
|
|
101
|
+
export function closedTerminals(list, isClosed, seen, now, graceMs = CLOSE_GRACE_MS) {
|
|
102
|
+
const keys = new Set(list.map((s) => s.key));
|
|
103
|
+
for (const key of [...seen.keys()]) if (!keys.has(key)) seen.delete(key);
|
|
104
|
+
const due = [];
|
|
105
|
+
for (const { key } of list) {
|
|
106
|
+
let closed = false;
|
|
107
|
+
try { closed = !!isClosed(key); } catch { closed = false; }
|
|
108
|
+
if (!closed) { seen.delete(key); continue; }
|
|
109
|
+
if (!seen.has(key)) seen.set(key, now);
|
|
110
|
+
if (now - seen.get(key) >= graceMs) due.push(key);
|
|
111
|
+
}
|
|
112
|
+
return due;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class TerminalHosts {
|
|
116
|
+
/**
|
|
117
|
+
* `local` is the app's own TaskTerminals, for when no host will start.
|
|
118
|
+
*/
|
|
119
|
+
constructor({
|
|
120
|
+
local,
|
|
121
|
+
stamp = terminalHostStamp(),
|
|
122
|
+
dir = os.tmpdir(),
|
|
123
|
+
uid = process.getuid?.() ?? 0,
|
|
124
|
+
launch = launchTerminalHost,
|
|
125
|
+
connect = connectTerminalHost,
|
|
126
|
+
logFile = null,
|
|
127
|
+
startMs = 8_000,
|
|
128
|
+
now = () => Date.now(),
|
|
129
|
+
log = () => {},
|
|
130
|
+
}) {
|
|
131
|
+
Object.assign(this, { local, stamp, dir, uid, launch, connect, logFile, startMs, now, log });
|
|
132
|
+
this.ownPath = terminalHostSocket({ stamp, dir, uid });
|
|
133
|
+
this.conns = new Map();
|
|
134
|
+
this.where = new Map();
|
|
135
|
+
this.closedSeen = new Map();
|
|
136
|
+
this.failedAt = null;
|
|
137
|
+
this.failure = null;
|
|
138
|
+
const t = local;
|
|
139
|
+
this.inApp = { local: true, call: async (op, ...args) => t[op](...args) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async _hello(sockPath) {
|
|
143
|
+
const had = this.conns.get(sockPath);
|
|
144
|
+
if (had && !had.closed) return had;
|
|
145
|
+
// Every open hands over this user's environment, so never to a socket
|
|
146
|
+
// somebody else put at the name.
|
|
147
|
+
try { if (process.getuid && fs.statSync(sockPath).uid !== process.getuid()) return null; } catch { return null; }
|
|
148
|
+
let conn;
|
|
149
|
+
try { conn = await this.connect(sockPath); } catch { return null; }
|
|
150
|
+
try {
|
|
151
|
+
const hi = await conn.call('hello');
|
|
152
|
+
if (hi?.protocol !== PROTOCOL) { conn.close(); return null; }
|
|
153
|
+
} catch { conn.close(); return null; }
|
|
154
|
+
this.conns.set(sockPath, conn);
|
|
155
|
+
return conn;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The hosts an earlier run of the app left, found once. */
|
|
159
|
+
ready() { return (this.discovered ??= this.discover().catch(() => {})); }
|
|
160
|
+
|
|
161
|
+
/** Every host this user has, and which terminal is in which. */
|
|
162
|
+
async discover() {
|
|
163
|
+
for (const sockPath of terminalHostSockets({ dir: this.dir, uid: this.uid })) await this._hello(sockPath);
|
|
164
|
+
await this.list();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The host made of this app's code, started if there is none. */
|
|
168
|
+
async _mine() {
|
|
169
|
+
const there = await this._hello(this.ownPath);
|
|
170
|
+
if (there) return there;
|
|
171
|
+
if (this.failedAt !== null && this.now() - this.failedAt < RETRY_MS) throw new Error(this.failure);
|
|
172
|
+
let proc;
|
|
173
|
+
try { proc = this.launch(this.ownPath, { logFile: this.logFile }); } catch (err) { return this._failed(err.message); }
|
|
174
|
+
const end = Date.now() + this.startMs;
|
|
175
|
+
while (Date.now() < end) {
|
|
176
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
177
|
+
const conn = await this._hello(this.ownPath);
|
|
178
|
+
if (conn) { this.failedAt = null; this.failure = null; return conn; }
|
|
179
|
+
if (Number.isInteger(proc?.exitCode) && proc.exitCode !== HOST_ALREADY_THERE) return this._failed(`it exited with code ${proc.exitCode}`);
|
|
180
|
+
}
|
|
181
|
+
return this._failed(`no answer in ${Math.round(this.startMs / 1000)} seconds`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_failed(why) {
|
|
185
|
+
this.failedAt = this.now();
|
|
186
|
+
this.failure = why;
|
|
187
|
+
this.log(`terminal host: ${why}`);
|
|
188
|
+
throw new Error(why);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_at(key) {
|
|
192
|
+
const at = this.where.get(key);
|
|
193
|
+
if (!at || at.closed) throw new Error(ENDED_SESSION);
|
|
194
|
+
return at;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async open(key, place) {
|
|
198
|
+
await this.ready();
|
|
199
|
+
const at = this.where.get(key);
|
|
200
|
+
if (at && !at.closed && (!at.local || this.local.sessions.has(key))) {
|
|
201
|
+
try { return await at.call('open', key); } catch { /* ended there: a new one below */ }
|
|
202
|
+
}
|
|
203
|
+
this.where.delete(key);
|
|
204
|
+
let host;
|
|
205
|
+
try { host = await this._mine(); } catch (err) {
|
|
206
|
+
const notice = `This terminal ends when ${NAME} quits, because the process that keeps terminals running through a restart would not start (${err.message}).`;
|
|
207
|
+
const state = this.local.open(key, { ...place, notice: [place?.notice, notice].filter(Boolean).join(' ') });
|
|
208
|
+
this.where.set(key, this.inApp);
|
|
209
|
+
return state;
|
|
210
|
+
}
|
|
211
|
+
const state = await host.call('open', key, place);
|
|
212
|
+
this.where.set(key, host);
|
|
213
|
+
return state;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
read(key, offset, wait) { return this._at(key).call('read', key, offset, wait); }
|
|
217
|
+
write(key, data) { return this._at(key).call('write', key, data); }
|
|
218
|
+
resize(key, cols, rows) { return this._at(key).call('resize', key, cols, rows); }
|
|
219
|
+
async close(key) {
|
|
220
|
+
const at = this.where.get(key);
|
|
221
|
+
this.where.delete(key);
|
|
222
|
+
if (at && !at.closed) await at.call('close', key);
|
|
223
|
+
}
|
|
224
|
+
async end(key, why) {
|
|
225
|
+
const at = this.where.get(key);
|
|
226
|
+
if (at && !at.closed) await at.call('end', key, why);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Every terminal, in every host and in the app, freshly asked. */
|
|
230
|
+
async list() {
|
|
231
|
+
const out = [];
|
|
232
|
+
const answered = new Set();
|
|
233
|
+
for (const [sockPath, conn] of this.conns) {
|
|
234
|
+
if (conn.closed) { this.conns.delete(sockPath); continue; }
|
|
235
|
+
let got = [];
|
|
236
|
+
try { got = await conn.call('list'); } catch { continue; }
|
|
237
|
+
answered.add(conn);
|
|
238
|
+
// An older host with nothing left ends itself once nobody is connected.
|
|
239
|
+
if (!got.length && sockPath !== this.ownPath) { conn.close(); this.conns.delete(sockPath); continue; }
|
|
240
|
+
for (const s of got) { this.where.set(s.key, conn); out.push(s); }
|
|
241
|
+
}
|
|
242
|
+
const listed = new Set(out.map((s) => s.key));
|
|
243
|
+
for (const [key, at] of this.where) {
|
|
244
|
+
if (at.closed || (answered.has(at) && !listed.has(key)) || (at.local && !this.local.sessions.has(key))) this.where.delete(key);
|
|
245
|
+
}
|
|
246
|
+
for (const s of this.local.list()) if (this.where.get(s.key) === this.inApp) out.push(s);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether this task has a terminal, as far as the app last knew. */
|
|
251
|
+
hasTerminal(id) {
|
|
252
|
+
for (const key of this.where.keys()) { try { if (JSON.parse(key).id === id) return true; } catch { /* not a task's */ } }
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async endClosed(isClosed) {
|
|
257
|
+
const due = closedTerminals(await this.list(), isClosed, this.closedSeen, this.now());
|
|
258
|
+
for (const key of due) { try { await this.end(key, ENDED.closed); } catch { /* gone already */ } }
|
|
259
|
+
return due;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// FINDING A CLAUDE CODE TRANSCRIPT WITHOUT HOLDING THE APP MORE THAN ONCE.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code keeps a session at `<projects>/<slug of its folder>/<id>.jsonl`.
|
|
4
|
+
// When it is not there the only way to find it is to look in every folder:
|
|
5
|
+
// ten thousand on a busy Mac, 1.4 s on the window's thread. So a session found
|
|
6
|
+
// nowhere is not looked for again for ten minutes, and a caller picks how to look:
|
|
7
|
+
// 'now' in this call, for an answer that decides a resume (an imported
|
|
8
|
+
// thread is filed under the folder it first ran in)
|
|
9
|
+
// 'later' in the background; this call answers null, a later one finds it
|
|
10
|
+
// 'never' only at the slug path: a cloud run's transcript comes home there
|
|
11
|
+
// and nowhere else (main/cloud/run.mjs transcriptFolderName)
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const MISS_KEPT_MS = 10 * 60_000;
|
|
16
|
+
|
|
17
|
+
const found = new Map(); // `${projects}\0${id}` -> transcript path
|
|
18
|
+
const missed = new Map(); // `${projects}\0${id}` -> when it was last looked for and not found
|
|
19
|
+
const wanted = new Map(); // projects folder -> session ids to look for
|
|
20
|
+
let search = null;
|
|
21
|
+
|
|
22
|
+
export const slugFor = (cwd) => String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
23
|
+
const keyOf = (projects, id) => `${projects}\0${id}`;
|
|
24
|
+
|
|
25
|
+
function remember(projects, id, file) {
|
|
26
|
+
if (file) { found.set(keyOf(projects, id), file); missed.delete(keyOf(projects, id)); return; }
|
|
27
|
+
missed.set(keyOf(projects, id), Date.now());
|
|
28
|
+
while (missed.size > 1000) missed.delete(missed.keys().next().value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The transcript's path, or null. `look` says where else to look when it is not at the slug path. */
|
|
32
|
+
export function findTranscript(projects, sessionId, cwd, { look = 'later' } = {}) {
|
|
33
|
+
if (!projects || !sessionId) return null;
|
|
34
|
+
const key = keyOf(projects, sessionId);
|
|
35
|
+
const known = found.get(key);
|
|
36
|
+
if (known && fs.existsSync(known)) return known;
|
|
37
|
+
// Claude Code names the folder from the resolved path: /tmp is /private/tmp on a Mac.
|
|
38
|
+
let real = cwd;
|
|
39
|
+
try { real = cwd ? fs.realpathSync(cwd) : cwd; } catch { /* the folder is gone */ }
|
|
40
|
+
for (const at of new Set([cwd, real])) {
|
|
41
|
+
const direct = path.join(projects, slugFor(at), `${sessionId}.jsonl`);
|
|
42
|
+
if (fs.existsSync(direct)) { found.set(key, direct); return direct; }
|
|
43
|
+
}
|
|
44
|
+
if (look === 'never' || Date.now() - (missed.get(key) ?? -Infinity) < MISS_KEPT_MS) return null;
|
|
45
|
+
if (look === 'later') { lookEverywhere(projects, sessionId); return null; }
|
|
46
|
+
let file = null;
|
|
47
|
+
try {
|
|
48
|
+
for (const dir of fs.readdirSync(projects)) {
|
|
49
|
+
const p = path.join(projects, dir, `${sessionId}.jsonl`);
|
|
50
|
+
if (fs.existsSync(p)) { file = p; break; }
|
|
51
|
+
}
|
|
52
|
+
} catch { /* no projects folder */ }
|
|
53
|
+
remember(projects, sessionId, file);
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lookEverywhere(projects, sessionId) {
|
|
58
|
+
if (!wanted.has(projects)) wanted.set(projects, new Set());
|
|
59
|
+
wanted.get(projects).add(sessionId);
|
|
60
|
+
search ??= (async () => {
|
|
61
|
+
while (wanted.size) {
|
|
62
|
+
const [root, ids] = wanted.entries().next().value;
|
|
63
|
+
wanted.delete(root);
|
|
64
|
+
const left = new Set(ids);
|
|
65
|
+
let dirs = [];
|
|
66
|
+
try { dirs = await fs.promises.readdir(root); } catch { /* no projects folder */ }
|
|
67
|
+
for (const dir of dirs) {
|
|
68
|
+
if (!left.size) break;
|
|
69
|
+
for (const id of [...left]) {
|
|
70
|
+
const p = path.join(root, dir, `${id}.jsonl`);
|
|
71
|
+
try { await fs.promises.access(p); remember(root, id, p); left.delete(id); } catch { /* not here */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const id of left) remember(root, id, null);
|
|
75
|
+
}
|
|
76
|
+
})().finally(() => { search = null; });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The background look, when one is running; settled otherwise. */
|
|
80
|
+
export const transcriptSearch = () => search ?? Promise.resolve();
|
package/main/updater.mjs
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { createRequire } from 'node:module';
|
|
50
50
|
import { Name } from '../shared/product-name.mjs';
|
|
51
|
+
import { MOVE_SAY } from '../shared/where-it-runs-from.mjs';
|
|
51
52
|
|
|
52
53
|
const require = createRequire(import.meta.url);
|
|
53
54
|
|
|
@@ -68,6 +69,14 @@ export function describe(state) {
|
|
|
68
69
|
const { phase, currentVersion, newVersion, percent, error, checkedAt, readyAt, source, changes, behind } = state;
|
|
69
70
|
const installing = phase === 'installing';
|
|
70
71
|
return {
|
|
72
|
+
// WHETHER THIS COPY CAN UPDATE ITSELF FROM WHERE IT IS, and the one press
|
|
73
|
+
// that fixes it when it cannot (main/where-it-runs-from.mjs). It rides the
|
|
74
|
+
// updater's own state because the three surfaces that would have to ask
|
|
75
|
+
// separately — the Settings row, the sidebar card and ⌘K — all already
|
|
76
|
+
// read this one object, and a second channel for it is a second answer.
|
|
77
|
+
canMove: !!state.canMove,
|
|
78
|
+
place: state.canMove ? (state.place ?? null) : null,
|
|
79
|
+
moveError: state.moveError ?? null,
|
|
71
80
|
phase,
|
|
72
81
|
currentVersion,
|
|
73
82
|
newVersion: newVersion ?? null,
|
|
@@ -96,7 +105,17 @@ export function describe(state) {
|
|
|
96
105
|
};
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
|
|
108
|
+
/**
|
|
109
|
+
* `autoUpdater` is a SEAM FOR THE TESTS and nothing else. The real one is
|
|
110
|
+
* required below; passing one in is how the failure paths get tested without a
|
|
111
|
+
* packaged build, a signed release and a network, which is the only reason the
|
|
112
|
+
* download bug (see the swallow in `check`) went seven reports unnoticed.
|
|
113
|
+
*/
|
|
114
|
+
export function createUpdater({ app, log = console, onChanged = () => {}, autoUpdater: given = null, place = null }) {
|
|
115
|
+
// READ ONCE, at construction (main/where-it-runs-from.mjs says why the
|
|
116
|
+
// bundle cannot move under a running process). A build with no place passed
|
|
117
|
+
// in — the tests, and `main/serve.mjs` — simply never offers the move.
|
|
118
|
+
const where = place?.state?.() ?? { place: null, canUpdate: true, offerMove: false };
|
|
100
119
|
let state = {
|
|
101
120
|
phase: 'idle',
|
|
102
121
|
currentVersion: app.getVersion(),
|
|
@@ -105,6 +124,9 @@ export function createUpdater({ app, log = console, onChanged = () => {} }) {
|
|
|
105
124
|
error: null,
|
|
106
125
|
checkedAt: null,
|
|
107
126
|
readyAt: null,
|
|
127
|
+
canMove: !!where.offerMove,
|
|
128
|
+
place: where.place ?? null,
|
|
129
|
+
moveError: null,
|
|
108
130
|
};
|
|
109
131
|
let timer = null;
|
|
110
132
|
let stopped = false;
|
|
@@ -117,12 +139,14 @@ export function createUpdater({ app, log = console, onChanged = () => {} }) {
|
|
|
117
139
|
// Loaded lazily and inside a try, so a build that somehow shipped without the
|
|
118
140
|
// dependency degrades to "no updates" instead of failing to boot at all. The
|
|
119
141
|
// app not starting is a strictly worse outcome than the app not updating.
|
|
120
|
-
let autoUpdater =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
let autoUpdater = given;
|
|
143
|
+
if (!autoUpdater) {
|
|
144
|
+
try {
|
|
145
|
+
({ autoUpdater } = require('electron-updater'));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
set({ phase: 'unsupported', error: 'This build has no updater in it.' });
|
|
148
|
+
log.warn?.('[updater] electron-updater is not available:', e?.message ?? e);
|
|
149
|
+
}
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
if (autoUpdater) {
|
|
@@ -184,7 +208,23 @@ export function createUpdater({ app, log = console, onChanged = () => {} }) {
|
|
|
184
208
|
if (state.phase === 'ready') return describe(state);
|
|
185
209
|
if (state.phase === 'checking' || (state.phase === 'downloading' && !manual)) return describe(state);
|
|
186
210
|
try {
|
|
187
|
-
await autoUpdater.checkForUpdates();
|
|
211
|
+
const found = await autoUpdater.checkForUpdates();
|
|
212
|
+
// THE DOWNLOAD IS A SECOND PROMISE AND NOBODY WAS HOLDING IT. With
|
|
213
|
+
// `autoDownload` on, electron-updater starts the download inside the
|
|
214
|
+
// check and hands it back here as `downloadPromise`; its own
|
|
215
|
+
// `downloadUpdate` dispatches the `error` event and then RE-THROWS. The
|
|
216
|
+
// await above resolves on the RESULT, not on the download, so a failed
|
|
217
|
+
// download had nowhere to land but `unhandledRejection` — and
|
|
218
|
+
// `main/crash-report.mjs` files one of those as a crash.
|
|
219
|
+
//
|
|
220
|
+
// That is 7 of the 14 crash reports of launch week (PostHog, 2026-10-08)
|
|
221
|
+
// and not one of them was a crash: five are Squirrel's read-only volume
|
|
222
|
+
// error from an app run out of the disk image, two are a dropped
|
|
223
|
+
// connection, and in every one of them the app carried on running. The
|
|
224
|
+
// failure is ALREADY on screen by the time we get here, because the
|
|
225
|
+
// `error` handler above set it, so there is nothing left to do with this
|
|
226
|
+
// rejection but stop it being mistaken for a crash.
|
|
227
|
+
found?.downloadPromise?.catch?.(() => {});
|
|
188
228
|
} catch (e) {
|
|
189
229
|
set({ phase: 'error', error: shortError(e), checkedAt: Date.now() });
|
|
190
230
|
}
|
|
@@ -201,6 +241,18 @@ export function createUpdater({ app, log = console, onChanged = () => {} }) {
|
|
|
201
241
|
return true;
|
|
202
242
|
}
|
|
203
243
|
|
|
244
|
+
// THE OTHER BUTTON, for a copy that cannot update itself from where it is.
|
|
245
|
+
// Electron moves the bundle and relaunches, so a `true` here is the last
|
|
246
|
+
// thing this process does. A failure goes into the state like every other
|
|
247
|
+
// failure, so the row and the sidebar card both say it and both offer
|
|
248
|
+
// another press.
|
|
249
|
+
function move() {
|
|
250
|
+
if (!state.canMove) return { moved: false, error: null };
|
|
251
|
+
const got = place?.move?.() ?? { moved: false, error: null };
|
|
252
|
+
if (!got.moved) set({ moveError: got.error ?? null });
|
|
253
|
+
return got;
|
|
254
|
+
}
|
|
255
|
+
|
|
204
256
|
function start() {
|
|
205
257
|
if (stopped || timer) return;
|
|
206
258
|
const tick = () => { void check(); };
|
|
@@ -215,7 +267,7 @@ export function createUpdater({ app, log = console, onChanged = () => {} }) {
|
|
|
215
267
|
timer = null;
|
|
216
268
|
}
|
|
217
269
|
|
|
218
|
-
return { start, stop, check, install, state: () => describe(state) };
|
|
270
|
+
return { start, stop, check, install, move, state: () => describe(state) };
|
|
219
271
|
}
|
|
220
272
|
|
|
221
273
|
// electron-updater's errors arrive as Error objects whose `message` is
|
|
@@ -234,6 +286,16 @@ export function shortError(e) {
|
|
|
234
286
|
// releases/download/v0.1.0/latest-mac.yml): HttpErr…" to her. The sentence
|
|
235
287
|
// for exactly that failure was already on the line below it.
|
|
236
288
|
const named = `${e?.code ?? ''} ${raw}`;
|
|
289
|
+
// SQUIRREL'S OWN PARAGRAPH, WHICH IS 240 CHARACTERS AND HAS A URL IN IT.
|
|
290
|
+
// "Cannot update while running on a read-only volume. The application is on
|
|
291
|
+
// a read-only volume. Please move the application and try again. If you'll
|
|
292
|
+
// need to move the application out of the Downloads directory. See
|
|
293
|
+
// https://github.com/Squirrel/Squirrel.Mac …" — the whole of what 23 of the
|
|
294
|
+
// 26 main-rejection reports carried (PostHog, 2026-10-12), and the Settings
|
|
295
|
+
// row showed the first 160 characters of it. The app knows where it is
|
|
296
|
+
// running from (shared/where-it-runs-from.mjs), so this is one sentence and
|
|
297
|
+
// the row beside it is the press that fixes it.
|
|
298
|
+
if (/read-only volume/i.test(raw)) return MOVE_SAY.cannotUpdateHere;
|
|
237
299
|
if (/ERR_UPDATER_ZIP_FILE_NOT_FOUND/.test(named)) return 'That release has no app in it to install.';
|
|
238
300
|
if (/ERR_UPDATER_CHANNEL_FILE_NOT_FOUND/.test(named)) return 'That release is missing its update file.';
|
|
239
301
|
if (/ERR_UPDATER_NO_PUBLISHED_VERSIONS|LATEST_VERSION_NOT_FOUND/.test(named)) return 'No published version to update to yet.';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// What an embedded page may do, decided here rather than on the tag that
|
|
2
|
+
// asked for it. The preload is the one that matters: `preload.cjs` is the
|
|
3
|
+
// bridge the app's own page talks to the store through, so a page off the web
|
|
4
|
+
// holding it holds everything the person holds.
|
|
5
|
+
//
|
|
6
|
+
// The partition is deliberately left alone. It says WHERE a page's sign-ins
|
|
7
|
+
// go rather than what the page may reach, and flattening it would merge the
|
|
8
|
+
// website pane's store into the default one (shared/websites.mjs).
|
|
9
|
+
//
|
|
10
|
+
// Pinned by tests/a-website-in-the-pane-cannot-reach-the-app.test.mjs.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pin one attaching webview to the floor. `webPreferences` and `params` are
|
|
14
|
+
* Electron's own objects from `will-attach-webview`, mutated in place, which
|
|
15
|
+
* is how that event is answered.
|
|
16
|
+
*/
|
|
17
|
+
export function hardenAttachedWebview(webPreferences, params) {
|
|
18
|
+
if (webPreferences) {
|
|
19
|
+
// Nothing of ours reaches the page.
|
|
20
|
+
delete webPreferences.preload;
|
|
21
|
+
delete webPreferences.preloadURL;
|
|
22
|
+
webPreferences.nodeIntegration = false;
|
|
23
|
+
webPreferences.nodeIntegrationInSubFrames = false;
|
|
24
|
+
webPreferences.nodeIntegrationInWorker = false;
|
|
25
|
+
webPreferences.contextIsolation = true;
|
|
26
|
+
webPreferences.sandbox = true;
|
|
27
|
+
webPreferences.webSecurity = true;
|
|
28
|
+
webPreferences.allowRunningInsecureContent = false;
|
|
29
|
+
webPreferences.experimentalFeatures = false;
|
|
30
|
+
webPreferences.enableBlinkFeatures = '';
|
|
31
|
+
|
|
32
|
+
// No embedded page starts sound before a click in it. Electron's default
|
|
33
|
+
// lets any page start media the moment it loads.
|
|
34
|
+
webPreferences.autoplayPolicy = 'document-user-activation-required';
|
|
35
|
+
}
|
|
36
|
+
if (params) {
|
|
37
|
+
// A window the guest opens for itself would be a window with none of the
|
|
38
|
+
// above. Links that want one go out to the real browser instead
|
|
39
|
+
// (setWindowOpenHandler in main/main.mjs).
|
|
40
|
+
params.allowpopups = false;
|
|
41
|
+
params.nodeintegration = false;
|
|
42
|
+
params.nodeintegrationinsubframes = false;
|
|
43
|
+
}
|
|
44
|
+
return webPreferences;
|
|
45
|
+
}
|