@juspay/shooter 1.0.0
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/.claude/hooks/notifier.cjs +1431 -0
- package/.claude/settings.json +162 -0
- package/README.md +515 -0
- package/bin/shooter.cjs +141 -0
- package/build/client/_app/immutable/assets/0.CM9Hl6d-.css +1 -0
- package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.br +0 -0
- package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.gz +0 -0
- package/build/client/_app/immutable/assets/2.CAShZ7lQ.css +1 -0
- package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.br +1 -0
- package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.gz +0 -0
- package/build/client/_app/immutable/assets/3.C0uFg0IS.css +1 -0
- package/build/client/_app/immutable/assets/3.C0uFg0IS.css.br +0 -0
- package/build/client/_app/immutable/assets/3.C0uFg0IS.css.gz +0 -0
- package/build/client/_app/immutable/assets/4.cJuCkJKZ.css +1 -0
- package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.br +0 -0
- package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.gz +0 -0
- package/build/client/_app/immutable/assets/5.DRjApZQW.css +1 -0
- package/build/client/_app/immutable/assets/5.DRjApZQW.css.br +0 -0
- package/build/client/_app/immutable/assets/5.DRjApZQW.css.gz +0 -0
- package/build/client/_app/immutable/assets/6.AraZrY8I.css +1 -0
- package/build/client/_app/immutable/assets/6.AraZrY8I.css.br +0 -0
- package/build/client/_app/immutable/assets/6.AraZrY8I.css.gz +0 -0
- package/build/client/_app/immutable/assets/7.BCJ1IuMx.css +1 -0
- package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.br +0 -0
- package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.gz +0 -0
- package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css +1 -0
- package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.br +0 -0
- package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.gz +0 -0
- package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css +1 -0
- package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.br +0 -0
- package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.gz +0 -0
- package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css +1 -0
- package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.br +0 -0
- package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BNJphC1q.js +56 -0
- package/build/client/_app/immutable/chunks/BNJphC1q.js.br +0 -0
- package/build/client/_app/immutable/chunks/BNJphC1q.js.gz +0 -0
- package/build/client/_app/immutable/chunks/BTGVxaYV.js +9 -0
- package/build/client/_app/immutable/chunks/BTGVxaYV.js.br +0 -0
- package/build/client/_app/immutable/chunks/BTGVxaYV.js.gz +0 -0
- package/build/client/_app/immutable/chunks/BlxrFPDK.js +1 -0
- package/build/client/_app/immutable/chunks/BlxrFPDK.js.br +0 -0
- package/build/client/_app/immutable/chunks/BlxrFPDK.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Bvk7mfPM.js +1 -0
- package/build/client/_app/immutable/chunks/Bvk7mfPM.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bvk7mfPM.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CAokzuPQ.js +1 -0
- package/build/client/_app/immutable/chunks/CAokzuPQ.js.br +0 -0
- package/build/client/_app/immutable/chunks/CAokzuPQ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CGLrx-H5.js +1 -0
- package/build/client/_app/immutable/chunks/CGLrx-H5.js.br +0 -0
- package/build/client/_app/immutable/chunks/CGLrx-H5.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CgCpWzEA.js +1 -0
- package/build/client/_app/immutable/chunks/CgCpWzEA.js.br +0 -0
- package/build/client/_app/immutable/chunks/CgCpWzEA.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Cjwk_cGO.js +6 -0
- package/build/client/_app/immutable/chunks/Cjwk_cGO.js.br +0 -0
- package/build/client/_app/immutable/chunks/Cjwk_cGO.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CtQ8EED1.js +11 -0
- package/build/client/_app/immutable/chunks/CtQ8EED1.js.br +0 -0
- package/build/client/_app/immutable/chunks/CtQ8EED1.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DERQCisl.js +1 -0
- package/build/client/_app/immutable/chunks/DERQCisl.js.br +0 -0
- package/build/client/_app/immutable/chunks/DERQCisl.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DKrg8TQs.js +1 -0
- package/build/client/_app/immutable/chunks/DKrg8TQs.js.br +0 -0
- package/build/client/_app/immutable/chunks/DKrg8TQs.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DLu6yJIZ.js +1 -0
- package/build/client/_app/immutable/chunks/DLu6yJIZ.js.br +0 -0
- package/build/client/_app/immutable/chunks/DLu6yJIZ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Dkkpz_4D.js +126 -0
- package/build/client/_app/immutable/chunks/Dkkpz_4D.js.br +0 -0
- package/build/client/_app/immutable/chunks/Dkkpz_4D.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DoczjQhA.js +1 -0
- package/build/client/_app/immutable/chunks/DoczjQhA.js.br +0 -0
- package/build/client/_app/immutable/chunks/DoczjQhA.js.gz +0 -0
- package/build/client/_app/immutable/chunks/PPVm8Dsz.js +1 -0
- package/build/client/_app/immutable/chunks/PPVm8Dsz.js.br +0 -0
- package/build/client/_app/immutable/chunks/PPVm8Dsz.js.gz +0 -0
- package/build/client/_app/immutable/chunks/RpcNruLP.js +2 -0
- package/build/client/_app/immutable/chunks/RpcNruLP.js.br +0 -0
- package/build/client/_app/immutable/chunks/RpcNruLP.js.gz +0 -0
- package/build/client/_app/immutable/chunks/a-St0Zwo.js +1 -0
- package/build/client/_app/immutable/chunks/a-St0Zwo.js.br +0 -0
- package/build/client/_app/immutable/chunks/a-St0Zwo.js.gz +0 -0
- package/build/client/_app/immutable/chunks/bo70OQUZ.js +1 -0
- package/build/client/_app/immutable/chunks/bo70OQUZ.js.br +0 -0
- package/build/client/_app/immutable/chunks/bo70OQUZ.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.QvGgdvTI.js +2 -0
- package/build/client/_app/immutable/entry/app.QvGgdvTI.js.br +0 -0
- package/build/client/_app/immutable/entry/app.QvGgdvTI.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BntDNRMC.js +1 -0
- package/build/client/_app/immutable/entry/start.BntDNRMC.js.br +0 -0
- package/build/client/_app/immutable/entry/start.BntDNRMC.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js +1 -0
- package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.MG1QhfrI.js +1 -0
- package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.B4MlOSh6.js +1 -0
- package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.DIwYkjDn.js +3 -0
- package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.gz +0 -0
- package/build/client/_app/immutable/nodes/4.D-cIe70D.js +1 -0
- package/build/client/_app/immutable/nodes/4.D-cIe70D.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.D-cIe70D.js.gz +0 -0
- package/build/client/_app/immutable/nodes/5.D7zPRe3L.js +1 -0
- package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.br +0 -0
- package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.BB7QE48r.js +2 -0
- package/build/client/_app/immutable/nodes/6.BB7QE48r.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.BB7QE48r.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.D8mqsrZG.js +2 -0
- package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.gz +0 -0
- package/build/client/_app/version.json +1 -0
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/client/app-icon.png +0 -0
- package/build/client/apple-touch-icon.png +0 -0
- package/build/client/favicon.png +0 -0
- package/build/client/favicon.svg +10 -0
- package/build/client/favicon.svg.br +0 -0
- package/build/client/favicon.svg.gz +0 -0
- package/build/client/manifest.webmanifest +1 -0
- package/build/client/pwa-192x192.png +0 -0
- package/build/client/pwa-512x512.png +0 -0
- package/build/client/registerSW.js +1 -0
- package/build/client/registerSW.js.br +0 -0
- package/build/client/registerSW.js.gz +0 -0
- package/build/client/sw.js +222 -0
- package/build/client/sw.js.br +0 -0
- package/build/client/sw.js.gz +0 -0
- package/build/client/workbox-5119daf5.js +3395 -0
- package/build/client/workbox-5119daf5.js.br +0 -0
- package/build/client/workbox-5119daf5.js.gz +0 -0
- package/build/env.js +94 -0
- package/build/handler.js +1494 -0
- package/build/index.js +345 -0
- package/build/pty-holder.cjs +510 -0
- package/build/server/chunks/0-q2IUp76Y.js +9 -0
- package/build/server/chunks/0-q2IUp76Y.js.map +1 -0
- package/build/server/chunks/1-CU50G5wZ.js +9 -0
- package/build/server/chunks/1-CU50G5wZ.js.map +1 -0
- package/build/server/chunks/2-D01t9s8T.js +9 -0
- package/build/server/chunks/2-D01t9s8T.js.map +1 -0
- package/build/server/chunks/3-5PUQ04wC.js +9 -0
- package/build/server/chunks/3-5PUQ04wC.js.map +1 -0
- package/build/server/chunks/4-e7gywnSG.js +9 -0
- package/build/server/chunks/4-e7gywnSG.js.map +1 -0
- package/build/server/chunks/5-CA1SA6KZ.js +9 -0
- package/build/server/chunks/5-CA1SA6KZ.js.map +1 -0
- package/build/server/chunks/6-71H221sV.js +9 -0
- package/build/server/chunks/6-71H221sV.js.map +1 -0
- package/build/server/chunks/7-Bo-vmdyz.js +9 -0
- package/build/server/chunks/7-Bo-vmdyz.js.map +1 -0
- package/build/server/chunks/_layout.svelte-SFHOxs74.js +132 -0
- package/build/server/chunks/_layout.svelte-SFHOxs74.js.map +1 -0
- package/build/server/chunks/_page.svelte-B4w-2wD-.js +120 -0
- package/build/server/chunks/_page.svelte-B4w-2wD-.js.map +1 -0
- package/build/server/chunks/_page.svelte-B_qAXjkh.js +213 -0
- package/build/server/chunks/_page.svelte-B_qAXjkh.js.map +1 -0
- package/build/server/chunks/_page.svelte-CsF1_TRG.js +50 -0
- package/build/server/chunks/_page.svelte-CsF1_TRG.js.map +1 -0
- package/build/server/chunks/_page.svelte-DJC6U-P0.js +68 -0
- package/build/server/chunks/_page.svelte-DJC6U-P0.js.map +1 -0
- package/build/server/chunks/_page.svelte-DQ6HBtsz.js +407 -0
- package/build/server/chunks/_page.svelte-DQ6HBtsz.js.map +1 -0
- package/build/server/chunks/_page.svelte-LbhhjP21.js +148 -0
- package/build/server/chunks/_page.svelte-LbhhjP21.js.map +1 -0
- package/build/server/chunks/_server.ts-BL2FGb5Z.js +387 -0
- package/build/server/chunks/_server.ts-BL2FGb5Z.js.map +1 -0
- package/build/server/chunks/_server.ts-BgdjBZco.js +47 -0
- package/build/server/chunks/_server.ts-BgdjBZco.js.map +1 -0
- package/build/server/chunks/_server.ts-BihKSdj_.js +59 -0
- package/build/server/chunks/_server.ts-BihKSdj_.js.map +1 -0
- package/build/server/chunks/_server.ts-BjOJsoy4.js +63 -0
- package/build/server/chunks/_server.ts-BjOJsoy4.js.map +1 -0
- package/build/server/chunks/_server.ts-C29xzfaw.js +77 -0
- package/build/server/chunks/_server.ts-C29xzfaw.js.map +1 -0
- package/build/server/chunks/_server.ts-CPa6DgIt.js +71 -0
- package/build/server/chunks/_server.ts-CPa6DgIt.js.map +1 -0
- package/build/server/chunks/_server.ts-CbDRDIoP.js +36 -0
- package/build/server/chunks/_server.ts-CbDRDIoP.js.map +1 -0
- package/build/server/chunks/_server.ts-Cl1OEWL4.js +54 -0
- package/build/server/chunks/_server.ts-Cl1OEWL4.js.map +1 -0
- package/build/server/chunks/_server.ts-ColfDHW8.js +60 -0
- package/build/server/chunks/_server.ts-ColfDHW8.js.map +1 -0
- package/build/server/chunks/_server.ts-Cv_OrRuL.js +494 -0
- package/build/server/chunks/_server.ts-Cv_OrRuL.js.map +1 -0
- package/build/server/chunks/_server.ts-D4MNi4cD.js +25 -0
- package/build/server/chunks/_server.ts-D4MNi4cD.js.map +1 -0
- package/build/server/chunks/_server.ts-DRVbgm6k.js +125 -0
- package/build/server/chunks/_server.ts-DRVbgm6k.js.map +1 -0
- package/build/server/chunks/_server.ts-DfajWaqh.js +39 -0
- package/build/server/chunks/_server.ts-DfajWaqh.js.map +1 -0
- package/build/server/chunks/_server.ts-y9-WYDMa.js +35 -0
- package/build/server/chunks/_server.ts-y9-WYDMa.js.map +1 -0
- package/build/server/chunks/auth-CEgFis71.js +32 -0
- package/build/server/chunks/auth-CEgFis71.js.map +1 -0
- package/build/server/chunks/client-CxCatAKr.js +255 -0
- package/build/server/chunks/client-CxCatAKr.js.map +1 -0
- package/build/server/chunks/error.svelte-BqdwMWdK.js +26 -0
- package/build/server/chunks/error.svelte-BqdwMWdK.js.map +1 -0
- package/build/server/chunks/exports-CJ0Q5XmL.js +4081 -0
- package/build/server/chunks/exports-CJ0Q5XmL.js.map +1 -0
- package/build/server/chunks/index2-DAxIoAO-.js +36 -0
- package/build/server/chunks/index2-DAxIoAO-.js.map +1 -0
- package/build/server/chunks/jsonl-parser-dmZU_Hyu.js +137 -0
- package/build/server/chunks/jsonl-parser-dmZU_Hyu.js.map +1 -0
- package/build/server/chunks/library-apns-BHxLmuIx.js +104 -0
- package/build/server/chunks/library-apns-BHxLmuIx.js.map +1 -0
- package/build/server/chunks/markdown-Bxrl3cCF.js +1241 -0
- package/build/server/chunks/markdown-Bxrl3cCF.js.map +1 -0
- package/build/server/chunks/pending-requests-D8UiTw7L.js +44 -0
- package/build/server/chunks/pending-requests-D8UiTw7L.js.map +1 -0
- package/build/server/chunks/pty-manager-C0FhBiVq.js +1697 -0
- package/build/server/chunks/pty-manager-C0FhBiVq.js.map +1 -0
- package/build/server/chunks/shared-server-BDY8jh20.js +200 -0
- package/build/server/chunks/shared-server-BDY8jh20.js.map +1 -0
- package/build/server/chunks/stores-D0HorpgL.js +36 -0
- package/build/server/chunks/stores-D0HorpgL.js.map +1 -0
- package/build/server/index.js +6466 -0
- package/build/server/index.js.map +1 -0
- package/build/server/manifest.js +184 -0
- package/build/server/manifest.js.map +1 -0
- package/build/shims.js +32 -0
- package/package.json +94 -0
- package/scripts/clipboard-shims/wl-paste +48 -0
- package/scripts/clipboard-shims/xclip +31 -0
- package/scripts/install.sh +477 -0
- package/scripts/setup-node-pty.sh +63 -0
- package/scripts/setup.cjs +571 -0
- package/scripts/test-runner.ts +243 -0
- package/scripts/vercel-env-commands.sh +60 -0
- package/server.ts +139 -0
- package/src/app.css +1835 -0
- package/src/app.d.ts +31 -0
- package/src/app.html +24 -0
- package/src/generated/types/APN.ts +305 -0
- package/src/generated/types/CLI.ts +52 -0
- package/src/generated/types/JWT.ts +92 -0
- package/src/generated/types/Terminal.ts +2736 -0
- package/src/generated/types/index.ts +6 -0
- package/src/lib/assets/icons/alert-triangle.svg +5 -0
- package/src/lib/assets/icons/bell.svg +4 -0
- package/src/lib/assets/icons/check-circle.svg +4 -0
- package/src/lib/assets/icons/file.svg +4 -0
- package/src/lib/assets/icons/folder.svg +3 -0
- package/src/lib/assets/icons/play.svg +3 -0
- package/src/lib/assets/icons/refresh.svg +4 -0
- package/src/lib/assets/icons/settings.svg +4 -0
- package/src/lib/assets/icons/terminal.svg +1 -0
- package/src/lib/assets/icons/tool.svg +3 -0
- package/src/lib/assets/icons/x-circle.svg +5 -0
- package/src/lib/modules/client/common/Card.svelte +26 -0
- package/src/lib/modules/client/common/EmptyState.svelte +36 -0
- package/src/lib/modules/client/common/Icon.svelte +61 -0
- package/src/lib/modules/client/common/StatusBadge.svelte +38 -0
- package/src/lib/modules/client/common/cache.ts +31 -0
- package/src/lib/modules/client/common/config-guard.ts +18 -0
- package/src/lib/modules/client/common/index.ts +12 -0
- package/src/lib/modules/client/common/markdown.ts +23 -0
- package/src/lib/modules/client/common/native-bridge.ts +50 -0
- package/src/lib/modules/client/common/time.ts +22 -0
- package/src/lib/modules/client/common/tool-title.ts +28 -0
- package/src/lib/modules/client/terminal/ChatView.svelte +400 -0
- package/src/lib/modules/client/terminal/CommandPalette.svelte +60 -0
- package/src/lib/modules/client/terminal/ConnectionStatus.svelte +99 -0
- package/src/lib/modules/client/terminal/LaunchSheet.svelte +294 -0
- package/src/lib/modules/client/terminal/QuickKeys.svelte +71 -0
- package/src/lib/modules/client/terminal/ShortcutsHelp.svelte +79 -0
- package/src/lib/modules/client/terminal/keyboard-shortcuts.ts +70 -0
- package/src/lib/modules/client/terminal/xterm-wrapper.ts +243 -0
- package/src/lib/modules/server/apn/library-apns.ts +137 -0
- package/src/lib/modules/server/apn/notification-history.ts +35 -0
- package/src/lib/modules/server/apn/notification-sessions.ts +117 -0
- package/src/lib/modules/server/apn/pending-requests.ts +65 -0
- package/src/lib/modules/server/apn/types.ts +51 -0
- package/src/lib/modules/server/auth.ts +34 -0
- package/src/lib/modules/server/cli/index.ts +79 -0
- package/src/lib/modules/server/cli/runner.ts +162 -0
- package/src/lib/modules/server/fcm/fcm-service.ts +72 -0
- package/src/lib/modules/server/sessions/jsonl-parser.ts +197 -0
- package/src/lib/modules/server/sessions/jsonl-reader.ts +301 -0
- package/src/lib/modules/server/sessions/opencode-reader.ts +264 -0
- package/src/lib/modules/server/sessions/types.ts +53 -0
- package/src/lib/modules/server/terminal/holder-client.ts +273 -0
- package/src/lib/modules/server/terminal/opencode-watcher.ts +661 -0
- package/src/lib/modules/server/terminal/pty-holder.cjs +510 -0
- package/src/lib/modules/server/terminal/pty-manager.ts +1012 -0
- package/src/lib/modules/server/terminal/session-watcher.ts +320 -0
- package/src/lib/modules/server/terminal/terminal-store.ts +198 -0
- package/src/lib/modules/server/ws/events-handler.ts +73 -0
- package/src/lib/modules/server/ws/keepalive.ts +108 -0
- package/src/lib/modules/server/ws/server.ts +93 -0
- package/src/lib/modules/server/ws/session-handler.ts +462 -0
- package/src/lib/modules/server/ws/terminal-handler.ts +197 -0
- package/src/lib/modules/server/ws/ticket-store.ts +58 -0
- package/src/lib/theme.css +529 -0
- package/src/lib/types/config.ts +6 -0
- package/src/routes/+layout.svelte +218 -0
- package/src/routes/+page.svelte +261 -0
- package/src/routes/api/debug/+server.ts +33 -0
- package/src/routes/api/device-token/+server.ts +85 -0
- package/src/routes/api/health/+server.ts +100 -0
- package/src/routes/api/notify/+server.ts +418 -0
- package/src/routes/api/qr-config/+server.ts +45 -0
- package/src/routes/api/response/+server.ts +73 -0
- package/src/routes/api/sessions/+server.ts +120 -0
- package/src/routes/api/terminals/+server.ts +141 -0
- package/src/routes/api/terminals/[id]/+server.ts +75 -0
- package/src/routes/api/terminals/[id]/paste-image/+server.ts +61 -0
- package/src/routes/api/terminals/[id]/resize/+server.ts +60 -0
- package/src/routes/api/webhook/+server.ts +42 -0
- package/src/routes/api/ws-status/+server.ts +23 -0
- package/src/routes/api/ws-ticket/+server.ts +86 -0
- package/src/routes/config/+page.svelte +600 -0
- package/src/routes/project/+page.svelte +274 -0
- package/src/routes/session/[id]/+page.svelte +434 -0
- package/src/routes/terminals/+page.svelte +618 -0
- package/src/routes/terminals/[id]/+page.svelte +968 -0
- package/svelte.config.js +18 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
import type WebSocket from 'ws';
|
|
2
|
+
|
|
3
|
+
import { type ChildProcess, fork } from 'child_process';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
import { HolderClient } from './holder-client';
|
|
10
|
+
import { openCodeWatcher } from './opencode-watcher';
|
|
11
|
+
import { sessionWatcher } from './session-watcher';
|
|
12
|
+
import { terminalStore } from './terminal-store';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
interface ManagedTerminal {
|
|
19
|
+
args: string[];
|
|
20
|
+
clients: Set<WebSocket>;
|
|
21
|
+
cols: number;
|
|
22
|
+
command: string;
|
|
23
|
+
createdAt: Date;
|
|
24
|
+
currentCwd: null | string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
exitCode: null | number;
|
|
27
|
+
exitedAt: Date | null;
|
|
28
|
+
holderPid: number;
|
|
29
|
+
id: string;
|
|
30
|
+
isActive: boolean;
|
|
31
|
+
openCodeNoopCb: ((messages: import('../sessions/types').ConversationMessage[]) => void) | null;
|
|
32
|
+
openCodeSessionId: null | string;
|
|
33
|
+
outputBuffers: Map<WebSocket, OutputBuffer>;
|
|
34
|
+
pid: number;
|
|
35
|
+
pollTimer: null | ReturnType<typeof setInterval>;
|
|
36
|
+
pty: HolderClient;
|
|
37
|
+
rows: number;
|
|
38
|
+
scrollback: string;
|
|
39
|
+
sessionFile: null | string;
|
|
40
|
+
socketPath: string;
|
|
41
|
+
status: 'exited' | 'running';
|
|
42
|
+
watcherOffset: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OutputBuffer {
|
|
46
|
+
data: string[];
|
|
47
|
+
size: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type { ManagedTerminal };
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Constants
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512 KB cached scrollback cap
|
|
57
|
+
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024; // 1 MB per client
|
|
58
|
+
const SCROLLBACK_CHUNK_SIZE = 50 * 1024; // 50 KB per chunk
|
|
59
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
60
|
+
const EXITED_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
61
|
+
const MAX_EXITED_TERMINALS = 10;
|
|
62
|
+
const SIGKILL_DELAY_MS = 5000;
|
|
63
|
+
const HOLDER_READY_TIMEOUT_MS = 5000;
|
|
64
|
+
const DB_CLEANUP_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours for SQLite records
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Resolve holder script path (ESM — no __dirname)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
71
|
+
const __dirname = path.dirname(__filename);
|
|
72
|
+
|
|
73
|
+
class PtyManager {
|
|
74
|
+
private cleanupTimer: null | ReturnType<typeof setInterval> = null;
|
|
75
|
+
private terminals = new Map<string, ManagedTerminal>();
|
|
76
|
+
|
|
77
|
+
constructor() {
|
|
78
|
+
this.cleanupTimer = setInterval(() => { this.cleanup(); }, CLEANUP_INTERVAL_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------------
|
|
82
|
+
// create — now async: forks a holder process, connects via HolderClient,
|
|
83
|
+
// persists to SQLite
|
|
84
|
+
// -----------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
attach(id: string, ws: WebSocket): boolean {
|
|
87
|
+
const terminal = this.terminals.get(id);
|
|
88
|
+
if (!terminal) {return false;}
|
|
89
|
+
|
|
90
|
+
terminal.clients.add(ws);
|
|
91
|
+
terminal.outputBuffers.set(ws, { data: [], size: 0 });
|
|
92
|
+
|
|
93
|
+
// Send cached scrollback in chunks
|
|
94
|
+
this.sendScrollback(terminal, ws);
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
// reconnectAll — recover persisted terminals on server startup
|
|
101
|
+
// -----------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
cleanup(): void {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const exited: { exitedAt: number; id: string; }[] = [];
|
|
106
|
+
|
|
107
|
+
for (const [id, terminal] of this.terminals) {
|
|
108
|
+
if (terminal.status !== 'exited') {continue;}
|
|
109
|
+
|
|
110
|
+
const exitTime = terminal.exitedAt?.getTime() ?? terminal.createdAt.getTime();
|
|
111
|
+
|
|
112
|
+
// Evict if older than TTL
|
|
113
|
+
if (now - exitTime > EXITED_TTL_MS) {
|
|
114
|
+
this.evict(id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
exited.push({ exitedAt: exitTime, id });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// If more than MAX_EXITED_TERMINALS remain, evict the oldest
|
|
122
|
+
if (exited.length > MAX_EXITED_TERMINALS) {
|
|
123
|
+
exited.sort((a, b) => a.exitedAt - b.exitedAt);
|
|
124
|
+
const toEvict = exited.slice(0, exited.length - MAX_EXITED_TERMINALS);
|
|
125
|
+
for (const { id } of toEvict) {
|
|
126
|
+
this.evict(id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Clean up old SQLite records (exited/orphaned older than 24 hours)
|
|
131
|
+
try {
|
|
132
|
+
const deleted = terminalStore.deleteOlderThan(DB_CLEANUP_TTL_MS);
|
|
133
|
+
if (deleted > 0) {
|
|
134
|
+
console.log(`[pty-manager] Cleaned up ${deleted} old terminal record(s) from SQLite`);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Best effort — don't crash the cleanup cycle
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
// disconnectAll — graceful shutdown: disconnect clients, keep holders alive
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async create(
|
|
146
|
+
command: string,
|
|
147
|
+
args: string[],
|
|
148
|
+
cwd: string,
|
|
149
|
+
cols: number,
|
|
150
|
+
rows: number
|
|
151
|
+
): Promise<ManagedTerminal> {
|
|
152
|
+
const id = randomBytes(4).toString('hex'); // 8 hex chars
|
|
153
|
+
const socketPath = `/tmp/shooter-term-${id}.sock`;
|
|
154
|
+
const holderScript = resolveHolderPath();
|
|
155
|
+
|
|
156
|
+
// Fork the holder process as detached so it survives server restarts
|
|
157
|
+
const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
|
|
158
|
+
const holder: ChildProcess = fork(holderScript, holderArgs, {
|
|
159
|
+
detached: true,
|
|
160
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
161
|
+
});
|
|
162
|
+
holder.unref();
|
|
163
|
+
|
|
164
|
+
// Wait for the holder to signal ready (socket listening)
|
|
165
|
+
await new Promise<void>((resolve, reject) => {
|
|
166
|
+
const timeout = setTimeout(() => {
|
|
167
|
+
holder.kill();
|
|
168
|
+
reject(new Error('Holder ready timeout'));
|
|
169
|
+
}, HOLDER_READY_TIMEOUT_MS);
|
|
170
|
+
|
|
171
|
+
holder.on('message', (msg: { type: string }) => {
|
|
172
|
+
if (msg.type === 'ready') {
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
holder.disconnect(); // Release IPC — holder is now fully detached
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
holder.on('error', (err) => {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
reject(new Error(`Holder process error: ${err.message}`));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
holder.on('exit', (code) => {
|
|
185
|
+
clearTimeout(timeout);
|
|
186
|
+
reject(new Error(`Holder process exited with code ${code} before ready`));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const holderPid = holder.pid!;
|
|
191
|
+
|
|
192
|
+
// Connect to the holder via Unix socket
|
|
193
|
+
const client = new HolderClient();
|
|
194
|
+
const connectResult = await client.connect(socketPath);
|
|
195
|
+
|
|
196
|
+
const now = new Date();
|
|
197
|
+
const terminal: ManagedTerminal = {
|
|
198
|
+
args,
|
|
199
|
+
clients: new Set(),
|
|
200
|
+
cols,
|
|
201
|
+
command,
|
|
202
|
+
createdAt: now,
|
|
203
|
+
cwd,
|
|
204
|
+
currentCwd: null,
|
|
205
|
+
exitCode: connectResult.exitCode,
|
|
206
|
+
exitedAt: null,
|
|
207
|
+
holderPid,
|
|
208
|
+
id,
|
|
209
|
+
isActive: false,
|
|
210
|
+
openCodeNoopCb: null,
|
|
211
|
+
openCodeSessionId: null,
|
|
212
|
+
outputBuffers: new Map(),
|
|
213
|
+
pid: connectResult.pid,
|
|
214
|
+
pollTimer: null,
|
|
215
|
+
pty: client,
|
|
216
|
+
rows,
|
|
217
|
+
scrollback: connectResult.scrollback,
|
|
218
|
+
sessionFile: null,
|
|
219
|
+
socketPath,
|
|
220
|
+
status: connectResult.exited ? 'exited' : 'running',
|
|
221
|
+
watcherOffset: 0,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Wire up all HolderClient callbacks
|
|
225
|
+
this.wireHolderCallbacks(client, terminal);
|
|
226
|
+
|
|
227
|
+
// Persist to SQLite
|
|
228
|
+
terminalStore.insert({
|
|
229
|
+
args: JSON.stringify(args),
|
|
230
|
+
cols,
|
|
231
|
+
command,
|
|
232
|
+
createdAt: now.toISOString(),
|
|
233
|
+
cwd,
|
|
234
|
+
exitCode: null,
|
|
235
|
+
exitedAt: null,
|
|
236
|
+
holderPid,
|
|
237
|
+
id,
|
|
238
|
+
opencodeSessionId: null,
|
|
239
|
+
pid: connectResult.pid,
|
|
240
|
+
rows,
|
|
241
|
+
sessionFile: null,
|
|
242
|
+
socketPath,
|
|
243
|
+
status: 'running',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Start session file discovery (same polling logic as before)
|
|
247
|
+
this.startSessionDiscovery(terminal);
|
|
248
|
+
|
|
249
|
+
this.terminals.set(id, terminal);
|
|
250
|
+
return terminal;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -----------------------------------------------------------------------
|
|
254
|
+
// get
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
destroy(): void {
|
|
258
|
+
if (this.cleanupTimer) {
|
|
259
|
+
clearInterval(this.cleanupTimer);
|
|
260
|
+
this.cleanupTimer = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const [id, terminal] of this.terminals) {
|
|
264
|
+
// Clear session-file poll timer if still running
|
|
265
|
+
if (terminal.pollTimer) {
|
|
266
|
+
clearInterval(terminal.pollTimer);
|
|
267
|
+
terminal.pollTimer = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (terminal.status === 'running') {
|
|
271
|
+
try {
|
|
272
|
+
terminal.pty.kill('SIGTERM');
|
|
273
|
+
} catch {
|
|
274
|
+
// Best effort
|
|
275
|
+
}
|
|
276
|
+
// Also kill the holder process directly
|
|
277
|
+
try {
|
|
278
|
+
process.kill(terminal.holderPid, 'SIGKILL');
|
|
279
|
+
} catch {
|
|
280
|
+
// Best effort — holder may already be gone
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Disconnect from holder socket
|
|
285
|
+
terminal.pty.disconnect();
|
|
286
|
+
|
|
287
|
+
// Close all client connections
|
|
288
|
+
for (const ws of terminal.clients) {
|
|
289
|
+
try {
|
|
290
|
+
ws.close();
|
|
291
|
+
} catch {
|
|
292
|
+
// Best effort
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.terminals.delete(id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -----------------------------------------------------------------------
|
|
300
|
+
// list — running first, then recently exited, each group sorted by
|
|
301
|
+
// createdAt descending
|
|
302
|
+
// -----------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
detach(id: string, ws: WebSocket): boolean {
|
|
305
|
+
const terminal = this.terminals.get(id);
|
|
306
|
+
if (!terminal) {return false;}
|
|
307
|
+
|
|
308
|
+
terminal.clients.delete(ws);
|
|
309
|
+
terminal.outputBuffers.delete(ws);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// -----------------------------------------------------------------------
|
|
314
|
+
// kill — route through holder: SIGTERM, then SIGKILL after 5 s
|
|
315
|
+
// -----------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
disconnectAll(): void {
|
|
318
|
+
if (this.cleanupTimer) {
|
|
319
|
+
clearInterval(this.cleanupTimer);
|
|
320
|
+
this.cleanupTimer = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [, terminal] of this.terminals) {
|
|
324
|
+
// Clear session-file poll timer
|
|
325
|
+
if (terminal.pollTimer) {
|
|
326
|
+
clearInterval(terminal.pollTimer);
|
|
327
|
+
terminal.pollTimer = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Disconnect from holder (does NOT kill the holder process)
|
|
331
|
+
terminal.pty.disconnect();
|
|
332
|
+
|
|
333
|
+
// Close all WS client connections
|
|
334
|
+
for (const ws of terminal.clients) {
|
|
335
|
+
try {
|
|
336
|
+
ws.close();
|
|
337
|
+
} catch {
|
|
338
|
+
// Best effort
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
terminal.clients.clear();
|
|
342
|
+
terminal.outputBuffers.clear();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.terminals.clear();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// -----------------------------------------------------------------------
|
|
349
|
+
// remove — remove an exited terminal from the map
|
|
350
|
+
// -----------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
get(id: string): ManagedTerminal | null {
|
|
353
|
+
return this.terminals.get(id) ?? null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// -----------------------------------------------------------------------
|
|
357
|
+
// resize
|
|
358
|
+
// -----------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
getScrollback(id: string): null | string {
|
|
361
|
+
const terminal = this.terminals.get(id);
|
|
362
|
+
if (!terminal) {return null;}
|
|
363
|
+
|
|
364
|
+
return terminal.scrollback;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// -----------------------------------------------------------------------
|
|
368
|
+
// attach — register a WebSocket client and replay scrollback
|
|
369
|
+
// -----------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
kill(id: string): boolean {
|
|
372
|
+
const terminal = this.terminals.get(id);
|
|
373
|
+
if (!terminal) {return false;}
|
|
374
|
+
if (terminal.status === 'exited') {return true;} // already dead
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
// Send SIGTERM through the holder protocol
|
|
378
|
+
terminal.pty.kill('SIGTERM');
|
|
379
|
+
} catch {
|
|
380
|
+
// Holder may already be gone — mark as exited
|
|
381
|
+
terminal.status = 'exited';
|
|
382
|
+
terminal.exitedAt = new Date();
|
|
383
|
+
terminalStore.markExited(id, null);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Schedule forceful kill if still running after delay
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
if (terminal.status === 'running') {
|
|
390
|
+
try {
|
|
391
|
+
terminal.pty.kill('SIGKILL');
|
|
392
|
+
} catch {
|
|
393
|
+
// Already gone
|
|
394
|
+
}
|
|
395
|
+
terminal.status = 'exited';
|
|
396
|
+
terminal.exitedAt = terminal.exitedAt ?? new Date();
|
|
397
|
+
terminalStore.markExited(id, null);
|
|
398
|
+
}
|
|
399
|
+
}, SIGKILL_DELAY_MS);
|
|
400
|
+
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// -----------------------------------------------------------------------
|
|
405
|
+
// detach — remove a WebSocket client
|
|
406
|
+
// -----------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
list(): ManagedTerminal[] {
|
|
409
|
+
const all = Array.from(this.terminals.values());
|
|
410
|
+
|
|
411
|
+
const running = all
|
|
412
|
+
.filter((t) => t.status === 'running')
|
|
413
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
414
|
+
|
|
415
|
+
const exited = all
|
|
416
|
+
.filter((t) => t.status === 'exited')
|
|
417
|
+
.sort((a, b) => {
|
|
418
|
+
const aTime = a.exitedAt?.getTime() ?? a.createdAt.getTime();
|
|
419
|
+
const bTime = b.exitedAt?.getTime() ?? b.createdAt.getTime();
|
|
420
|
+
return bTime - aTime;
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return [...running, ...exited];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// -----------------------------------------------------------------------
|
|
427
|
+
// getScrollback — return raw scrollback data for replay
|
|
428
|
+
// -----------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
async reconnectAll(): Promise<void> {
|
|
431
|
+
const running = terminalStore.listRunning();
|
|
432
|
+
if (running.length === 0) {
|
|
433
|
+
console.log('[pty-manager] No persisted terminals to reconnect');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
console.log(`[pty-manager] Reconnecting to ${running.length} persisted terminal(s)...`);
|
|
438
|
+
|
|
439
|
+
for (const record of running) {
|
|
440
|
+
try {
|
|
441
|
+
await this.reconnectOne(record);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
444
|
+
console.warn(`[pty-manager] Failed to reconnect terminal ${record.id}: ${errMsg}`);
|
|
445
|
+
this.handleReconnectFailure(record);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// -----------------------------------------------------------------------
|
|
451
|
+
// cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
|
|
452
|
+
// also clean up old SQLite records
|
|
453
|
+
// -----------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
remove(id: string): boolean {
|
|
456
|
+
const terminal = this.terminals.get(id);
|
|
457
|
+
if (!terminal) {return false;}
|
|
458
|
+
if (terminal.status === 'running') {return false;} // cannot remove running terminals
|
|
459
|
+
|
|
460
|
+
this.evict(id);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// -----------------------------------------------------------------------
|
|
465
|
+
// destroy — emergency forced kill (kills holder processes too)
|
|
466
|
+
// -----------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
resize(id: string, cols: number, rows: number): boolean {
|
|
469
|
+
const terminal = this.terminals.get(id);
|
|
470
|
+
if (!terminal || terminal.status === 'exited') {return false;}
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
terminal.pty.resize(cols, rows);
|
|
474
|
+
terminal.cols = cols;
|
|
475
|
+
terminal.rows = rows;
|
|
476
|
+
return true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// -----------------------------------------------------------------------
|
|
483
|
+
// Private: reconnectOne — reconnect to a single persisted terminal
|
|
484
|
+
// -----------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
private appendScrollback(terminal: ManagedTerminal, data: string): void {
|
|
487
|
+
terminal.scrollback += data;
|
|
488
|
+
|
|
489
|
+
// Trim at newline boundary when we exceed the byte cap
|
|
490
|
+
// (avoids corrupting multi-byte UTF-8 chars or VT escape sequences)
|
|
491
|
+
if (Buffer.byteLength(terminal.scrollback, 'utf8') > MAX_SCROLLBACK_BYTES) {
|
|
492
|
+
const mid = Math.floor(terminal.scrollback.length / 2);
|
|
493
|
+
const newlineIdx = terminal.scrollback.indexOf('\n', mid);
|
|
494
|
+
if (newlineIdx !== -1) {
|
|
495
|
+
terminal.scrollback = terminal.scrollback.slice(newlineIdx + 1);
|
|
496
|
+
} else {
|
|
497
|
+
// No newline found after midpoint — discard the first half entirely
|
|
498
|
+
terminal.scrollback = terminal.scrollback.slice(mid);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// -----------------------------------------------------------------------
|
|
504
|
+
// Private: handleReconnectFailure — handle failed reconnection
|
|
505
|
+
// -----------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
private broadcastOutput(terminal: ManagedTerminal, data: string): void {
|
|
508
|
+
const msg = JSON.stringify({ data, type: 'output' });
|
|
509
|
+
|
|
510
|
+
for (const ws of terminal.clients) {
|
|
511
|
+
// Skip if WebSocket has too much queued already
|
|
512
|
+
if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
|
|
513
|
+
this.safeSend(ws, JSON.stringify({ bytes: data.length, type: 'output-dropped' }));
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
518
|
+
if (!buffer) {continue;}
|
|
519
|
+
|
|
520
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
521
|
+
|
|
522
|
+
// Check backpressure: if buffer exceeds limit, drop oldest data
|
|
523
|
+
if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
|
|
524
|
+
let droppedBytes = 0;
|
|
525
|
+
while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
|
|
526
|
+
const dropped = buffer.data.shift();
|
|
527
|
+
if (dropped) {
|
|
528
|
+
const droppedSize = Buffer.byteLength(dropped, 'utf8');
|
|
529
|
+
buffer.size -= droppedSize;
|
|
530
|
+
droppedBytes += droppedSize;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Notify client of dropped output
|
|
535
|
+
if (droppedBytes > 0) {
|
|
536
|
+
const dropMsg = JSON.stringify({
|
|
537
|
+
bytes: droppedBytes,
|
|
538
|
+
type: 'output-dropped',
|
|
539
|
+
});
|
|
540
|
+
this.safeSend(ws, dropMsg);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Buffer the message and attempt to send
|
|
545
|
+
buffer.data.push(msg);
|
|
546
|
+
buffer.size += msgSize;
|
|
547
|
+
|
|
548
|
+
this.flushOutputBuffer(ws, buffer);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// -----------------------------------------------------------------------
|
|
553
|
+
// Private: startSessionDiscovery — polling for session files
|
|
554
|
+
// -----------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
/** Evict a terminal, freeing all resources. */
|
|
557
|
+
private evict(id: string): void {
|
|
558
|
+
const terminal = this.terminals.get(id);
|
|
559
|
+
if (!terminal) {return;}
|
|
560
|
+
|
|
561
|
+
// Clear session-file poll timer if still running
|
|
562
|
+
if (terminal.pollTimer) {
|
|
563
|
+
clearInterval(terminal.pollTimer);
|
|
564
|
+
terminal.pollTimer = null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Unsubscribe the no-op OpenCode watcher callback if present
|
|
568
|
+
if (terminal.openCodeSessionId && terminal.openCodeNoopCb) {
|
|
569
|
+
openCodeWatcher.stop(terminal.openCodeSessionId, terminal.openCodeNoopCb);
|
|
570
|
+
terminal.openCodeNoopCb = null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Disconnect from holder (but don't kill it — it may already be gone)
|
|
574
|
+
terminal.pty.disconnect();
|
|
575
|
+
|
|
576
|
+
// Close remaining client connections
|
|
577
|
+
for (const ws of terminal.clients) {
|
|
578
|
+
try {
|
|
579
|
+
ws.close();
|
|
580
|
+
} catch {
|
|
581
|
+
// Best effort
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
terminal.clients.clear();
|
|
585
|
+
terminal.outputBuffers.clear();
|
|
586
|
+
terminal.scrollback = '';
|
|
587
|
+
|
|
588
|
+
this.terminals.delete(id);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// -----------------------------------------------------------------------
|
|
592
|
+
// Private: appendScrollback — append to cached scrollback string,
|
|
593
|
+
// trim from midpoint when cap exceeded
|
|
594
|
+
// -----------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
/** Attempt to flush buffered messages to a WebSocket client. */
|
|
597
|
+
private flushOutputBuffer(ws: WebSocket, buffer: OutputBuffer): void {
|
|
598
|
+
while (buffer.data.length > 0) {
|
|
599
|
+
const msg = buffer.data[0];
|
|
600
|
+
if (!this.safeSend(ws, msg)) {
|
|
601
|
+
// Send failed — leave remaining messages in the buffer
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
buffer.data.shift();
|
|
605
|
+
buffer.size -= Buffer.byteLength(msg, 'utf8');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// -----------------------------------------------------------------------
|
|
610
|
+
// Private: broadcastOutput — send output to all connected WS clients
|
|
611
|
+
// with backpressure management
|
|
612
|
+
// -----------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
private handleReconnectFailure(record: {
|
|
615
|
+
holderPid: null | number;
|
|
616
|
+
id: string;
|
|
617
|
+
socketPath: null | string;
|
|
618
|
+
}): void {
|
|
619
|
+
// Check if holder PID is still alive
|
|
620
|
+
if (record.holderPid) {
|
|
621
|
+
try {
|
|
622
|
+
process.kill(record.holderPid, 0); // Signal 0 = check alive
|
|
623
|
+
// PID is alive but socket failed — unusual state, still mark orphaned
|
|
624
|
+
console.warn(
|
|
625
|
+
`[pty-manager] Holder PID ${record.holderPid} alive but socket dead for ${record.id}`
|
|
626
|
+
);
|
|
627
|
+
} catch {
|
|
628
|
+
// PID is dead — expected case
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Check for .exit sidecar
|
|
633
|
+
if (record.socketPath) {
|
|
634
|
+
const exitFilePath = `${record.socketPath }.exit`;
|
|
635
|
+
if (existsSync(exitFilePath)) {
|
|
636
|
+
try {
|
|
637
|
+
const exitData = JSON.parse(readFileSync(exitFilePath, 'utf8')) as {
|
|
638
|
+
code: null | number;
|
|
639
|
+
timestamp: number;
|
|
640
|
+
};
|
|
641
|
+
unlinkSync(exitFilePath);
|
|
642
|
+
terminalStore.markExited(record.id, exitData.code);
|
|
643
|
+
console.log(
|
|
644
|
+
`[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
|
|
645
|
+
);
|
|
646
|
+
return;
|
|
647
|
+
} catch {
|
|
648
|
+
// Malformed sidecar — fall through to orphan
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Mark as orphaned in SQLite (not added to in-memory Map)
|
|
654
|
+
terminalStore.markOrphaned(record.id);
|
|
655
|
+
console.log(`[pty-manager] Marked terminal ${record.id} as orphaned`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private async reconnectOne(record: {
|
|
659
|
+
args: string;
|
|
660
|
+
cols: number;
|
|
661
|
+
command: string;
|
|
662
|
+
createdAt: string;
|
|
663
|
+
cwd: string;
|
|
664
|
+
exitCode: null | number;
|
|
665
|
+
exitedAt: null | string;
|
|
666
|
+
holderPid: null | number;
|
|
667
|
+
id: string;
|
|
668
|
+
opencodeSessionId: null | string;
|
|
669
|
+
pid: null | number;
|
|
670
|
+
rows: number;
|
|
671
|
+
sessionFile: null | string;
|
|
672
|
+
socketPath: null | string;
|
|
673
|
+
status: string;
|
|
674
|
+
}): Promise<void> {
|
|
675
|
+
if (!record.socketPath) {
|
|
676
|
+
throw new Error('No socket path stored');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Check for .exit sidecar file — the PTY may have exited while
|
|
680
|
+
// the server was down
|
|
681
|
+
const exitFilePath = `${record.socketPath }.exit`;
|
|
682
|
+
if (existsSync(exitFilePath)) {
|
|
683
|
+
try {
|
|
684
|
+
const exitData = JSON.parse(readFileSync(exitFilePath, 'utf8')) as {
|
|
685
|
+
code: null | number;
|
|
686
|
+
timestamp: number;
|
|
687
|
+
};
|
|
688
|
+
unlinkSync(exitFilePath); // Clean up sidecar immediately
|
|
689
|
+
terminalStore.markExited(record.id, exitData.code);
|
|
690
|
+
console.log(
|
|
691
|
+
`[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
|
|
692
|
+
);
|
|
693
|
+
return; // Do not add to in-memory Map
|
|
694
|
+
} catch {
|
|
695
|
+
// Sidecar file may be malformed — continue with socket connect attempt
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Try connecting to the holder via its Unix socket
|
|
700
|
+
const client = new HolderClient();
|
|
701
|
+
const connectResult = await client.connect(record.socketPath);
|
|
702
|
+
|
|
703
|
+
// Parse stored args
|
|
704
|
+
let parsedArgs: string[] = [];
|
|
705
|
+
try {
|
|
706
|
+
parsedArgs = JSON.parse(record.args) as string[];
|
|
707
|
+
} catch {
|
|
708
|
+
// Fallback to empty
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const terminal: ManagedTerminal = {
|
|
712
|
+
args: parsedArgs,
|
|
713
|
+
clients: new Set(),
|
|
714
|
+
cols: record.cols,
|
|
715
|
+
command: record.command,
|
|
716
|
+
createdAt: new Date(record.createdAt),
|
|
717
|
+
currentCwd: null,
|
|
718
|
+
cwd: record.cwd,
|
|
719
|
+
exitCode: connectResult.exitCode,
|
|
720
|
+
exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
|
|
721
|
+
holderPid: record.holderPid ?? 0,
|
|
722
|
+
id: record.id,
|
|
723
|
+
isActive: false,
|
|
724
|
+
openCodeNoopCb: null,
|
|
725
|
+
openCodeSessionId: record.opencodeSessionId ?? null,
|
|
726
|
+
outputBuffers: new Map(),
|
|
727
|
+
pid: connectResult.pid,
|
|
728
|
+
pollTimer: null,
|
|
729
|
+
pty: client,
|
|
730
|
+
rows: record.rows,
|
|
731
|
+
scrollback: connectResult.scrollback,
|
|
732
|
+
sessionFile: record.sessionFile ?? null,
|
|
733
|
+
socketPath: record.socketPath,
|
|
734
|
+
status: connectResult.exited ? 'exited' : 'running',
|
|
735
|
+
watcherOffset: 0,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// If the PTY already exited, update SQLite and add to Map for visibility
|
|
739
|
+
if (connectResult.exited) {
|
|
740
|
+
terminal.exitedAt = terminal.exitedAt ?? new Date();
|
|
741
|
+
terminalStore.markExited(record.id, connectResult.exitCode);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Wire up all HolderClient callbacks
|
|
745
|
+
this.wireHolderCallbacks(client, terminal);
|
|
746
|
+
|
|
747
|
+
// Re-attach session watchers
|
|
748
|
+
if (terminal.sessionFile) {
|
|
749
|
+
// No-op: session-handler.ts subscribes when a client connects.
|
|
750
|
+
// Previously called sessionWatcher.watch() with an empty callback,
|
|
751
|
+
// which blocked real subscribers due to the single-callback guard.
|
|
752
|
+
}
|
|
753
|
+
if (terminal.openCodeSessionId) {
|
|
754
|
+
const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
|
|
755
|
+
terminal.openCodeNoopCb = noopCb;
|
|
756
|
+
openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Restart session discovery if session hasn't been found yet
|
|
760
|
+
if (!terminal.sessionFile && !terminal.openCodeSessionId && terminal.status === 'running') {
|
|
761
|
+
this.startSessionDiscovery(terminal);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Update PID in SQLite if it changed (e.g., holder restarted PTY — unlikely but defensive)
|
|
765
|
+
if (record.pid !== connectResult.pid) {
|
|
766
|
+
terminalStore.update(record.id, { pid: connectResult.pid });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
this.terminals.set(record.id, terminal);
|
|
770
|
+
console.log(
|
|
771
|
+
`[pty-manager] Reconnected terminal ${record.id} (pid=${connectResult.pid}, ` +
|
|
772
|
+
`holder=${record.holderPid}, status=${terminal.status})`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Wire up all HolderClient callbacks (activity, CWD, output, exit, disconnect). */
|
|
777
|
+
private wireHolderCallbacks(client: HolderClient, terminal: ManagedTerminal): void {
|
|
778
|
+
client.onActivity((active: boolean) => {
|
|
779
|
+
terminal.isActive = active;
|
|
780
|
+
const msg = JSON.stringify({ active, type: 'activity' });
|
|
781
|
+
for (const ws of terminal.clients) {
|
|
782
|
+
this.safeSend(ws, msg);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
client.onCwd((path: string) => {
|
|
787
|
+
terminal.currentCwd = path;
|
|
788
|
+
const msg = JSON.stringify({ path, type: 'cwd' });
|
|
789
|
+
for (const ws of terminal.clients) {
|
|
790
|
+
this.safeSend(ws, msg);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
client.onOutput((data: string) => {
|
|
795
|
+
this.appendScrollback(terminal, data);
|
|
796
|
+
this.broadcastOutput(terminal, data);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
client.onExit((exitCode: null | number) => {
|
|
800
|
+
terminal.status = 'exited';
|
|
801
|
+
terminal.exitCode = exitCode;
|
|
802
|
+
terminal.exitedAt = new Date();
|
|
803
|
+
terminalStore.markExited(terminal.id, exitCode);
|
|
804
|
+
|
|
805
|
+
const exitMsg = JSON.stringify({
|
|
806
|
+
code: exitCode,
|
|
807
|
+
signal: null,
|
|
808
|
+
type: 'exit',
|
|
809
|
+
});
|
|
810
|
+
for (const ws of terminal.clients) {
|
|
811
|
+
this.safeSend(ws, exitMsg);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
client.onDisconnect(() => {
|
|
816
|
+
if (terminal.status === 'running') {
|
|
817
|
+
console.warn(`[pty-manager] Holder disconnected unexpectedly for terminal ${terminal.id}`);
|
|
818
|
+
terminal.status = 'exited';
|
|
819
|
+
terminal.exitedAt = new Date();
|
|
820
|
+
terminalStore.markOrphaned(terminal.id);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Safely send a message to a WebSocket, returning false on failure. */
|
|
826
|
+
private safeSend(ws: WebSocket, msg: string): boolean {
|
|
827
|
+
try {
|
|
828
|
+
// readyState 1 === OPEN
|
|
829
|
+
if (ws.readyState !== 1) {return false;}
|
|
830
|
+
ws.send(msg);
|
|
831
|
+
return true;
|
|
832
|
+
} catch {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Send cached scrollback data to a newly connected client in 50 KB chunks. */
|
|
838
|
+
private async sendScrollback(terminal: ManagedTerminal, ws: WebSocket): Promise<void> {
|
|
839
|
+
const fullData = terminal.scrollback;
|
|
840
|
+
if (fullData.length === 0) {return;}
|
|
841
|
+
|
|
842
|
+
const totalBytes = Buffer.byteLength(fullData, 'utf8');
|
|
843
|
+
const totalChunks = Math.ceil(totalBytes / SCROLLBACK_CHUNK_SIZE);
|
|
844
|
+
|
|
845
|
+
if (totalChunks <= 1) {
|
|
846
|
+
// Single chunk — send directly
|
|
847
|
+
const msg = JSON.stringify({
|
|
848
|
+
chunk: 1,
|
|
849
|
+
data: fullData,
|
|
850
|
+
total: 1,
|
|
851
|
+
type: 'scrollback',
|
|
852
|
+
});
|
|
853
|
+
this.safeSend(ws, msg);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Multi-chunk: split the data into byte-safe segments
|
|
858
|
+
const buf = Buffer.from(fullData, 'utf8');
|
|
859
|
+
let offset = 0;
|
|
860
|
+
let chunkIndex = 1;
|
|
861
|
+
|
|
862
|
+
while (offset < buf.length) {
|
|
863
|
+
// Gate scrollback sending on actual socket backpressure
|
|
864
|
+
if (ws.bufferedAmount > SCROLLBACK_CHUNK_SIZE * 2) {
|
|
865
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const end = Math.min(offset + SCROLLBACK_CHUNK_SIZE, buf.length);
|
|
869
|
+
const chunkData = buf.subarray(offset, end).toString('utf8');
|
|
870
|
+
const msg = JSON.stringify({
|
|
871
|
+
chunk: chunkIndex,
|
|
872
|
+
data: chunkData,
|
|
873
|
+
total: totalChunks,
|
|
874
|
+
type: 'scrollback',
|
|
875
|
+
});
|
|
876
|
+
this.safeSend(ws, msg);
|
|
877
|
+
offset = end;
|
|
878
|
+
chunkIndex++;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private startSessionDiscovery(terminal: ManagedTerminal): void {
|
|
883
|
+
const { command, cwd, id } = terminal;
|
|
884
|
+
|
|
885
|
+
// For Claude Code: detect the session file by watching the project
|
|
886
|
+
// directory for new JSONL files created after launch.
|
|
887
|
+
if (command === 'claude') {
|
|
888
|
+
const projectDir = path.join(
|
|
889
|
+
process.env.HOME || '', '.claude', 'projects',
|
|
890
|
+
cwd.replace(/\//g, '-')
|
|
891
|
+
);
|
|
892
|
+
const launchTime = terminal.createdAt.getTime();
|
|
893
|
+
|
|
894
|
+
terminal.pollTimer = setInterval(() => {
|
|
895
|
+
if (terminal.status === 'exited' || terminal.sessionFile) {
|
|
896
|
+
if (terminal.pollTimer) {
|
|
897
|
+
clearInterval(terminal.pollTimer);
|
|
898
|
+
terminal.pollTimer = null;
|
|
899
|
+
}
|
|
900
|
+
if (terminal.sessionFile) {
|
|
901
|
+
// No-op: session-handler.ts subscribes when a client connects.
|
|
902
|
+
// Previously called sessionWatcher.watch() with an empty callback,
|
|
903
|
+
// which blocked real subscribers due to the single-callback guard.
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
if (!existsSync(projectDir)) {return;}
|
|
909
|
+
const files = readdirSync(projectDir)
|
|
910
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
911
|
+
.map((f) => {
|
|
912
|
+
const stat = statSync(path.join(projectDir, f));
|
|
913
|
+
return {
|
|
914
|
+
birthtime: stat.birthtimeMs,
|
|
915
|
+
fullPath: path.join(projectDir, f),
|
|
916
|
+
mtime: stat.mtimeMs,
|
|
917
|
+
name: f,
|
|
918
|
+
};
|
|
919
|
+
})
|
|
920
|
+
// Filter by CREATION time, not modification time.
|
|
921
|
+
// Using mtime would match existing active sessions in the same
|
|
922
|
+
// project directory (their mtime keeps updating as they write).
|
|
923
|
+
.filter((f) => f.birthtime > launchTime)
|
|
924
|
+
.sort((a, b) => b.birthtime - a.birthtime);
|
|
925
|
+
|
|
926
|
+
if (files.length > 0) {
|
|
927
|
+
terminal.sessionFile = files[0].fullPath;
|
|
928
|
+
if (terminal.pollTimer) {
|
|
929
|
+
clearInterval(terminal.pollTimer);
|
|
930
|
+
terminal.pollTimer = null;
|
|
931
|
+
}
|
|
932
|
+
// No-op: session-handler.ts subscribes when a client connects.
|
|
933
|
+
// Previously called sessionWatcher.watch() with an empty callback,
|
|
934
|
+
// which blocked real subscribers due to the single-callback guard.
|
|
935
|
+
// Persist session file to SQLite
|
|
936
|
+
terminalStore.update(id, { sessionFile: terminal.sessionFile });
|
|
937
|
+
}
|
|
938
|
+
} catch {
|
|
939
|
+
// ignore filesystem errors
|
|
940
|
+
}
|
|
941
|
+
}, 1500);
|
|
942
|
+
|
|
943
|
+
setTimeout(() => {
|
|
944
|
+
if (terminal.pollTimer) {
|
|
945
|
+
clearInterval(terminal.pollTimer);
|
|
946
|
+
terminal.pollTimer = null;
|
|
947
|
+
}
|
|
948
|
+
}, 5 * 60 * 1000);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// For OpenCode: detect the session via SQLite database lookup.
|
|
952
|
+
// Only match sessions created AFTER this terminal launched (prevents
|
|
953
|
+
// latching onto old sessions in the same directory).
|
|
954
|
+
if (command === 'opencode') {
|
|
955
|
+
const launchTime = terminal.createdAt.getTime();
|
|
956
|
+
const pollInterval = setInterval(() => {
|
|
957
|
+
if (terminal.status === 'exited' || terminal.openCodeSessionId) {
|
|
958
|
+
clearInterval(pollInterval);
|
|
959
|
+
if (terminal.openCodeSessionId) {
|
|
960
|
+
const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
|
|
961
|
+
terminal.openCodeNoopCb = noopCb;
|
|
962
|
+
openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const sessionId = openCodeWatcher.findSessionId(cwd, launchTime);
|
|
967
|
+
if (sessionId) {
|
|
968
|
+
terminal.openCodeSessionId = sessionId;
|
|
969
|
+
clearInterval(pollInterval);
|
|
970
|
+
const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
|
|
971
|
+
terminal.openCodeNoopCb = noopCb;
|
|
972
|
+
openCodeWatcher.watch(sessionId, noopCb);
|
|
973
|
+
// Persist session ID to SQLite
|
|
974
|
+
terminalStore.update(id, { opencodeSessionId: sessionId });
|
|
975
|
+
}
|
|
976
|
+
}, 2000);
|
|
977
|
+
|
|
978
|
+
terminal.pollTimer = pollInterval;
|
|
979
|
+
setTimeout(() => { clearInterval(pollInterval); terminal.pollTimer = null; }, 5 * 60 * 1000);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ---------------------------------------------------------------------------
|
|
985
|
+
// PtyManager
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
|
|
988
|
+
function resolveHolderPath(): string {
|
|
989
|
+
if (process.env.SHOOTER_HOLDER_PATH) {
|
|
990
|
+
return process.env.SHOOTER_HOLDER_PATH;
|
|
991
|
+
}
|
|
992
|
+
// In dev: __dirname is src/lib/modules/server/terminal/ → pty-holder.cjs is co-located
|
|
993
|
+
// In prod: __dirname is build/server/chunks/ → pty-holder.cjs is at build/ (copied by postbuild)
|
|
994
|
+
const colocated = path.join(__dirname, 'pty-holder.cjs');
|
|
995
|
+
if (existsSync(colocated)) {
|
|
996
|
+
return colocated;
|
|
997
|
+
}
|
|
998
|
+
// Walk up from build/server/chunks/ to build/
|
|
999
|
+
return path.resolve(__dirname, '..', '..', 'pty-holder.cjs');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// Singleton export
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
// Use globalThis to ensure a single shared instance across module loaders.
|
|
1007
|
+
// server.ts (tsx) and SvelteKit's build handler load this module separately.
|
|
1008
|
+
const PTY_GLOBAL_KEY = '__shooter_pty_manager';
|
|
1009
|
+
export const ptyManager: PtyManager =
|
|
1010
|
+
((globalThis as Record<string, unknown>)[PTY_GLOBAL_KEY] as PtyManager) ||
|
|
1011
|
+
new PtyManager();
|
|
1012
|
+
(globalThis as Record<string, unknown>)[PTY_GLOBAL_KEY] = ptyManager;
|