@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,1697 @@
|
|
|
1
|
+
import { fork } from 'child_process';
|
|
2
|
+
import { randomBytes } from 'crypto';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import path__default from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import * as net from 'net';
|
|
9
|
+
import Database from 'better-sqlite3';
|
|
10
|
+
import { watch } from 'chokidar';
|
|
11
|
+
import { p as parseJsonlText } from './jsonl-parser-dmZU_Hyu.js';
|
|
12
|
+
|
|
13
|
+
class HolderClient {
|
|
14
|
+
connected = false;
|
|
15
|
+
pid = 0;
|
|
16
|
+
activityCb = null;
|
|
17
|
+
cwdCb = null;
|
|
18
|
+
disconnectCb = null;
|
|
19
|
+
exitCb = null;
|
|
20
|
+
lineBuf = "";
|
|
21
|
+
outputCb = null;
|
|
22
|
+
socket = null;
|
|
23
|
+
/**
|
|
24
|
+
* Connect to a holder process via its Unix domain socket.
|
|
25
|
+
* Resolves once the initial `info` and `scrollback` handshake messages
|
|
26
|
+
* have been received.
|
|
27
|
+
*/
|
|
28
|
+
connect(socketPath) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
let info = null;
|
|
32
|
+
let scrollback = "";
|
|
33
|
+
const pendingMessages = [];
|
|
34
|
+
const socket = net.createConnection(socketPath);
|
|
35
|
+
this.socket = socket;
|
|
36
|
+
socket.setEncoding("utf8");
|
|
37
|
+
socket.on("connect", () => {
|
|
38
|
+
this.connected = true;
|
|
39
|
+
});
|
|
40
|
+
socket.on("data", (chunk) => {
|
|
41
|
+
this.lineBuf += chunk;
|
|
42
|
+
const lines = this.lineBuf.split("\n");
|
|
43
|
+
this.lineBuf = lines.pop();
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.length === 0) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let msg;
|
|
49
|
+
try {
|
|
50
|
+
msg = JSON.parse(line);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!settled) {
|
|
55
|
+
if (msg.type === "info") {
|
|
56
|
+
info = { exitCode: msg.exitCode, exited: msg.exited, pid: msg.pid };
|
|
57
|
+
this.pid = msg.pid;
|
|
58
|
+
} else if (msg.type === "scrollback") {
|
|
59
|
+
scrollback = msg.data;
|
|
60
|
+
} else {
|
|
61
|
+
pendingMessages.push(msg);
|
|
62
|
+
}
|
|
63
|
+
if (info !== null) {
|
|
64
|
+
const settle = () => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
resolve({
|
|
68
|
+
exitCode: info.exitCode,
|
|
69
|
+
exited: info.exited,
|
|
70
|
+
pid: info.pid,
|
|
71
|
+
scrollback
|
|
72
|
+
});
|
|
73
|
+
const queued = [...pendingMessages];
|
|
74
|
+
pendingMessages.length = 0;
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
for (const pending of queued) {
|
|
77
|
+
this.handleMessage(pending);
|
|
78
|
+
}
|
|
79
|
+
}, 0);
|
|
80
|
+
};
|
|
81
|
+
if (msg.type === "scrollback" || info.exited) {
|
|
82
|
+
settle();
|
|
83
|
+
} else if (msg.type === "info") {
|
|
84
|
+
setTimeout(settle, 100);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
this.handleMessage(msg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
socket.on("error", (err) => {
|
|
93
|
+
if (!settled) {
|
|
94
|
+
settled = true;
|
|
95
|
+
this.connected = false;
|
|
96
|
+
this.socket = null;
|
|
97
|
+
reject(err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
socket.on("close", () => {
|
|
101
|
+
const wasConnected = this.connected;
|
|
102
|
+
this.connected = false;
|
|
103
|
+
this.socket = null;
|
|
104
|
+
this.lineBuf = "";
|
|
105
|
+
if (!settled) {
|
|
106
|
+
settled = true;
|
|
107
|
+
reject(new Error("Socket closed before handshake completed"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (wasConnected && this.disconnectCb) {
|
|
111
|
+
this.disconnectCb();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Gracefully disconnect from the holder (does NOT kill the holder). */
|
|
117
|
+
disconnect() {
|
|
118
|
+
this.connected = false;
|
|
119
|
+
if (this.socket) {
|
|
120
|
+
this.socket.destroy();
|
|
121
|
+
this.socket = null;
|
|
122
|
+
}
|
|
123
|
+
this.lineBuf = "";
|
|
124
|
+
}
|
|
125
|
+
/** Send a signal to the PTY process (default SIGTERM). */
|
|
126
|
+
kill(signal) {
|
|
127
|
+
const msg = { type: "kill" };
|
|
128
|
+
if (signal) {
|
|
129
|
+
msg.signal = signal;
|
|
130
|
+
}
|
|
131
|
+
this.send(msg);
|
|
132
|
+
}
|
|
133
|
+
/** Register callback for activity state changes. */
|
|
134
|
+
onActivity(cb) {
|
|
135
|
+
this.activityCb = cb;
|
|
136
|
+
}
|
|
137
|
+
/** Register callback for CWD changes. */
|
|
138
|
+
onCwd(cb) {
|
|
139
|
+
this.cwdCb = cb;
|
|
140
|
+
}
|
|
141
|
+
/** Register callback for unexpected disconnect from holder. */
|
|
142
|
+
onDisconnect(cb) {
|
|
143
|
+
this.disconnectCb = cb;
|
|
144
|
+
}
|
|
145
|
+
/** Register callback for PTY exit. */
|
|
146
|
+
onExit(cb) {
|
|
147
|
+
this.exitCb = cb;
|
|
148
|
+
}
|
|
149
|
+
/** Register callback for PTY output data. */
|
|
150
|
+
onOutput(cb) {
|
|
151
|
+
this.outputCb = cb;
|
|
152
|
+
}
|
|
153
|
+
/** Resize the PTY. */
|
|
154
|
+
resize(cols, rows) {
|
|
155
|
+
this.send({ cols, rows, type: "resize" });
|
|
156
|
+
}
|
|
157
|
+
/** Write data to the PTY stdin. */
|
|
158
|
+
write(data) {
|
|
159
|
+
this.send({ data, type: "input" });
|
|
160
|
+
}
|
|
161
|
+
// ── Private Helpers ────────────────────────────────────────────────
|
|
162
|
+
/** Dispatch a post-handshake message from the holder. */
|
|
163
|
+
handleMessage(msg) {
|
|
164
|
+
switch (msg.type) {
|
|
165
|
+
case "activity":
|
|
166
|
+
if (this.activityCb) {
|
|
167
|
+
this.activityCb(msg.active);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
case "cwd":
|
|
171
|
+
if (this.cwdCb) {
|
|
172
|
+
this.cwdCb(msg.path);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case "exit":
|
|
176
|
+
if (this.exitCb) {
|
|
177
|
+
this.exitCb(msg.code);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case "output":
|
|
181
|
+
if (this.outputCb) {
|
|
182
|
+
this.outputCb(msg.data);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Send an ndjson message to the holder. */
|
|
188
|
+
send(msg) {
|
|
189
|
+
if (!this.socket || !this.connected) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.socket.write(`${JSON.stringify(msg)}
|
|
193
|
+
`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const OPENCODE_DB_PATH = (() => {
|
|
197
|
+
if (process.platform === "darwin") {
|
|
198
|
+
return path.join(process.env.HOME || "", "Library", "Application Support", "opencode", "opencode.db");
|
|
199
|
+
}
|
|
200
|
+
const xdgData = process.env.XDG_DATA_HOME || path.join(process.env.HOME || "", ".local", "share");
|
|
201
|
+
return path.join(xdgData, "opencode", "opencode.db");
|
|
202
|
+
})();
|
|
203
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
204
|
+
const SQLITE_MAX_PARAMS = 500;
|
|
205
|
+
function toMillis(timestamp) {
|
|
206
|
+
return timestamp < 1e12 ? timestamp * 1e3 : timestamp;
|
|
207
|
+
}
|
|
208
|
+
class OpenCodeWatcher {
|
|
209
|
+
watchers = /* @__PURE__ */ new Map();
|
|
210
|
+
/**
|
|
211
|
+
* Find the most recent non-archived OpenCode session that matches
|
|
212
|
+
* the given working directory. Checks session.directory equals or
|
|
213
|
+
* starts with `cwd`.
|
|
214
|
+
*
|
|
215
|
+
* Returns the session ID or null if none found.
|
|
216
|
+
*/
|
|
217
|
+
findSessionId(cwd, createdAfter) {
|
|
218
|
+
const db = openDb();
|
|
219
|
+
if (!db) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const timeFilter = createdAfter ? Math.min(createdAfter, Math.floor(createdAfter / 1e3)) : 0;
|
|
224
|
+
const row = db.prepare(
|
|
225
|
+
`
|
|
226
|
+
SELECT id
|
|
227
|
+
FROM session
|
|
228
|
+
WHERE (time_archived IS NULL OR time_archived = 0)
|
|
229
|
+
AND (directory = ? OR directory LIKE ? || '/%')
|
|
230
|
+
AND time_updated > ?
|
|
231
|
+
ORDER BY time_updated DESC
|
|
232
|
+
LIMIT 1
|
|
233
|
+
`
|
|
234
|
+
).get(cwd, cwd, timeFilter);
|
|
235
|
+
return row?.id ?? null;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.error("[opencode-watcher] Failed to find session:", error);
|
|
238
|
+
return null;
|
|
239
|
+
} finally {
|
|
240
|
+
db.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Read all messages and parts for a session from SQLite, converting
|
|
245
|
+
* them to ConversationMessage format.
|
|
246
|
+
*/
|
|
247
|
+
getHistory(sessionId) {
|
|
248
|
+
const db = openDb();
|
|
249
|
+
if (!db) {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const messages = db.prepare(
|
|
254
|
+
`
|
|
255
|
+
SELECT id, session_id, time_created, time_updated, data
|
|
256
|
+
FROM message
|
|
257
|
+
WHERE session_id = ?
|
|
258
|
+
ORDER BY time_created ASC
|
|
259
|
+
`
|
|
260
|
+
).all(sessionId);
|
|
261
|
+
if (messages.length === 0) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
const messageIds = messages.map((m) => m.id);
|
|
265
|
+
const parts = batchInQuery(
|
|
266
|
+
db,
|
|
267
|
+
`SELECT id, message_id, session_id, time_created, time_updated, data
|
|
268
|
+
FROM part
|
|
269
|
+
WHERE message_id IN (__PLACEHOLDERS__)
|
|
270
|
+
ORDER BY time_created ASC`,
|
|
271
|
+
messageIds
|
|
272
|
+
);
|
|
273
|
+
const partsByMessage = /* @__PURE__ */ new Map();
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
if (!partsByMessage.has(part.message_id)) {
|
|
276
|
+
partsByMessage.set(part.message_id, []);
|
|
277
|
+
}
|
|
278
|
+
partsByMessage.get(part.message_id).push(part);
|
|
279
|
+
}
|
|
280
|
+
return this.buildMessages(messages, partsByMessage);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error("[opencode-watcher] Failed to read history:", error);
|
|
283
|
+
return [];
|
|
284
|
+
} finally {
|
|
285
|
+
db.close();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Stop watching a specific session. If a callback is provided, only that
|
|
290
|
+
* subscriber is removed — the interval keeps running while other subscribers
|
|
291
|
+
* remain. If no callback is provided, all subscribers and the interval are
|
|
292
|
+
* cleared (backward compat).
|
|
293
|
+
*/
|
|
294
|
+
stop(sessionId, callback) {
|
|
295
|
+
const state = this.watchers.get(sessionId);
|
|
296
|
+
if (!state) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (callback) {
|
|
300
|
+
state.callbacks.delete(callback);
|
|
301
|
+
console.log(
|
|
302
|
+
`[opencode-watcher] Removed subscriber from session: ${sessionId} (remaining=${state.callbacks.size})`
|
|
303
|
+
);
|
|
304
|
+
if (state.callbacks.size > 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
clearInterval(state.intervalHandle);
|
|
309
|
+
this.watchers.delete(sessionId);
|
|
310
|
+
console.log(`[opencode-watcher] Stopped watching session: ${sessionId}`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Stop all active watchers.
|
|
314
|
+
*/
|
|
315
|
+
stopAll() {
|
|
316
|
+
for (const [sessionId] of this.watchers) {
|
|
317
|
+
this.stop(sessionId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Start polling the SQLite DB every 2 seconds for new messages/parts
|
|
322
|
+
* in the given session. Converts new data to ConversationMessage
|
|
323
|
+
* format and invokes the callback.
|
|
324
|
+
*/
|
|
325
|
+
watch(sessionId, callback) {
|
|
326
|
+
const existing = this.watchers.get(sessionId);
|
|
327
|
+
if (existing) {
|
|
328
|
+
existing.callbacks.add(callback);
|
|
329
|
+
console.log(
|
|
330
|
+
`[opencode-watcher] Added subscriber to session: ${sessionId} (total=${existing.callbacks.size})`
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const { emittedMessageIds, emittedPartIds, lastMessageTime, lastPartTime } = this.getHighWaterMarks(sessionId);
|
|
335
|
+
const intervalHandle = setInterval(() => {
|
|
336
|
+
this.poll(sessionId);
|
|
337
|
+
}, POLL_INTERVAL_MS);
|
|
338
|
+
const state = {
|
|
339
|
+
callbacks: /* @__PURE__ */ new Set([callback]),
|
|
340
|
+
emittedMessageIds,
|
|
341
|
+
emittedPartIds,
|
|
342
|
+
intervalHandle,
|
|
343
|
+
lastMessageTime,
|
|
344
|
+
lastPartTime,
|
|
345
|
+
sessionId
|
|
346
|
+
};
|
|
347
|
+
this.watchers.set(sessionId, state);
|
|
348
|
+
console.log(
|
|
349
|
+
`[opencode-watcher] Watching session: ${sessionId} (lastMsg=${lastMessageTime}, lastPart=${lastPartTime})`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
// ── Private Helpers ────────────────────────────────────────────────
|
|
353
|
+
/**
|
|
354
|
+
* Convert OpenCode messages + their parts into ConversationMessage
|
|
355
|
+
* objects for consumption by the session handler.
|
|
356
|
+
*/
|
|
357
|
+
buildMessages(messages, partsByMessage) {
|
|
358
|
+
const result = [];
|
|
359
|
+
for (const msg of messages) {
|
|
360
|
+
let msgData = {};
|
|
361
|
+
try {
|
|
362
|
+
msgData = JSON.parse(msg.data);
|
|
363
|
+
} catch {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const role = msgData.role === "user" ? "user" : "assistant";
|
|
367
|
+
const msgParts = partsByMessage.get(msg.id) || [];
|
|
368
|
+
const parts = [];
|
|
369
|
+
for (const part of msgParts) {
|
|
370
|
+
let partData;
|
|
371
|
+
try {
|
|
372
|
+
partData = JSON.parse(part.data);
|
|
373
|
+
} catch {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const converted = convertPartToMessagePart(partData);
|
|
377
|
+
if (converted) {
|
|
378
|
+
parts.push(converted);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (parts.length === 0) {
|
|
382
|
+
console.debug(`[opencode-watcher] Skipping message ${msg.id} (no usable parts)`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
result.push({
|
|
386
|
+
id: msg.id,
|
|
387
|
+
parts,
|
|
388
|
+
role,
|
|
389
|
+
timestamp: new Date(toMillis(msg.time_created)).toISOString()
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Scan existing messages/parts to determine the starting high-water
|
|
396
|
+
* marks for time-based polling. Also collects the set of already-seen
|
|
397
|
+
* message IDs so we do not re-emit them.
|
|
398
|
+
*/
|
|
399
|
+
getHighWaterMarks(sessionId) {
|
|
400
|
+
const db = openDb();
|
|
401
|
+
if (!db) {
|
|
402
|
+
return { emittedMessageIds: /* @__PURE__ */ new Set(), emittedPartIds: /* @__PURE__ */ new Set(), lastMessageTime: 0, lastPartTime: 0 };
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const msgRow = db.prepare(
|
|
406
|
+
`
|
|
407
|
+
SELECT MAX(time_created) as maxTime
|
|
408
|
+
FROM message
|
|
409
|
+
WHERE session_id = ?
|
|
410
|
+
`
|
|
411
|
+
).get(sessionId);
|
|
412
|
+
const partRow = db.prepare(
|
|
413
|
+
`
|
|
414
|
+
SELECT MAX(time_updated) as maxTime
|
|
415
|
+
FROM part
|
|
416
|
+
WHERE session_id = ?
|
|
417
|
+
`
|
|
418
|
+
).get(sessionId);
|
|
419
|
+
const existingIds = db.prepare(
|
|
420
|
+
`
|
|
421
|
+
SELECT id FROM message WHERE session_id = ?
|
|
422
|
+
`
|
|
423
|
+
).all(sessionId);
|
|
424
|
+
const emittedMessageIds = new Set(existingIds.map((r) => r.id));
|
|
425
|
+
const existingPartIds = db.prepare(
|
|
426
|
+
`
|
|
427
|
+
SELECT id FROM part WHERE session_id = ?
|
|
428
|
+
`
|
|
429
|
+
).all(sessionId);
|
|
430
|
+
const emittedPartIds = new Set(existingPartIds.map((r) => r.id));
|
|
431
|
+
return {
|
|
432
|
+
emittedMessageIds,
|
|
433
|
+
emittedPartIds,
|
|
434
|
+
lastMessageTime: msgRow?.maxTime ?? 0,
|
|
435
|
+
lastPartTime: partRow?.maxTime ?? 0
|
|
436
|
+
};
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.error("[opencode-watcher] Failed to get high-water marks:", error);
|
|
439
|
+
return { emittedMessageIds: /* @__PURE__ */ new Set(), emittedPartIds: /* @__PURE__ */ new Set(), lastMessageTime: 0, lastPartTime: 0 };
|
|
440
|
+
} finally {
|
|
441
|
+
db.close();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Single poll iteration. Opens the DB, queries for new messages and
|
|
446
|
+
* updated parts, converts them to ConversationMessage[], and invokes
|
|
447
|
+
* the watcher callbacks.
|
|
448
|
+
*/
|
|
449
|
+
poll(sessionId) {
|
|
450
|
+
const state = this.watchers.get(sessionId);
|
|
451
|
+
if (!state) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const db = openDb();
|
|
455
|
+
if (!db) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const results = [];
|
|
460
|
+
const newMessages = db.prepare(
|
|
461
|
+
`
|
|
462
|
+
SELECT id, session_id, time_created, time_updated, data
|
|
463
|
+
FROM message
|
|
464
|
+
WHERE session_id = ? AND time_created > ?
|
|
465
|
+
ORDER BY time_created ASC
|
|
466
|
+
`
|
|
467
|
+
).all(sessionId, state.lastMessageTime);
|
|
468
|
+
if (newMessages.length > 0) {
|
|
469
|
+
const dedupedMessages = newMessages.filter(
|
|
470
|
+
(m) => !state.emittedMessageIds.has(m.id)
|
|
471
|
+
);
|
|
472
|
+
if (dedupedMessages.length > 0) {
|
|
473
|
+
const newMsgIds = dedupedMessages.map((m) => m.id);
|
|
474
|
+
const newParts = batchInQuery(
|
|
475
|
+
db,
|
|
476
|
+
`SELECT id, message_id, session_id, time_created, time_updated, data
|
|
477
|
+
FROM part
|
|
478
|
+
WHERE message_id IN (__PLACEHOLDERS__)
|
|
479
|
+
ORDER BY time_created ASC`,
|
|
480
|
+
newMsgIds
|
|
481
|
+
);
|
|
482
|
+
const partsByMessage = /* @__PURE__ */ new Map();
|
|
483
|
+
for (const part of newParts) {
|
|
484
|
+
if (!partsByMessage.has(part.message_id)) {
|
|
485
|
+
partsByMessage.set(part.message_id, []);
|
|
486
|
+
}
|
|
487
|
+
partsByMessage.get(part.message_id).push(part);
|
|
488
|
+
}
|
|
489
|
+
const newEntries = this.buildMessages(dedupedMessages, partsByMessage);
|
|
490
|
+
results.push(...newEntries);
|
|
491
|
+
for (const part of newParts) {
|
|
492
|
+
if (part.time_updated > state.lastPartTime) {
|
|
493
|
+
state.lastPartTime = part.time_updated;
|
|
494
|
+
}
|
|
495
|
+
state.emittedPartIds.add(part.id);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const msg of newMessages) {
|
|
499
|
+
if (msg.time_created > state.lastMessageTime) {
|
|
500
|
+
state.lastMessageTime = msg.time_created;
|
|
501
|
+
}
|
|
502
|
+
state.emittedMessageIds.add(msg.id);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const updatedParts = db.prepare(
|
|
506
|
+
`
|
|
507
|
+
SELECT id, message_id, session_id, time_created, time_updated, data
|
|
508
|
+
FROM part
|
|
509
|
+
WHERE session_id = ? AND time_updated > ?
|
|
510
|
+
ORDER BY time_created ASC
|
|
511
|
+
`
|
|
512
|
+
).all(sessionId, state.lastPartTime);
|
|
513
|
+
if (updatedParts.length > 0) {
|
|
514
|
+
const newMsgIdSet = new Set(newMessages.map((m) => m.id));
|
|
515
|
+
const newParts = updatedParts.filter(
|
|
516
|
+
(p) => !newMsgIdSet.has(p.message_id) && !state.emittedPartIds.has(p.id)
|
|
517
|
+
);
|
|
518
|
+
if (newParts.length > 0) {
|
|
519
|
+
const partsByMessage = /* @__PURE__ */ new Map();
|
|
520
|
+
for (const part of newParts) {
|
|
521
|
+
if (!partsByMessage.has(part.message_id)) {
|
|
522
|
+
partsByMessage.set(part.message_id, []);
|
|
523
|
+
}
|
|
524
|
+
partsByMessage.get(part.message_id).push(part);
|
|
525
|
+
}
|
|
526
|
+
const affectedMsgIds = [...partsByMessage.keys()];
|
|
527
|
+
const affectedMessages = batchInQuery(
|
|
528
|
+
db,
|
|
529
|
+
`SELECT id, session_id, time_created, time_updated, data
|
|
530
|
+
FROM message
|
|
531
|
+
WHERE id IN (__PLACEHOLDERS__)
|
|
532
|
+
ORDER BY time_created ASC`,
|
|
533
|
+
affectedMsgIds
|
|
534
|
+
);
|
|
535
|
+
const updatedEntries = this.buildMessages(affectedMessages, partsByMessage);
|
|
536
|
+
results.push(...updatedEntries);
|
|
537
|
+
for (const part of newParts) {
|
|
538
|
+
state.emittedPartIds.add(part.id);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const part of updatedParts) {
|
|
542
|
+
if (part.time_updated > state.lastPartTime) {
|
|
543
|
+
state.lastPartTime = part.time_updated;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (results.length > 0) {
|
|
548
|
+
for (const cb of state.callbacks) {
|
|
549
|
+
try {
|
|
550
|
+
cb(results);
|
|
551
|
+
} catch (cbError) {
|
|
552
|
+
console.error("[opencode-watcher] Callback error:", cbError);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error("[opencode-watcher] Poll error:", error);
|
|
558
|
+
} finally {
|
|
559
|
+
db.close();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function batchInQuery(db, sql, ids) {
|
|
564
|
+
if (ids.length === 0) return [];
|
|
565
|
+
const results = [];
|
|
566
|
+
for (let i = 0; i < ids.length; i += SQLITE_MAX_PARAMS) {
|
|
567
|
+
const chunk = ids.slice(i, i + SQLITE_MAX_PARAMS);
|
|
568
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
569
|
+
const query = sql.replace("__PLACEHOLDERS__", placeholders);
|
|
570
|
+
const rows = db.prepare(query).all(...chunk);
|
|
571
|
+
results.push(...rows);
|
|
572
|
+
}
|
|
573
|
+
return results;
|
|
574
|
+
}
|
|
575
|
+
function convertPartToMessagePart(data) {
|
|
576
|
+
switch (data.type) {
|
|
577
|
+
case "reasoning":
|
|
578
|
+
return { content: data.text || "", type: "thinking" };
|
|
579
|
+
case "text":
|
|
580
|
+
return { content: data.text || "", type: "text" };
|
|
581
|
+
case "tool":
|
|
582
|
+
return {
|
|
583
|
+
id: data.callID || data.id || "",
|
|
584
|
+
input: data.state?.input || {},
|
|
585
|
+
toolName: data.tool || "Unknown",
|
|
586
|
+
type: "tool_use"
|
|
587
|
+
};
|
|
588
|
+
default:
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function openDb() {
|
|
593
|
+
if (!fs.existsSync(OPENCODE_DB_PATH)) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
return new Database(OPENCODE_DB_PATH, { readonly: true });
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const OW_GLOBAL_KEY = "__shooter_opencode_watcher";
|
|
603
|
+
const openCodeWatcher = globalThis[OW_GLOBAL_KEY] || new OpenCodeWatcher();
|
|
604
|
+
globalThis[OW_GLOBAL_KEY] = openCodeWatcher;
|
|
605
|
+
path.join(process.env.HOME || "", ".claude", "projects");
|
|
606
|
+
class SessionWatcher {
|
|
607
|
+
// Track assistant turns that span multiple JSONL lines, keyed by filePath
|
|
608
|
+
assistantTurnsPerFile = /* @__PURE__ */ new Map();
|
|
609
|
+
// Buffer for incomplete trailing lines (no terminating newline yet)
|
|
610
|
+
lineBufferPerFile = /* @__PURE__ */ new Map();
|
|
611
|
+
// Track message index per file for generating fallback IDs
|
|
612
|
+
messageIndexPerFile = /* @__PURE__ */ new Map();
|
|
613
|
+
watchedFiles = /* @__PURE__ */ new Map();
|
|
614
|
+
/**
|
|
615
|
+
* Read all entries from a JSONL file from the beginning.
|
|
616
|
+
* Used for catch-up replay when a new client connects mid-session.
|
|
617
|
+
*/
|
|
618
|
+
getHistory(filePath) {
|
|
619
|
+
if (!fs.existsSync(filePath)) {
|
|
620
|
+
return [];
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
624
|
+
const assistantTurns = /* @__PURE__ */ new Map();
|
|
625
|
+
const messages = parseJsonlText(raw, assistantTurns, 0);
|
|
626
|
+
for (const [msgId, turn] of assistantTurns) {
|
|
627
|
+
if (turn.parts.length > 0) {
|
|
628
|
+
messages.push({
|
|
629
|
+
id: msgId,
|
|
630
|
+
parts: turn.parts,
|
|
631
|
+
role: "assistant",
|
|
632
|
+
timestamp: turn.timestamp
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return messages;
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error(`[session-watcher] Failed to read history for ${filePath}:`, error);
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Get raw JSONL entries from a session file (unparsed objects).
|
|
644
|
+
*/
|
|
645
|
+
getRawEntries(filePath) {
|
|
646
|
+
if (!fs.existsSync(filePath)) {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
651
|
+
const entries = [];
|
|
652
|
+
for (const line of raw.split("\n")) {
|
|
653
|
+
const trimmed = line.trim();
|
|
654
|
+
if (!trimmed) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
entries.push(JSON.parse(trimmed));
|
|
659
|
+
} catch {
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return entries;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.error(`[session-watcher] Failed to read raw entries for ${filePath}:`, error);
|
|
665
|
+
return [];
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Stop watching a specific file and clean up resources.
|
|
670
|
+
*/
|
|
671
|
+
stop(filePath) {
|
|
672
|
+
const watched = this.watchedFiles.get(filePath);
|
|
673
|
+
if (!watched) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
watched.watcher.close();
|
|
677
|
+
this.watchedFiles.delete(filePath);
|
|
678
|
+
this.assistantTurnsPerFile.delete(filePath);
|
|
679
|
+
this.messageIndexPerFile.delete(filePath);
|
|
680
|
+
this.lineBufferPerFile.delete(filePath);
|
|
681
|
+
console.log(`[session-watcher] Stopped watching: ${filePath}`);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Stop watching all files and clean up all resources.
|
|
685
|
+
*/
|
|
686
|
+
stopAll() {
|
|
687
|
+
for (const [filePath] of this.watchedFiles) {
|
|
688
|
+
this.stop(filePath);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Subscribe to new JSONL entries for a file. If the file is not yet
|
|
693
|
+
* being watched, starts watching it. Returns an unsubscribe function
|
|
694
|
+
* that removes the callback (and stops the watcher when no subscribers
|
|
695
|
+
* remain). Matches the multi-subscriber pattern used by OpenCodeWatcher.
|
|
696
|
+
*/
|
|
697
|
+
subscribe(filePath, onNewEntries) {
|
|
698
|
+
const existing = this.watchedFiles.get(filePath);
|
|
699
|
+
if (existing) {
|
|
700
|
+
existing.callbacks.add(onNewEntries);
|
|
701
|
+
console.log(
|
|
702
|
+
`[session-watcher] Added subscriber to: ${filePath} (total=${existing.callbacks.size})`
|
|
703
|
+
);
|
|
704
|
+
return () => {
|
|
705
|
+
existing.callbacks.delete(onNewEntries);
|
|
706
|
+
console.log(
|
|
707
|
+
`[session-watcher] Removed subscriber from: ${filePath} (remaining=${existing.callbacks.size})`
|
|
708
|
+
);
|
|
709
|
+
if (existing.callbacks.size === 0) {
|
|
710
|
+
this.stop(filePath);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const initialOffset = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
715
|
+
this.assistantTurnsPerFile.set(filePath, /* @__PURE__ */ new Map());
|
|
716
|
+
this.messageIndexPerFile.set(filePath, 0);
|
|
717
|
+
this.lineBufferPerFile.set(filePath, "");
|
|
718
|
+
const watcher = watch(filePath, {
|
|
719
|
+
// Debounce rapid successive writes
|
|
720
|
+
awaitWriteFinish: {
|
|
721
|
+
pollInterval: 100,
|
|
722
|
+
stabilityThreshold: 200
|
|
723
|
+
},
|
|
724
|
+
// Don't emit 'add' event on initial scan — we handle catch-up via getHistory
|
|
725
|
+
ignoreInitial: true,
|
|
726
|
+
// Use polling as a fallback for network filesystems
|
|
727
|
+
usePolling: false
|
|
728
|
+
});
|
|
729
|
+
const watched = {
|
|
730
|
+
callbacks: /* @__PURE__ */ new Set([onNewEntries]),
|
|
731
|
+
filePath,
|
|
732
|
+
offset: initialOffset,
|
|
733
|
+
watcher
|
|
734
|
+
};
|
|
735
|
+
watcher.on("change", () => {
|
|
736
|
+
this.readNewEntries(watched);
|
|
737
|
+
});
|
|
738
|
+
watcher.on("add", () => {
|
|
739
|
+
this.readNewEntries(watched);
|
|
740
|
+
});
|
|
741
|
+
watcher.on("error", (error) => {
|
|
742
|
+
console.error(`[session-watcher] Error watching ${filePath}:`, error);
|
|
743
|
+
});
|
|
744
|
+
this.watchedFiles.set(filePath, watched);
|
|
745
|
+
console.log(`[session-watcher] Watching: ${filePath} (offset: ${initialOffset})`);
|
|
746
|
+
return () => {
|
|
747
|
+
watched.callbacks.delete(onNewEntries);
|
|
748
|
+
console.log(
|
|
749
|
+
`[session-watcher] Removed subscriber from: ${filePath} (remaining=${watched.callbacks.size})`
|
|
750
|
+
);
|
|
751
|
+
if (watched.callbacks.size === 0) {
|
|
752
|
+
this.stop(filePath);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Start watching a JSONL file for new entries (legacy API).
|
|
758
|
+
* Delegates to subscribe() internally. Callers that need to
|
|
759
|
+
* unsubscribe should use subscribe() directly instead.
|
|
760
|
+
*/
|
|
761
|
+
watch(filePath, onNewEntries) {
|
|
762
|
+
this.subscribe(filePath, onNewEntries);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Read bytes appended since last offset, parse new JSONL lines,
|
|
766
|
+
* and invoke the callback with any new messages.
|
|
767
|
+
*/
|
|
768
|
+
readNewEntries(watched) {
|
|
769
|
+
const { filePath } = watched;
|
|
770
|
+
let stat;
|
|
771
|
+
try {
|
|
772
|
+
stat = fs.statSync(filePath);
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const currentSize = stat.size;
|
|
777
|
+
if (currentSize <= watched.offset) {
|
|
778
|
+
if (currentSize < watched.offset) {
|
|
779
|
+
console.warn(`[session-watcher] File truncated, resetting offset: ${filePath}`);
|
|
780
|
+
watched.offset = 0;
|
|
781
|
+
this.assistantTurnsPerFile.set(filePath, /* @__PURE__ */ new Map());
|
|
782
|
+
this.messageIndexPerFile.set(filePath, 0);
|
|
783
|
+
this.lineBufferPerFile.set(filePath, "");
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const fd = fs.openSync(filePath, "r");
|
|
788
|
+
try {
|
|
789
|
+
const bytesToRead = currentSize - watched.offset;
|
|
790
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
791
|
+
fs.readSync(fd, buffer, 0, bytesToRead, watched.offset);
|
|
792
|
+
watched.offset = currentSize;
|
|
793
|
+
const chunk = buffer.toString("utf-8");
|
|
794
|
+
const previousBuffer = this.lineBufferPerFile.get(filePath) || "";
|
|
795
|
+
const combined = previousBuffer + chunk;
|
|
796
|
+
const segments = combined.split("\n");
|
|
797
|
+
if (!combined.endsWith("\n")) {
|
|
798
|
+
this.lineBufferPerFile.set(filePath, segments.pop() || "");
|
|
799
|
+
} else {
|
|
800
|
+
this.lineBufferPerFile.set(filePath, "");
|
|
801
|
+
if (segments.length > 0 && segments[segments.length - 1] === "") {
|
|
802
|
+
segments.pop();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const completeLines = segments.filter((line) => line.trim());
|
|
806
|
+
if (completeLines.length === 0) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const assistantTurns = this.assistantTurnsPerFile.get(filePath) || /* @__PURE__ */ new Map();
|
|
810
|
+
const startIndex = this.messageIndexPerFile.get(filePath) || 0;
|
|
811
|
+
const newText = completeLines.join("\n");
|
|
812
|
+
const newMessages = parseJsonlText(newText, assistantTurns, startIndex);
|
|
813
|
+
this.messageIndexPerFile.set(filePath, startIndex + completeLines.length);
|
|
814
|
+
if (newMessages.length > 0) {
|
|
815
|
+
for (const cb of watched.callbacks) {
|
|
816
|
+
try {
|
|
817
|
+
cb(newMessages);
|
|
818
|
+
} catch (cbError) {
|
|
819
|
+
console.error("[session-watcher] Callback error:", cbError);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} finally {
|
|
824
|
+
fs.closeSync(fd);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const SW_GLOBAL_KEY = "__shooter_session_watcher";
|
|
829
|
+
const sessionWatcher = globalThis[SW_GLOBAL_KEY] || new SessionWatcher();
|
|
830
|
+
globalThis[SW_GLOBAL_KEY] = sessionWatcher;
|
|
831
|
+
const DB_DIR = path.join(process.env.HOME || "", ".shooter");
|
|
832
|
+
const DB_PATH = path.join(DB_DIR, "shooter.db");
|
|
833
|
+
const COLUMNS = [
|
|
834
|
+
"id",
|
|
835
|
+
"command",
|
|
836
|
+
"args",
|
|
837
|
+
"cwd",
|
|
838
|
+
"cols",
|
|
839
|
+
"rows",
|
|
840
|
+
"pid",
|
|
841
|
+
"holder_pid",
|
|
842
|
+
"socket_path",
|
|
843
|
+
"session_file",
|
|
844
|
+
"opencode_session_id",
|
|
845
|
+
"status",
|
|
846
|
+
"exit_code",
|
|
847
|
+
"created_at",
|
|
848
|
+
"exited_at"
|
|
849
|
+
];
|
|
850
|
+
function rowToRecord(row) {
|
|
851
|
+
return {
|
|
852
|
+
args: row.args,
|
|
853
|
+
cols: row.cols,
|
|
854
|
+
command: row.command,
|
|
855
|
+
createdAt: row.created_at,
|
|
856
|
+
cwd: row.cwd,
|
|
857
|
+
exitCode: row.exit_code ?? null,
|
|
858
|
+
exitedAt: row.exited_at ?? null,
|
|
859
|
+
holderPid: row.holder_pid ?? null,
|
|
860
|
+
id: row.id,
|
|
861
|
+
opencodeSessionId: row.opencode_session_id ?? null,
|
|
862
|
+
pid: row.pid ?? null,
|
|
863
|
+
rows: row.rows,
|
|
864
|
+
sessionFile: row.session_file ?? null,
|
|
865
|
+
socketPath: row.socket_path ?? null,
|
|
866
|
+
status: row.status
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const CAMEL_TO_SNAKE = {
|
|
870
|
+
createdAt: "created_at",
|
|
871
|
+
exitCode: "exit_code",
|
|
872
|
+
exitedAt: "exited_at",
|
|
873
|
+
holderPid: "holder_pid",
|
|
874
|
+
opencodeSessionId: "opencode_session_id",
|
|
875
|
+
sessionFile: "session_file",
|
|
876
|
+
socketPath: "socket_path"
|
|
877
|
+
};
|
|
878
|
+
class TerminalStore {
|
|
879
|
+
db;
|
|
880
|
+
constructor() {
|
|
881
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
882
|
+
this.db = new Database(DB_PATH);
|
|
883
|
+
this.db.pragma("journal_mode = WAL");
|
|
884
|
+
this.db.exec(`
|
|
885
|
+
CREATE TABLE IF NOT EXISTS terminals (
|
|
886
|
+
id TEXT PRIMARY KEY,
|
|
887
|
+
command TEXT NOT NULL,
|
|
888
|
+
args TEXT NOT NULL DEFAULT '[]',
|
|
889
|
+
cwd TEXT NOT NULL,
|
|
890
|
+
cols INTEGER NOT NULL DEFAULT 80,
|
|
891
|
+
rows INTEGER NOT NULL DEFAULT 24,
|
|
892
|
+
pid INTEGER,
|
|
893
|
+
holder_pid INTEGER,
|
|
894
|
+
socket_path TEXT,
|
|
895
|
+
session_file TEXT,
|
|
896
|
+
opencode_session_id TEXT,
|
|
897
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
898
|
+
exit_code INTEGER,
|
|
899
|
+
created_at TEXT NOT NULL,
|
|
900
|
+
exited_at TEXT
|
|
901
|
+
)
|
|
902
|
+
`);
|
|
903
|
+
}
|
|
904
|
+
deleteOlderThan(ms) {
|
|
905
|
+
const cutoff = new Date(Date.now() - ms).toISOString();
|
|
906
|
+
const result = this.db.prepare(
|
|
907
|
+
"DELETE FROM terminals WHERE status IN ('exited', 'orphaned') AND COALESCE(exited_at, created_at) < ?"
|
|
908
|
+
).run(cutoff);
|
|
909
|
+
return result.changes;
|
|
910
|
+
}
|
|
911
|
+
get(id) {
|
|
912
|
+
const row = this.db.prepare("SELECT * FROM terminals WHERE id = ?").get(id);
|
|
913
|
+
return row ? rowToRecord(row) : null;
|
|
914
|
+
}
|
|
915
|
+
insert(terminal) {
|
|
916
|
+
const placeholders = COLUMNS.map(() => "?").join(", ");
|
|
917
|
+
const stmt = this.db.prepare(
|
|
918
|
+
`INSERT INTO terminals (${COLUMNS.join(", ")}) VALUES (${placeholders})`
|
|
919
|
+
);
|
|
920
|
+
stmt.run(
|
|
921
|
+
terminal.id,
|
|
922
|
+
terminal.command,
|
|
923
|
+
terminal.args,
|
|
924
|
+
terminal.cwd,
|
|
925
|
+
terminal.cols,
|
|
926
|
+
terminal.rows,
|
|
927
|
+
terminal.pid,
|
|
928
|
+
terminal.holderPid,
|
|
929
|
+
terminal.socketPath,
|
|
930
|
+
terminal.sessionFile,
|
|
931
|
+
terminal.opencodeSessionId,
|
|
932
|
+
terminal.status,
|
|
933
|
+
terminal.exitCode,
|
|
934
|
+
terminal.createdAt,
|
|
935
|
+
terminal.exitedAt
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
listAll() {
|
|
939
|
+
const rows = this.db.prepare("SELECT * FROM terminals ORDER BY created_at DESC").all();
|
|
940
|
+
return rows.map(rowToRecord);
|
|
941
|
+
}
|
|
942
|
+
listRunning() {
|
|
943
|
+
const rows = this.db.prepare("SELECT * FROM terminals WHERE status = 'running' ORDER BY created_at DESC").all();
|
|
944
|
+
return rows.map(rowToRecord);
|
|
945
|
+
}
|
|
946
|
+
markExited(id, exitCode) {
|
|
947
|
+
this.db.prepare(
|
|
948
|
+
"UPDATE terminals SET status = 'exited', exit_code = ?, exited_at = ? WHERE id = ?"
|
|
949
|
+
).run(exitCode, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
950
|
+
}
|
|
951
|
+
markOrphaned(id) {
|
|
952
|
+
this.db.prepare("UPDATE terminals SET status = 'orphaned' WHERE id = ?").run(id);
|
|
953
|
+
}
|
|
954
|
+
update(id, fields) {
|
|
955
|
+
const entries = Object.entries(fields).filter(([key]) => key !== "id");
|
|
956
|
+
if (entries.length === 0) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const sets = entries.map(([key]) => `${toSnake(key)} = ?`).join(", ");
|
|
960
|
+
const values = entries.map(([, val]) => val ?? null);
|
|
961
|
+
this.db.prepare(`UPDATE terminals SET ${sets} WHERE id = ?`).run(...values, id);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function toSnake(key) {
|
|
965
|
+
return CAMEL_TO_SNAKE[key] || key;
|
|
966
|
+
}
|
|
967
|
+
const TS_GLOBAL_KEY = "__shooter_terminal_store";
|
|
968
|
+
const terminalStore = globalThis[TS_GLOBAL_KEY] || new TerminalStore();
|
|
969
|
+
globalThis[TS_GLOBAL_KEY] = terminalStore;
|
|
970
|
+
const MAX_SCROLLBACK_BYTES = 512 * 1024;
|
|
971
|
+
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024;
|
|
972
|
+
const SCROLLBACK_CHUNK_SIZE = 50 * 1024;
|
|
973
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
974
|
+
const EXITED_TTL_MS = 60 * 60 * 1e3;
|
|
975
|
+
const MAX_EXITED_TERMINALS = 10;
|
|
976
|
+
const SIGKILL_DELAY_MS = 5e3;
|
|
977
|
+
const HOLDER_READY_TIMEOUT_MS = 5e3;
|
|
978
|
+
const DB_CLEANUP_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
979
|
+
const __filename$1 = fileURLToPath(import.meta.url);
|
|
980
|
+
const __dirname$1 = path__default.dirname(__filename$1);
|
|
981
|
+
class PtyManager {
|
|
982
|
+
cleanupTimer = null;
|
|
983
|
+
terminals = /* @__PURE__ */ new Map();
|
|
984
|
+
constructor() {
|
|
985
|
+
this.cleanupTimer = setInterval(() => {
|
|
986
|
+
this.cleanup();
|
|
987
|
+
}, CLEANUP_INTERVAL_MS);
|
|
988
|
+
}
|
|
989
|
+
// -----------------------------------------------------------------------
|
|
990
|
+
// create — now async: forks a holder process, connects via HolderClient,
|
|
991
|
+
// persists to SQLite
|
|
992
|
+
// -----------------------------------------------------------------------
|
|
993
|
+
attach(id, ws) {
|
|
994
|
+
const terminal = this.terminals.get(id);
|
|
995
|
+
if (!terminal) {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
terminal.clients.add(ws);
|
|
999
|
+
terminal.outputBuffers.set(ws, { data: [], size: 0 });
|
|
1000
|
+
this.sendScrollback(terminal, ws);
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
// -----------------------------------------------------------------------
|
|
1004
|
+
// reconnectAll — recover persisted terminals on server startup
|
|
1005
|
+
// -----------------------------------------------------------------------
|
|
1006
|
+
cleanup() {
|
|
1007
|
+
const now = Date.now();
|
|
1008
|
+
const exited = [];
|
|
1009
|
+
for (const [id, terminal] of this.terminals) {
|
|
1010
|
+
if (terminal.status !== "exited") {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const exitTime = terminal.exitedAt?.getTime() ?? terminal.createdAt.getTime();
|
|
1014
|
+
if (now - exitTime > EXITED_TTL_MS) {
|
|
1015
|
+
this.evict(id);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
exited.push({ exitedAt: exitTime, id });
|
|
1019
|
+
}
|
|
1020
|
+
if (exited.length > MAX_EXITED_TERMINALS) {
|
|
1021
|
+
exited.sort((a, b) => a.exitedAt - b.exitedAt);
|
|
1022
|
+
const toEvict = exited.slice(0, exited.length - MAX_EXITED_TERMINALS);
|
|
1023
|
+
for (const { id } of toEvict) {
|
|
1024
|
+
this.evict(id);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const deleted = terminalStore.deleteOlderThan(DB_CLEANUP_TTL_MS);
|
|
1029
|
+
if (deleted > 0) {
|
|
1030
|
+
console.log(`[pty-manager] Cleaned up ${deleted} old terminal record(s) from SQLite`);
|
|
1031
|
+
}
|
|
1032
|
+
} catch {
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// -----------------------------------------------------------------------
|
|
1036
|
+
// disconnectAll — graceful shutdown: disconnect clients, keep holders alive
|
|
1037
|
+
// -----------------------------------------------------------------------
|
|
1038
|
+
async create(command, args, cwd, cols, rows) {
|
|
1039
|
+
const id = randomBytes(4).toString("hex");
|
|
1040
|
+
const socketPath = `/tmp/shooter-term-${id}.sock`;
|
|
1041
|
+
const holderScript = resolveHolderPath();
|
|
1042
|
+
const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
|
|
1043
|
+
const holder = fork(holderScript, holderArgs, {
|
|
1044
|
+
detached: true,
|
|
1045
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"]
|
|
1046
|
+
});
|
|
1047
|
+
holder.unref();
|
|
1048
|
+
await new Promise((resolve, reject) => {
|
|
1049
|
+
const timeout = setTimeout(() => {
|
|
1050
|
+
holder.kill();
|
|
1051
|
+
reject(new Error("Holder ready timeout"));
|
|
1052
|
+
}, HOLDER_READY_TIMEOUT_MS);
|
|
1053
|
+
holder.on("message", (msg) => {
|
|
1054
|
+
if (msg.type === "ready") {
|
|
1055
|
+
clearTimeout(timeout);
|
|
1056
|
+
holder.disconnect();
|
|
1057
|
+
resolve();
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
holder.on("error", (err) => {
|
|
1061
|
+
clearTimeout(timeout);
|
|
1062
|
+
reject(new Error(`Holder process error: ${err.message}`));
|
|
1063
|
+
});
|
|
1064
|
+
holder.on("exit", (code) => {
|
|
1065
|
+
clearTimeout(timeout);
|
|
1066
|
+
reject(new Error(`Holder process exited with code ${code} before ready`));
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
const holderPid = holder.pid;
|
|
1070
|
+
const client = new HolderClient();
|
|
1071
|
+
const connectResult = await client.connect(socketPath);
|
|
1072
|
+
const now = /* @__PURE__ */ new Date();
|
|
1073
|
+
const terminal = {
|
|
1074
|
+
args,
|
|
1075
|
+
clients: /* @__PURE__ */ new Set(),
|
|
1076
|
+
cols,
|
|
1077
|
+
command,
|
|
1078
|
+
createdAt: now,
|
|
1079
|
+
cwd,
|
|
1080
|
+
currentCwd: null,
|
|
1081
|
+
exitCode: connectResult.exitCode,
|
|
1082
|
+
exitedAt: null,
|
|
1083
|
+
holderPid,
|
|
1084
|
+
id,
|
|
1085
|
+
isActive: false,
|
|
1086
|
+
openCodeNoopCb: null,
|
|
1087
|
+
openCodeSessionId: null,
|
|
1088
|
+
outputBuffers: /* @__PURE__ */ new Map(),
|
|
1089
|
+
pid: connectResult.pid,
|
|
1090
|
+
pollTimer: null,
|
|
1091
|
+
pty: client,
|
|
1092
|
+
rows,
|
|
1093
|
+
scrollback: connectResult.scrollback,
|
|
1094
|
+
sessionFile: null,
|
|
1095
|
+
socketPath,
|
|
1096
|
+
status: connectResult.exited ? "exited" : "running",
|
|
1097
|
+
watcherOffset: 0
|
|
1098
|
+
};
|
|
1099
|
+
this.wireHolderCallbacks(client, terminal);
|
|
1100
|
+
terminalStore.insert({
|
|
1101
|
+
args: JSON.stringify(args),
|
|
1102
|
+
cols,
|
|
1103
|
+
command,
|
|
1104
|
+
createdAt: now.toISOString(),
|
|
1105
|
+
cwd,
|
|
1106
|
+
exitCode: null,
|
|
1107
|
+
exitedAt: null,
|
|
1108
|
+
holderPid,
|
|
1109
|
+
id,
|
|
1110
|
+
opencodeSessionId: null,
|
|
1111
|
+
pid: connectResult.pid,
|
|
1112
|
+
rows,
|
|
1113
|
+
sessionFile: null,
|
|
1114
|
+
socketPath,
|
|
1115
|
+
status: "running"
|
|
1116
|
+
});
|
|
1117
|
+
this.startSessionDiscovery(terminal);
|
|
1118
|
+
this.terminals.set(id, terminal);
|
|
1119
|
+
return terminal;
|
|
1120
|
+
}
|
|
1121
|
+
// -----------------------------------------------------------------------
|
|
1122
|
+
// get
|
|
1123
|
+
// -----------------------------------------------------------------------
|
|
1124
|
+
destroy() {
|
|
1125
|
+
if (this.cleanupTimer) {
|
|
1126
|
+
clearInterval(this.cleanupTimer);
|
|
1127
|
+
this.cleanupTimer = null;
|
|
1128
|
+
}
|
|
1129
|
+
for (const [id, terminal] of this.terminals) {
|
|
1130
|
+
if (terminal.pollTimer) {
|
|
1131
|
+
clearInterval(terminal.pollTimer);
|
|
1132
|
+
terminal.pollTimer = null;
|
|
1133
|
+
}
|
|
1134
|
+
if (terminal.status === "running") {
|
|
1135
|
+
try {
|
|
1136
|
+
terminal.pty.kill("SIGTERM");
|
|
1137
|
+
} catch {
|
|
1138
|
+
}
|
|
1139
|
+
try {
|
|
1140
|
+
process.kill(terminal.holderPid, "SIGKILL");
|
|
1141
|
+
} catch {
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
terminal.pty.disconnect();
|
|
1145
|
+
for (const ws of terminal.clients) {
|
|
1146
|
+
try {
|
|
1147
|
+
ws.close();
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
this.terminals.delete(id);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// -----------------------------------------------------------------------
|
|
1155
|
+
// list — running first, then recently exited, each group sorted by
|
|
1156
|
+
// createdAt descending
|
|
1157
|
+
// -----------------------------------------------------------------------
|
|
1158
|
+
detach(id, ws) {
|
|
1159
|
+
const terminal = this.terminals.get(id);
|
|
1160
|
+
if (!terminal) {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
terminal.clients.delete(ws);
|
|
1164
|
+
terminal.outputBuffers.delete(ws);
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
// -----------------------------------------------------------------------
|
|
1168
|
+
// kill — route through holder: SIGTERM, then SIGKILL after 5 s
|
|
1169
|
+
// -----------------------------------------------------------------------
|
|
1170
|
+
disconnectAll() {
|
|
1171
|
+
if (this.cleanupTimer) {
|
|
1172
|
+
clearInterval(this.cleanupTimer);
|
|
1173
|
+
this.cleanupTimer = null;
|
|
1174
|
+
}
|
|
1175
|
+
for (const [, terminal] of this.terminals) {
|
|
1176
|
+
if (terminal.pollTimer) {
|
|
1177
|
+
clearInterval(terminal.pollTimer);
|
|
1178
|
+
terminal.pollTimer = null;
|
|
1179
|
+
}
|
|
1180
|
+
terminal.pty.disconnect();
|
|
1181
|
+
for (const ws of terminal.clients) {
|
|
1182
|
+
try {
|
|
1183
|
+
ws.close();
|
|
1184
|
+
} catch {
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
terminal.clients.clear();
|
|
1188
|
+
terminal.outputBuffers.clear();
|
|
1189
|
+
}
|
|
1190
|
+
this.terminals.clear();
|
|
1191
|
+
}
|
|
1192
|
+
// -----------------------------------------------------------------------
|
|
1193
|
+
// remove — remove an exited terminal from the map
|
|
1194
|
+
// -----------------------------------------------------------------------
|
|
1195
|
+
get(id) {
|
|
1196
|
+
return this.terminals.get(id) ?? null;
|
|
1197
|
+
}
|
|
1198
|
+
// -----------------------------------------------------------------------
|
|
1199
|
+
// resize
|
|
1200
|
+
// -----------------------------------------------------------------------
|
|
1201
|
+
getScrollback(id) {
|
|
1202
|
+
const terminal = this.terminals.get(id);
|
|
1203
|
+
if (!terminal) {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
return terminal.scrollback;
|
|
1207
|
+
}
|
|
1208
|
+
// -----------------------------------------------------------------------
|
|
1209
|
+
// attach — register a WebSocket client and replay scrollback
|
|
1210
|
+
// -----------------------------------------------------------------------
|
|
1211
|
+
kill(id) {
|
|
1212
|
+
const terminal = this.terminals.get(id);
|
|
1213
|
+
if (!terminal) {
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
if (terminal.status === "exited") {
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
terminal.pty.kill("SIGTERM");
|
|
1221
|
+
} catch {
|
|
1222
|
+
terminal.status = "exited";
|
|
1223
|
+
terminal.exitedAt = /* @__PURE__ */ new Date();
|
|
1224
|
+
terminalStore.markExited(id, null);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
setTimeout(() => {
|
|
1228
|
+
if (terminal.status === "running") {
|
|
1229
|
+
try {
|
|
1230
|
+
terminal.pty.kill("SIGKILL");
|
|
1231
|
+
} catch {
|
|
1232
|
+
}
|
|
1233
|
+
terminal.status = "exited";
|
|
1234
|
+
terminal.exitedAt = terminal.exitedAt ?? /* @__PURE__ */ new Date();
|
|
1235
|
+
terminalStore.markExited(id, null);
|
|
1236
|
+
}
|
|
1237
|
+
}, SIGKILL_DELAY_MS);
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
// -----------------------------------------------------------------------
|
|
1241
|
+
// detach — remove a WebSocket client
|
|
1242
|
+
// -----------------------------------------------------------------------
|
|
1243
|
+
list() {
|
|
1244
|
+
const all = Array.from(this.terminals.values());
|
|
1245
|
+
const running = all.filter((t) => t.status === "running").sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
1246
|
+
const exited = all.filter((t) => t.status === "exited").sort((a, b) => {
|
|
1247
|
+
const aTime = a.exitedAt?.getTime() ?? a.createdAt.getTime();
|
|
1248
|
+
const bTime = b.exitedAt?.getTime() ?? b.createdAt.getTime();
|
|
1249
|
+
return bTime - aTime;
|
|
1250
|
+
});
|
|
1251
|
+
return [...running, ...exited];
|
|
1252
|
+
}
|
|
1253
|
+
// -----------------------------------------------------------------------
|
|
1254
|
+
// getScrollback — return raw scrollback data for replay
|
|
1255
|
+
// -----------------------------------------------------------------------
|
|
1256
|
+
async reconnectAll() {
|
|
1257
|
+
const running = terminalStore.listRunning();
|
|
1258
|
+
if (running.length === 0) {
|
|
1259
|
+
console.log("[pty-manager] No persisted terminals to reconnect");
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
console.log(`[pty-manager] Reconnecting to ${running.length} persisted terminal(s)...`);
|
|
1263
|
+
for (const record of running) {
|
|
1264
|
+
try {
|
|
1265
|
+
await this.reconnectOne(record);
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1268
|
+
console.warn(`[pty-manager] Failed to reconnect terminal ${record.id}: ${errMsg}`);
|
|
1269
|
+
this.handleReconnectFailure(record);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
// -----------------------------------------------------------------------
|
|
1274
|
+
// cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
|
|
1275
|
+
// also clean up old SQLite records
|
|
1276
|
+
// -----------------------------------------------------------------------
|
|
1277
|
+
remove(id) {
|
|
1278
|
+
const terminal = this.terminals.get(id);
|
|
1279
|
+
if (!terminal) {
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
if (terminal.status === "running") {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
this.evict(id);
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
// -----------------------------------------------------------------------
|
|
1289
|
+
// destroy — emergency forced kill (kills holder processes too)
|
|
1290
|
+
// -----------------------------------------------------------------------
|
|
1291
|
+
resize(id, cols, rows) {
|
|
1292
|
+
const terminal = this.terminals.get(id);
|
|
1293
|
+
if (!terminal || terminal.status === "exited") {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
terminal.pty.resize(cols, rows);
|
|
1298
|
+
terminal.cols = cols;
|
|
1299
|
+
terminal.rows = rows;
|
|
1300
|
+
return true;
|
|
1301
|
+
} catch {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// -----------------------------------------------------------------------
|
|
1306
|
+
// Private: reconnectOne — reconnect to a single persisted terminal
|
|
1307
|
+
// -----------------------------------------------------------------------
|
|
1308
|
+
appendScrollback(terminal, data) {
|
|
1309
|
+
terminal.scrollback += data;
|
|
1310
|
+
if (Buffer.byteLength(terminal.scrollback, "utf8") > MAX_SCROLLBACK_BYTES) {
|
|
1311
|
+
const mid = Math.floor(terminal.scrollback.length / 2);
|
|
1312
|
+
const newlineIdx = terminal.scrollback.indexOf("\n", mid);
|
|
1313
|
+
if (newlineIdx !== -1) {
|
|
1314
|
+
terminal.scrollback = terminal.scrollback.slice(newlineIdx + 1);
|
|
1315
|
+
} else {
|
|
1316
|
+
terminal.scrollback = terminal.scrollback.slice(mid);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// -----------------------------------------------------------------------
|
|
1321
|
+
// Private: handleReconnectFailure — handle failed reconnection
|
|
1322
|
+
// -----------------------------------------------------------------------
|
|
1323
|
+
broadcastOutput(terminal, data) {
|
|
1324
|
+
const msg = JSON.stringify({ data, type: "output" });
|
|
1325
|
+
for (const ws of terminal.clients) {
|
|
1326
|
+
if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
|
|
1327
|
+
this.safeSend(ws, JSON.stringify({ bytes: data.length, type: "output-dropped" }));
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
1331
|
+
if (!buffer) {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const msgSize = Buffer.byteLength(msg, "utf8");
|
|
1335
|
+
if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
|
|
1336
|
+
let droppedBytes = 0;
|
|
1337
|
+
while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
|
|
1338
|
+
const dropped = buffer.data.shift();
|
|
1339
|
+
if (dropped) {
|
|
1340
|
+
const droppedSize = Buffer.byteLength(dropped, "utf8");
|
|
1341
|
+
buffer.size -= droppedSize;
|
|
1342
|
+
droppedBytes += droppedSize;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (droppedBytes > 0) {
|
|
1346
|
+
const dropMsg = JSON.stringify({
|
|
1347
|
+
bytes: droppedBytes,
|
|
1348
|
+
type: "output-dropped"
|
|
1349
|
+
});
|
|
1350
|
+
this.safeSend(ws, dropMsg);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
buffer.data.push(msg);
|
|
1354
|
+
buffer.size += msgSize;
|
|
1355
|
+
this.flushOutputBuffer(ws, buffer);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
// -----------------------------------------------------------------------
|
|
1359
|
+
// Private: startSessionDiscovery — polling for session files
|
|
1360
|
+
// -----------------------------------------------------------------------
|
|
1361
|
+
/** Evict a terminal, freeing all resources. */
|
|
1362
|
+
evict(id) {
|
|
1363
|
+
const terminal = this.terminals.get(id);
|
|
1364
|
+
if (!terminal) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (terminal.pollTimer) {
|
|
1368
|
+
clearInterval(terminal.pollTimer);
|
|
1369
|
+
terminal.pollTimer = null;
|
|
1370
|
+
}
|
|
1371
|
+
if (terminal.openCodeSessionId && terminal.openCodeNoopCb) {
|
|
1372
|
+
openCodeWatcher.stop(terminal.openCodeSessionId, terminal.openCodeNoopCb);
|
|
1373
|
+
terminal.openCodeNoopCb = null;
|
|
1374
|
+
}
|
|
1375
|
+
terminal.pty.disconnect();
|
|
1376
|
+
for (const ws of terminal.clients) {
|
|
1377
|
+
try {
|
|
1378
|
+
ws.close();
|
|
1379
|
+
} catch {
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
terminal.clients.clear();
|
|
1383
|
+
terminal.outputBuffers.clear();
|
|
1384
|
+
terminal.scrollback = "";
|
|
1385
|
+
this.terminals.delete(id);
|
|
1386
|
+
}
|
|
1387
|
+
// -----------------------------------------------------------------------
|
|
1388
|
+
// Private: appendScrollback — append to cached scrollback string,
|
|
1389
|
+
// trim from midpoint when cap exceeded
|
|
1390
|
+
// -----------------------------------------------------------------------
|
|
1391
|
+
/** Attempt to flush buffered messages to a WebSocket client. */
|
|
1392
|
+
flushOutputBuffer(ws, buffer) {
|
|
1393
|
+
while (buffer.data.length > 0) {
|
|
1394
|
+
const msg = buffer.data[0];
|
|
1395
|
+
if (!this.safeSend(ws, msg)) {
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
buffer.data.shift();
|
|
1399
|
+
buffer.size -= Buffer.byteLength(msg, "utf8");
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// -----------------------------------------------------------------------
|
|
1403
|
+
// Private: broadcastOutput — send output to all connected WS clients
|
|
1404
|
+
// with backpressure management
|
|
1405
|
+
// -----------------------------------------------------------------------
|
|
1406
|
+
handleReconnectFailure(record) {
|
|
1407
|
+
if (record.holderPid) {
|
|
1408
|
+
try {
|
|
1409
|
+
process.kill(record.holderPid, 0);
|
|
1410
|
+
console.warn(
|
|
1411
|
+
`[pty-manager] Holder PID ${record.holderPid} alive but socket dead for ${record.id}`
|
|
1412
|
+
);
|
|
1413
|
+
} catch {
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (record.socketPath) {
|
|
1417
|
+
const exitFilePath = `${record.socketPath}.exit`;
|
|
1418
|
+
if (existsSync(exitFilePath)) {
|
|
1419
|
+
try {
|
|
1420
|
+
const exitData = JSON.parse(readFileSync(exitFilePath, "utf8"));
|
|
1421
|
+
unlinkSync(exitFilePath);
|
|
1422
|
+
terminalStore.markExited(record.id, exitData.code);
|
|
1423
|
+
console.log(
|
|
1424
|
+
`[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
|
|
1425
|
+
);
|
|
1426
|
+
return;
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
terminalStore.markOrphaned(record.id);
|
|
1432
|
+
console.log(`[pty-manager] Marked terminal ${record.id} as orphaned`);
|
|
1433
|
+
}
|
|
1434
|
+
async reconnectOne(record) {
|
|
1435
|
+
if (!record.socketPath) {
|
|
1436
|
+
throw new Error("No socket path stored");
|
|
1437
|
+
}
|
|
1438
|
+
const exitFilePath = `${record.socketPath}.exit`;
|
|
1439
|
+
if (existsSync(exitFilePath)) {
|
|
1440
|
+
try {
|
|
1441
|
+
const exitData = JSON.parse(readFileSync(exitFilePath, "utf8"));
|
|
1442
|
+
unlinkSync(exitFilePath);
|
|
1443
|
+
terminalStore.markExited(record.id, exitData.code);
|
|
1444
|
+
console.log(
|
|
1445
|
+
`[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
|
|
1446
|
+
);
|
|
1447
|
+
return;
|
|
1448
|
+
} catch {
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const client = new HolderClient();
|
|
1452
|
+
const connectResult = await client.connect(record.socketPath);
|
|
1453
|
+
let parsedArgs = [];
|
|
1454
|
+
try {
|
|
1455
|
+
parsedArgs = JSON.parse(record.args);
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1458
|
+
const terminal = {
|
|
1459
|
+
args: parsedArgs,
|
|
1460
|
+
clients: /* @__PURE__ */ new Set(),
|
|
1461
|
+
cols: record.cols,
|
|
1462
|
+
command: record.command,
|
|
1463
|
+
createdAt: new Date(record.createdAt),
|
|
1464
|
+
currentCwd: null,
|
|
1465
|
+
cwd: record.cwd,
|
|
1466
|
+
exitCode: connectResult.exitCode,
|
|
1467
|
+
exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
|
|
1468
|
+
holderPid: record.holderPid ?? 0,
|
|
1469
|
+
id: record.id,
|
|
1470
|
+
isActive: false,
|
|
1471
|
+
openCodeNoopCb: null,
|
|
1472
|
+
openCodeSessionId: record.opencodeSessionId ?? null,
|
|
1473
|
+
outputBuffers: /* @__PURE__ */ new Map(),
|
|
1474
|
+
pid: connectResult.pid,
|
|
1475
|
+
pollTimer: null,
|
|
1476
|
+
pty: client,
|
|
1477
|
+
rows: record.rows,
|
|
1478
|
+
scrollback: connectResult.scrollback,
|
|
1479
|
+
sessionFile: record.sessionFile ?? null,
|
|
1480
|
+
socketPath: record.socketPath,
|
|
1481
|
+
status: connectResult.exited ? "exited" : "running",
|
|
1482
|
+
watcherOffset: 0
|
|
1483
|
+
};
|
|
1484
|
+
if (connectResult.exited) {
|
|
1485
|
+
terminal.exitedAt = terminal.exitedAt ?? /* @__PURE__ */ new Date();
|
|
1486
|
+
terminalStore.markExited(record.id, connectResult.exitCode);
|
|
1487
|
+
}
|
|
1488
|
+
this.wireHolderCallbacks(client, terminal);
|
|
1489
|
+
if (terminal.sessionFile) ;
|
|
1490
|
+
if (terminal.openCodeSessionId) {
|
|
1491
|
+
const noopCb = () => {
|
|
1492
|
+
};
|
|
1493
|
+
terminal.openCodeNoopCb = noopCb;
|
|
1494
|
+
openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
|
|
1495
|
+
}
|
|
1496
|
+
if (!terminal.sessionFile && !terminal.openCodeSessionId && terminal.status === "running") {
|
|
1497
|
+
this.startSessionDiscovery(terminal);
|
|
1498
|
+
}
|
|
1499
|
+
if (record.pid !== connectResult.pid) {
|
|
1500
|
+
terminalStore.update(record.id, { pid: connectResult.pid });
|
|
1501
|
+
}
|
|
1502
|
+
this.terminals.set(record.id, terminal);
|
|
1503
|
+
console.log(
|
|
1504
|
+
`[pty-manager] Reconnected terminal ${record.id} (pid=${connectResult.pid}, holder=${record.holderPid}, status=${terminal.status})`
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
/** Wire up all HolderClient callbacks (activity, CWD, output, exit, disconnect). */
|
|
1508
|
+
wireHolderCallbacks(client, terminal) {
|
|
1509
|
+
client.onActivity((active) => {
|
|
1510
|
+
terminal.isActive = active;
|
|
1511
|
+
const msg = JSON.stringify({ active, type: "activity" });
|
|
1512
|
+
for (const ws of terminal.clients) {
|
|
1513
|
+
this.safeSend(ws, msg);
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
client.onCwd((path2) => {
|
|
1517
|
+
terminal.currentCwd = path2;
|
|
1518
|
+
const msg = JSON.stringify({ path: path2, type: "cwd" });
|
|
1519
|
+
for (const ws of terminal.clients) {
|
|
1520
|
+
this.safeSend(ws, msg);
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
client.onOutput((data) => {
|
|
1524
|
+
this.appendScrollback(terminal, data);
|
|
1525
|
+
this.broadcastOutput(terminal, data);
|
|
1526
|
+
});
|
|
1527
|
+
client.onExit((exitCode) => {
|
|
1528
|
+
terminal.status = "exited";
|
|
1529
|
+
terminal.exitCode = exitCode;
|
|
1530
|
+
terminal.exitedAt = /* @__PURE__ */ new Date();
|
|
1531
|
+
terminalStore.markExited(terminal.id, exitCode);
|
|
1532
|
+
const exitMsg = JSON.stringify({
|
|
1533
|
+
code: exitCode,
|
|
1534
|
+
signal: null,
|
|
1535
|
+
type: "exit"
|
|
1536
|
+
});
|
|
1537
|
+
for (const ws of terminal.clients) {
|
|
1538
|
+
this.safeSend(ws, exitMsg);
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
client.onDisconnect(() => {
|
|
1542
|
+
if (terminal.status === "running") {
|
|
1543
|
+
console.warn(`[pty-manager] Holder disconnected unexpectedly for terminal ${terminal.id}`);
|
|
1544
|
+
terminal.status = "exited";
|
|
1545
|
+
terminal.exitedAt = /* @__PURE__ */ new Date();
|
|
1546
|
+
terminalStore.markOrphaned(terminal.id);
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
/** Safely send a message to a WebSocket, returning false on failure. */
|
|
1551
|
+
safeSend(ws, msg) {
|
|
1552
|
+
try {
|
|
1553
|
+
if (ws.readyState !== 1) {
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
ws.send(msg);
|
|
1557
|
+
return true;
|
|
1558
|
+
} catch {
|
|
1559
|
+
return false;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/** Send cached scrollback data to a newly connected client in 50 KB chunks. */
|
|
1563
|
+
async sendScrollback(terminal, ws) {
|
|
1564
|
+
const fullData = terminal.scrollback;
|
|
1565
|
+
if (fullData.length === 0) {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const totalBytes = Buffer.byteLength(fullData, "utf8");
|
|
1569
|
+
const totalChunks = Math.ceil(totalBytes / SCROLLBACK_CHUNK_SIZE);
|
|
1570
|
+
if (totalChunks <= 1) {
|
|
1571
|
+
const msg = JSON.stringify({
|
|
1572
|
+
chunk: 1,
|
|
1573
|
+
data: fullData,
|
|
1574
|
+
total: 1,
|
|
1575
|
+
type: "scrollback"
|
|
1576
|
+
});
|
|
1577
|
+
this.safeSend(ws, msg);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const buf = Buffer.from(fullData, "utf8");
|
|
1581
|
+
let offset = 0;
|
|
1582
|
+
let chunkIndex = 1;
|
|
1583
|
+
while (offset < buf.length) {
|
|
1584
|
+
if (ws.bufferedAmount > SCROLLBACK_CHUNK_SIZE * 2) {
|
|
1585
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1586
|
+
}
|
|
1587
|
+
const end = Math.min(offset + SCROLLBACK_CHUNK_SIZE, buf.length);
|
|
1588
|
+
const chunkData = buf.subarray(offset, end).toString("utf8");
|
|
1589
|
+
const msg = JSON.stringify({
|
|
1590
|
+
chunk: chunkIndex,
|
|
1591
|
+
data: chunkData,
|
|
1592
|
+
total: totalChunks,
|
|
1593
|
+
type: "scrollback"
|
|
1594
|
+
});
|
|
1595
|
+
this.safeSend(ws, msg);
|
|
1596
|
+
offset = end;
|
|
1597
|
+
chunkIndex++;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
startSessionDiscovery(terminal) {
|
|
1601
|
+
const { command, cwd, id } = terminal;
|
|
1602
|
+
if (command === "claude") {
|
|
1603
|
+
const projectDir = path__default.join(
|
|
1604
|
+
process.env.HOME || "",
|
|
1605
|
+
".claude",
|
|
1606
|
+
"projects",
|
|
1607
|
+
cwd.replace(/\//g, "-")
|
|
1608
|
+
);
|
|
1609
|
+
const launchTime = terminal.createdAt.getTime();
|
|
1610
|
+
terminal.pollTimer = setInterval(() => {
|
|
1611
|
+
if (terminal.status === "exited" || terminal.sessionFile) {
|
|
1612
|
+
if (terminal.pollTimer) {
|
|
1613
|
+
clearInterval(terminal.pollTimer);
|
|
1614
|
+
terminal.pollTimer = null;
|
|
1615
|
+
}
|
|
1616
|
+
if (terminal.sessionFile) ;
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
try {
|
|
1620
|
+
if (!existsSync(projectDir)) {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
const files = readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => {
|
|
1624
|
+
const stat = statSync(path__default.join(projectDir, f));
|
|
1625
|
+
return {
|
|
1626
|
+
birthtime: stat.birthtimeMs,
|
|
1627
|
+
fullPath: path__default.join(projectDir, f),
|
|
1628
|
+
mtime: stat.mtimeMs,
|
|
1629
|
+
name: f
|
|
1630
|
+
};
|
|
1631
|
+
}).filter((f) => f.birthtime > launchTime).sort((a, b) => b.birthtime - a.birthtime);
|
|
1632
|
+
if (files.length > 0) {
|
|
1633
|
+
terminal.sessionFile = files[0].fullPath;
|
|
1634
|
+
if (terminal.pollTimer) {
|
|
1635
|
+
clearInterval(terminal.pollTimer);
|
|
1636
|
+
terminal.pollTimer = null;
|
|
1637
|
+
}
|
|
1638
|
+
terminalStore.update(id, { sessionFile: terminal.sessionFile });
|
|
1639
|
+
}
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
}, 1500);
|
|
1643
|
+
setTimeout(() => {
|
|
1644
|
+
if (terminal.pollTimer) {
|
|
1645
|
+
clearInterval(terminal.pollTimer);
|
|
1646
|
+
terminal.pollTimer = null;
|
|
1647
|
+
}
|
|
1648
|
+
}, 5 * 60 * 1e3);
|
|
1649
|
+
}
|
|
1650
|
+
if (command === "opencode") {
|
|
1651
|
+
const launchTime = terminal.createdAt.getTime();
|
|
1652
|
+
const pollInterval = setInterval(() => {
|
|
1653
|
+
if (terminal.status === "exited" || terminal.openCodeSessionId) {
|
|
1654
|
+
clearInterval(pollInterval);
|
|
1655
|
+
if (terminal.openCodeSessionId) {
|
|
1656
|
+
const noopCb = () => {
|
|
1657
|
+
};
|
|
1658
|
+
terminal.openCodeNoopCb = noopCb;
|
|
1659
|
+
openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
|
|
1660
|
+
}
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const sessionId = openCodeWatcher.findSessionId(cwd, launchTime);
|
|
1664
|
+
if (sessionId) {
|
|
1665
|
+
terminal.openCodeSessionId = sessionId;
|
|
1666
|
+
clearInterval(pollInterval);
|
|
1667
|
+
const noopCb = () => {
|
|
1668
|
+
};
|
|
1669
|
+
terminal.openCodeNoopCb = noopCb;
|
|
1670
|
+
openCodeWatcher.watch(sessionId, noopCb);
|
|
1671
|
+
terminalStore.update(id, { opencodeSessionId: sessionId });
|
|
1672
|
+
}
|
|
1673
|
+
}, 2e3);
|
|
1674
|
+
terminal.pollTimer = pollInterval;
|
|
1675
|
+
setTimeout(() => {
|
|
1676
|
+
clearInterval(pollInterval);
|
|
1677
|
+
terminal.pollTimer = null;
|
|
1678
|
+
}, 5 * 60 * 1e3);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function resolveHolderPath() {
|
|
1683
|
+
if (process.env.SHOOTER_HOLDER_PATH) {
|
|
1684
|
+
return process.env.SHOOTER_HOLDER_PATH;
|
|
1685
|
+
}
|
|
1686
|
+
const colocated = path__default.join(__dirname$1, "pty-holder.cjs");
|
|
1687
|
+
if (existsSync(colocated)) {
|
|
1688
|
+
return colocated;
|
|
1689
|
+
}
|
|
1690
|
+
return path__default.resolve(__dirname$1, "..", "..", "pty-holder.cjs");
|
|
1691
|
+
}
|
|
1692
|
+
const PTY_GLOBAL_KEY = "__shooter_pty_manager";
|
|
1693
|
+
const ptyManager = globalThis[PTY_GLOBAL_KEY] || new PtyManager();
|
|
1694
|
+
globalThis[PTY_GLOBAL_KEY] = ptyManager;
|
|
1695
|
+
|
|
1696
|
+
export { ptyManager as p };
|
|
1697
|
+
//# sourceMappingURL=pty-manager-C0FhBiVq.js.map
|