@juspay/shooter 1.21.0 → 1.23.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 +94 -1
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css +1 -0
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css.br +0 -0
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css.gz +0 -0
- package/build/client/_app/immutable/chunks/{DOEXXmsh.js → Bj5wFimK.js} +2 -2
- package/build/client/_app/immutable/chunks/Bj5wFimK.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bj5wFimK.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{EqMAkEha.js → BjYr_-Ss.js} +1 -1
- package/build/client/_app/immutable/chunks/BjYr_-Ss.js.br +0 -0
- package/build/client/_app/immutable/chunks/BjYr_-Ss.js.gz +0 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js +1 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js.br +0 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DULfdsh6.js +6 -0
- package/build/client/_app/immutable/chunks/DULfdsh6.js.br +0 -0
- package/build/client/_app/immutable/chunks/DULfdsh6.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{BmfLecb1.js → fcNfTA-E.js} +1 -1
- package/build/client/_app/immutable/chunks/fcNfTA-E.js.br +0 -0
- package/build/client/_app/immutable/chunks/fcNfTA-E.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.CeSxgGat.js → app.Bvoqymnp.js} +2 -2
- package/build/client/_app/immutable/entry/app.Bvoqymnp.js.br +0 -0
- package/build/client/_app/immutable/entry/app.Bvoqymnp.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BqXCPPZJ.js +1 -0
- package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.br +2 -0
- package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.oaPwxh1O.js → 0.Bv_TwEnq.js} +1 -1
- package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.DMPyoM-M.js → 1.7lffTIeb.js} +1 -1
- package/build/client/_app/immutable/nodes/1.7lffTIeb.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.7lffTIeb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{10.Cbm7nQKK.js → 10.ChiIrIDl.js} +1 -1
- package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js → 11.DO3vyXEv.js} +2 -2
- package/build/client/_app/immutable/nodes/11.DO3vyXEv.js.br +0 -0
- package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js.gz → 11.DO3vyXEv.js.gz} +0 -0
- package/build/client/_app/immutable/nodes/2.iMIqsE7n.js +23 -0
- package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.BgLpGnzb.js → 3.CArnSHOO.js} +1 -1
- package/build/client/_app/immutable/nodes/3.CArnSHOO.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.CArnSHOO.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{5.Avc1-gVb.js → 5.DziEu9rx.js} +1 -1
- package/build/client/_app/immutable/nodes/5.DziEu9rx.js.br +0 -0
- package/build/client/_app/immutable/nodes/5.DziEu9rx.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.Dw2wEssJ.js → 6.B8l1RwkB.js} +1 -1
- package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.DwKZjoBg.js → 7.BPyfhDis.js} +1 -1
- package/build/client/_app/immutable/nodes/7.BPyfhDis.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.BPyfhDis.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.ZUAI6g5E.js → 8.D_vszZ9E.js} +1 -1
- package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.I_KGXPwB.js → 9.Drah-do-.js} +1 -1
- package/build/client/_app/immutable/nodes/9.Drah-do-.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.Drah-do-.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/pty-holder.cjs +6 -0
- package/build/server/chunks/{0-vrTNAfZB.js → 0-DAB_6Vm1.js} +2 -2
- package/build/server/chunks/{0-vrTNAfZB.js.map → 0-DAB_6Vm1.js.map} +1 -1
- package/build/server/chunks/{1-nbr-bOoF.js → 1-D-qMYaCx.js} +2 -2
- package/build/server/chunks/{1-nbr-bOoF.js.map → 1-D-qMYaCx.js.map} +1 -1
- package/build/server/chunks/{10-ChyvvJ6w.js → 10-CeFFGo-X.js} +2 -2
- package/build/server/chunks/{10-ChyvvJ6w.js.map → 10-CeFFGo-X.js.map} +1 -1
- package/build/server/chunks/{11-6ZAjL3uU.js → 11-DRMu_ATU.js} +2 -2
- package/build/server/chunks/{11-6ZAjL3uU.js.map → 11-DRMu_ATU.js.map} +1 -1
- package/build/server/chunks/{2-DWFRVDWJ.js → 2-B7OLBMNH.js} +4 -4
- package/build/server/chunks/{2-DWFRVDWJ.js.map → 2-B7OLBMNH.js.map} +1 -1
- package/build/server/chunks/{3-CKANM_WM.js → 3-B38ZarLw.js} +2 -2
- package/build/server/chunks/{3-CKANM_WM.js.map → 3-B38ZarLw.js.map} +1 -1
- package/build/server/chunks/{5-BxVjs2qi.js → 5-D-Uv1voC.js} +2 -2
- package/build/server/chunks/{5-BxVjs2qi.js.map → 5-D-Uv1voC.js.map} +1 -1
- package/build/server/chunks/{6-Cbf1AAMQ.js → 6-DP46cUej.js} +2 -2
- package/build/server/chunks/{6-Cbf1AAMQ.js.map → 6-DP46cUej.js.map} +1 -1
- package/build/server/chunks/{7-CMK2quEf.js → 7-B29_3ar6.js} +2 -2
- package/build/server/chunks/{7-CMK2quEf.js.map → 7-B29_3ar6.js.map} +1 -1
- package/build/server/chunks/{8-DhdfkfDM.js → 8-DCnSDVrX.js} +2 -2
- package/build/server/chunks/{8-DhdfkfDM.js.map → 8-DCnSDVrX.js.map} +1 -1
- package/build/server/chunks/{9-CPpxtRM5.js → 9-BwqDc8wC.js} +2 -2
- package/build/server/chunks/{9-CPpxtRM5.js.map → 9-BwqDc8wC.js.map} +1 -1
- package/build/server/chunks/_page.svelte-8OFzwdNA.js +758 -0
- package/build/server/chunks/_page.svelte-8OFzwdNA.js.map +1 -0
- package/build/server/chunks/{_server.ts-BWVlO8iV.js → _server.ts-05JJOdcX.js} +15 -12
- package/build/server/chunks/_server.ts-05JJOdcX.js.map +1 -0
- package/build/server/chunks/{_server.ts-BevnuePu.js → _server.ts-BCljU9Sg.js} +7 -3
- package/build/server/chunks/_server.ts-BCljU9Sg.js.map +1 -0
- package/build/server/chunks/{_server.ts-D-vgx5UZ.js → _server.ts-BTmknWpO.js} +2 -2
- package/build/server/chunks/{_server.ts-D-vgx5UZ.js.map → _server.ts-BTmknWpO.js.map} +1 -1
- package/build/server/chunks/{_server.ts-tChyh9FX.js → _server.ts-BXhmLZwN.js} +4 -2
- package/build/server/chunks/{_server.ts-tChyh9FX.js.map → _server.ts-BXhmLZwN.js.map} +1 -1
- package/build/server/chunks/{_server.ts-CvJKTS3Z.js → _server.ts-BbRSpB74.js} +4 -2
- package/build/server/chunks/{_server.ts-CvJKTS3Z.js.map → _server.ts-BbRSpB74.js.map} +1 -1
- package/build/server/chunks/{_server.ts-CC2K8-L2.js → _server.ts-Blx6TuRU.js} +4 -2
- package/build/server/chunks/_server.ts-Blx6TuRU.js.map +1 -0
- package/build/server/chunks/_server.ts-C6NRpe7e.js +33 -0
- package/build/server/chunks/_server.ts-C6NRpe7e.js.map +1 -0
- package/build/server/chunks/_server.ts-CGqCOCdK.js +53 -0
- package/build/server/chunks/_server.ts-CGqCOCdK.js.map +1 -0
- package/build/server/chunks/{_server.ts-X1R7L_QI.js → _server.ts-CYWXjihn.js} +4 -2
- package/build/server/chunks/{_server.ts-X1R7L_QI.js.map → _server.ts-CYWXjihn.js.map} +1 -1
- package/build/server/chunks/{_server.ts-CD7JP3fz.js → _server.ts-D0___krA.js} +4 -2
- package/build/server/chunks/_server.ts-D0___krA.js.map +1 -0
- package/build/server/chunks/{_server.ts-VzDcFFgy.js → _server.ts-DPHRUFYS.js} +4 -2
- package/build/server/chunks/_server.ts-DPHRUFYS.js.map +1 -0
- package/build/server/chunks/{_server.ts-D0zRDSx0.js → _server.ts-D_WRex0k.js} +4 -2
- package/build/server/chunks/_server.ts-D_WRex0k.js.map +1 -0
- package/build/server/chunks/{_server.ts-CA5KUENM.js → _server.ts-Da1kSClZ.js} +4 -2
- package/build/server/chunks/_server.ts-Da1kSClZ.js.map +1 -0
- package/build/server/chunks/{_server.ts-Dp-hXW_I.js → _server.ts-l3cd4Cto.js} +4 -2
- package/build/server/chunks/_server.ts-l3cd4Cto.js.map +1 -0
- package/build/server/chunks/{library-apns-Dl3iRE2h.js → library-apns-D8RPINlv.js} +62 -7
- package/build/server/chunks/library-apns-D8RPINlv.js.map +1 -0
- package/build/server/chunks/{pending-requests-C9p57WoU.js → pending-requests-8rWjrF6d.js} +3 -2
- package/build/server/chunks/pending-requests-8rWjrF6d.js.map +1 -0
- package/build/server/chunks/presence-store-Bx_g0-Gd.js +23 -0
- package/build/server/chunks/presence-store-Bx_g0-Gd.js.map +1 -0
- package/build/server/chunks/{pty-manager-ZqXqa-6A.js → pty-manager-DDjG7DlH.js} +297 -31
- package/build/server/chunks/pty-manager-DDjG7DlH.js.map +1 -0
- package/build/server/chunks/shooter-home-4f_HkdGI.js +10 -0
- package/build/server/chunks/shooter-home-4f_HkdGI.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +38 -24
- package/build/server/manifest.js.map +1 -1
- package/package.json +4 -2
- package/server.ts +2 -2
- package/src/lib/modules/client/common/index.ts +1 -0
- package/src/lib/modules/client/common/presence.ts +47 -0
- package/src/lib/modules/client/dashboard/AutopilotPanel.svelte +188 -4
- package/src/lib/modules/client/dashboard/autopilot-driver.svelte.ts +681 -0
- package/src/lib/modules/client/dashboard/decide-injection.ts +127 -0
- package/src/lib/modules/client/dashboard/store.svelte.ts +65 -24
- package/src/lib/modules/client/neurolink/fetch-proxy.ts +38 -1
- package/src/lib/modules/client/terminal/xterm-wrapper.ts +52 -12
- package/src/lib/modules/server/apn/apns-payload.ts +50 -0
- package/src/lib/modules/server/apn/library-apns.ts +50 -8
- package/src/lib/modules/server/apn/pending-requests.ts +3 -1
- package/src/lib/modules/server/sessions/autopilot-context.ts +57 -0
- package/src/lib/modules/server/sessions/autopilot-engine.ts +148 -43
- package/src/lib/modules/server/sessions/litellm-client.ts +90 -34
- package/src/lib/modules/server/sessions/next-step-consensus.ts +27 -2
- package/src/lib/modules/server/sessions/summary-store.ts +3 -1
- package/src/lib/modules/server/terminal/agent-launch.ts +26 -0
- package/src/lib/modules/server/terminal/pty-holder.cjs +6 -0
- package/src/lib/modules/server/terminal/pty-manager.ts +292 -38
- package/src/lib/modules/server/terminal/session-watcher.ts +12 -2
- package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -0
- package/src/lib/modules/server/terminal/terminal-store.ts +3 -1
- package/src/lib/modules/server/utils/shooter-home.ts +16 -0
- package/src/lib/modules/server/ws/presence-store.ts +50 -0
- package/src/lib/modules/server/ws/server.ts +18 -2
- package/src/lib/modules/server/ws/terminal-handler.ts +11 -6
- package/src/lib/types/autopilot.ts +65 -0
- package/src/lib/types/generated/WsProtocol.ts +10 -1
- package/src/lib/types/server.ts +27 -1
- package/src/lib/types/terminal-client.ts +3 -0
- package/src/lib/types/ws.ts +3 -2
- package/src/routes/api/autopilot/goal/+server.ts +72 -0
- package/src/routes/api/neurolink-proxy/+server.ts +8 -2
- package/src/routes/api/notify/+server.ts +22 -15
- package/src/routes/api/presence/+server.ts +39 -0
- package/src/routes/api/ws-status/+server.ts +8 -5
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css +0 -1
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css.br +0 -0
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BmfLecb1.js.br +0 -0
- package/build/client/_app/immutable/chunks/BmfLecb1.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CRkG7oE4.js +0 -1
- package/build/client/_app/immutable/chunks/CRkG7oE4.js.br +0 -0
- package/build/client/_app/immutable/chunks/CRkG7oE4.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DOEXXmsh.js.br +0 -0
- package/build/client/_app/immutable/chunks/DOEXXmsh.js.gz +0 -0
- package/build/client/_app/immutable/chunks/EqMAkEha.js.br +0 -0
- package/build/client/_app/immutable/chunks/EqMAkEha.js.gz +0 -0
- package/build/client/_app/immutable/chunks/J5-Cr5oR.js +0 -6
- package/build/client/_app/immutable/chunks/J5-Cr5oR.js.br +0 -0
- package/build/client/_app/immutable/chunks/J5-Cr5oR.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.CeSxgGat.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CeSxgGat.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js +0 -1
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js.br +0 -2
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.gz +0 -0
- package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.gz +0 -0
- package/build/client/_app/immutable/nodes/11.CKmZjP_a.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js +0 -13
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.br +0 -0
- package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.gz +0 -0
- package/build/server/chunks/_page.svelte-tBuIq8Pg.js +0 -159
- package/build/server/chunks/_page.svelte-tBuIq8Pg.js.map +0 -1
- package/build/server/chunks/_server.ts-BWVlO8iV.js.map +0 -1
- package/build/server/chunks/_server.ts-BevnuePu.js.map +0 -1
- package/build/server/chunks/_server.ts-CA5KUENM.js.map +0 -1
- package/build/server/chunks/_server.ts-CC2K8-L2.js.map +0 -1
- package/build/server/chunks/_server.ts-CD7JP3fz.js.map +0 -1
- package/build/server/chunks/_server.ts-D0zRDSx0.js.map +0 -1
- package/build/server/chunks/_server.ts-Dp-hXW_I.js.map +0 -1
- package/build/server/chunks/_server.ts-VzDcFFgy.js.map +0 -1
- package/build/server/chunks/library-apns-Dl3iRE2h.js.map +0 -1
- package/build/server/chunks/pending-requests-C9p57WoU.js.map +0 -1
- package/build/server/chunks/pty-manager-ZqXqa-6A.js.map +0 -1
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ConversationMessage,
|
|
3
3
|
PtyManagedTerminal as ManagedTerminal,
|
|
4
4
|
PtyOutputBuffer as OutputBuffer,
|
|
5
|
+
SeqRingEntry,
|
|
5
6
|
TerminalRecord,
|
|
6
7
|
} from '$lib/types';
|
|
7
8
|
import type WebSocket from 'ws';
|
|
@@ -18,8 +19,10 @@ import {
|
|
|
18
19
|
readOnlySourceForCommand,
|
|
19
20
|
} from '../sessions/provider-paths';
|
|
20
21
|
import { broadcastEvent } from '../ws/server.js';
|
|
22
|
+
import { withAgentPermissionMode } from './agent-launch.js';
|
|
21
23
|
import { HolderClient } from './holder-client';
|
|
22
24
|
import { openCodeWatcher } from './opencode-watcher';
|
|
25
|
+
import { TerminalEmulator } from './terminal-emulator';
|
|
23
26
|
import { terminalStore } from './terminal-store';
|
|
24
27
|
|
|
25
28
|
export type { ManagedTerminal };
|
|
@@ -31,6 +34,16 @@ export type { ManagedTerminal };
|
|
|
31
34
|
const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512 KB cached scrollback cap
|
|
32
35
|
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024; // 1 MB per client
|
|
33
36
|
const SCROLLBACK_CHUNK_SIZE = 50 * 1024; // 50 KB per chunk
|
|
37
|
+
const SEQ_RING_MAX_ENTRIES = 2000; // bounded replay ring (~2-10 MB of recent output)
|
|
38
|
+
// Server-side emulator + snapshot-on-join is on unless explicitly disabled
|
|
39
|
+
// (SHOOTER_SNAPSHOT_FALLBACK=raw reverts to legacy raw-scrollback replay).
|
|
40
|
+
const SNAPSHOT_ENABLED = process.env.SHOOTER_SNAPSHOT_FALLBACK !== 'raw';
|
|
41
|
+
// Phase 2 backpressure convergence: when a client falls behind we stop dropping
|
|
42
|
+
// bytes silently and instead resnapshot it to the current screen once its socket
|
|
43
|
+
// drains below the low-water mark (or after a hard timeout if it never drains).
|
|
44
|
+
const RESNAPSHOT_LOW_WATER_BYTES = MAX_OUTPUT_BUFFER_BYTES / 4;
|
|
45
|
+
const RESNAPSHOT_POLL_MS = 100;
|
|
46
|
+
const RESNAPSHOT_MAX_WAIT_MS = 10_000;
|
|
34
47
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
35
48
|
const EXITED_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
36
49
|
const MAX_EXITED_TERMINALS = 10;
|
|
@@ -47,6 +60,10 @@ const __dirname = path.dirname(__filename);
|
|
|
47
60
|
|
|
48
61
|
class PtyManager {
|
|
49
62
|
private cleanupTimer: null | ReturnType<typeof setInterval> = null;
|
|
63
|
+
// Clients currently converging via a resnapshot (Phase 2). While pending, a
|
|
64
|
+
// client receives no normal output frames — the forthcoming snapshot brings
|
|
65
|
+
// it to the current screen. WeakSet so disconnected sockets drop out on GC.
|
|
66
|
+
private resnapshotPending = new WeakSet<WebSocket>();
|
|
50
67
|
private terminals = new Map<string, ManagedTerminal>();
|
|
51
68
|
|
|
52
69
|
constructor() {
|
|
@@ -60,25 +77,63 @@ class PtyManager {
|
|
|
60
77
|
// persists to SQLite
|
|
61
78
|
// -----------------------------------------------------------------------
|
|
62
79
|
|
|
63
|
-
attach(id: string, ws: WebSocket): boolean {
|
|
80
|
+
attach(id: string, ws: WebSocket, opts?: { lastSeq?: number; snapshot?: boolean }): boolean {
|
|
64
81
|
const terminal = this.terminals.get(id);
|
|
65
82
|
if (!terminal) {
|
|
66
83
|
return false;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
terminal.clients.add(ws);
|
|
70
86
|
terminal.outputBuffers.set(ws, { data: [], size: 0 });
|
|
71
87
|
|
|
72
|
-
|
|
88
|
+
const wantsSnapshot = opts?.snapshot === true;
|
|
89
|
+
const lastSeq = opts?.lastSeq ?? 0;
|
|
90
|
+
|
|
91
|
+
// Reconnect resume (Phase 2): the client already applied output up to
|
|
92
|
+
// lastSeq. If those missing frames are still in the ring, replay just the
|
|
93
|
+
// gap and go live — a seamless catch-up with no screen flash. This branch
|
|
94
|
+
// is fully synchronous, so no live frame can interleave between computing
|
|
95
|
+
// the gap and registering the client (no missed frames). Falls through to a
|
|
96
|
+
// fresh snapshot/scrollback when the gap predates the ring (getSeqRingFrom
|
|
97
|
+
// returns null) or the seq counter reset across a server restart.
|
|
98
|
+
if (wantsSnapshot && lastSeq > 0) {
|
|
99
|
+
const gap = this.getSeqRingFrom(id, lastSeq);
|
|
100
|
+
if (gap !== null) {
|
|
101
|
+
for (const entry of gap) {
|
|
102
|
+
this.safeSend(ws, JSON.stringify({ data: entry.data, seq: entry.seq, type: 'output' }));
|
|
103
|
+
}
|
|
104
|
+
terminal.clients.add(ws);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (wantsSnapshot && terminal.emulator) {
|
|
110
|
+
// Snapshot-capable client: send the current-screen snapshot FIRST, then
|
|
111
|
+
// start the live tail (add to clients). Adding to clients only after the
|
|
112
|
+
// snapshot is sent guarantees no live frame precedes or duplicates it —
|
|
113
|
+
// the emulator already includes any output produced while snapshotting,
|
|
114
|
+
// and the snapshot's seq lets the client drop already-included frames.
|
|
115
|
+
void this.snapshotAndSend(terminal, ws).then((ok) => {
|
|
116
|
+
if (ws.readyState !== 1 /* OPEN */) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!ok) {
|
|
120
|
+
// Snapshot failed — fall back to the legacy raw scrollback replay.
|
|
121
|
+
terminal.clients.add(ws);
|
|
122
|
+
void this.sendScrollback(terminal, ws);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
terminal.clients.add(ws);
|
|
126
|
+
});
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
terminal.clients.add(ws);
|
|
131
|
+
// Send cached scrollback in chunks (legacy / non-snapshot-capable clients).
|
|
73
132
|
void this.sendScrollback(terminal, ws);
|
|
74
133
|
|
|
75
134
|
return true;
|
|
76
135
|
}
|
|
77
136
|
|
|
78
|
-
// -----------------------------------------------------------------------
|
|
79
|
-
// reconnectAll — recover persisted terminals on server startup
|
|
80
|
-
// -----------------------------------------------------------------------
|
|
81
|
-
|
|
82
137
|
cleanup(): void {
|
|
83
138
|
const now = Date.now();
|
|
84
139
|
const exited: { exitedAt: number; id: string }[] = [];
|
|
@@ -120,7 +175,7 @@ class PtyManager {
|
|
|
120
175
|
}
|
|
121
176
|
|
|
122
177
|
// -----------------------------------------------------------------------
|
|
123
|
-
//
|
|
178
|
+
// reconnectAll — recover persisted terminals on server startup
|
|
124
179
|
// -----------------------------------------------------------------------
|
|
125
180
|
|
|
126
181
|
async create(
|
|
@@ -134,8 +189,17 @@ class PtyManager {
|
|
|
134
189
|
const socketPath = `/tmp/shooter-term-${id}.sock`;
|
|
135
190
|
const holderScript = resolveHolderPath();
|
|
136
191
|
|
|
192
|
+
// Inject the configured default --permission-mode for claude (SHOOTER_AGENT_PERMISSION_MODE),
|
|
193
|
+
// so a managed agent can act instead of being auto-denied by a restrictive global config.
|
|
194
|
+
// No-op unless set; never overrides an explicit flag. Persisted so reconnect keeps it.
|
|
195
|
+
const launchArgs = withAgentPermissionMode(
|
|
196
|
+
command,
|
|
197
|
+
args,
|
|
198
|
+
process.env.SHOOTER_AGENT_PERMISSION_MODE
|
|
199
|
+
);
|
|
200
|
+
|
|
137
201
|
// Fork the holder process as detached so it survives server restarts
|
|
138
|
-
const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...
|
|
202
|
+
const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...launchArgs];
|
|
139
203
|
const holder: ChildProcess = fork(holderScript, holderArgs, {
|
|
140
204
|
detached: true,
|
|
141
205
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
@@ -179,13 +243,14 @@ class PtyManager {
|
|
|
179
243
|
|
|
180
244
|
const now = new Date();
|
|
181
245
|
const terminal: ManagedTerminal = {
|
|
182
|
-
args,
|
|
246
|
+
args: launchArgs,
|
|
183
247
|
clients: new Set(),
|
|
184
248
|
cols,
|
|
185
249
|
command,
|
|
186
250
|
createdAt: now,
|
|
187
251
|
currentCwd: null,
|
|
188
252
|
cwd,
|
|
253
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(cols, rows) : null,
|
|
189
254
|
exitCode: connectResult.exitCode,
|
|
190
255
|
exitedAt: null,
|
|
191
256
|
holderPid,
|
|
@@ -199,6 +264,8 @@ class PtyManager {
|
|
|
199
264
|
pty: client,
|
|
200
265
|
rows,
|
|
201
266
|
scrollback: connectResult.scrollback,
|
|
267
|
+
seqCounter: 0,
|
|
268
|
+
seqRing: [],
|
|
202
269
|
sessionFile: null,
|
|
203
270
|
socketPath,
|
|
204
271
|
status: connectResult.exited ? 'exited' : 'running',
|
|
@@ -210,7 +277,7 @@ class PtyManager {
|
|
|
210
277
|
|
|
211
278
|
// Persist to SQLite
|
|
212
279
|
terminalStore.insert({
|
|
213
|
-
args: JSON.stringify(
|
|
280
|
+
args: JSON.stringify(launchArgs),
|
|
214
281
|
cols,
|
|
215
282
|
command,
|
|
216
283
|
createdAt: now.toISOString(),
|
|
@@ -242,7 +309,7 @@ class PtyManager {
|
|
|
242
309
|
}
|
|
243
310
|
|
|
244
311
|
// -----------------------------------------------------------------------
|
|
245
|
-
//
|
|
312
|
+
// disconnectAll — graceful shutdown: disconnect clients, keep holders alive
|
|
246
313
|
// -----------------------------------------------------------------------
|
|
247
314
|
|
|
248
315
|
destroy(): void {
|
|
@@ -291,8 +358,7 @@ class PtyManager {
|
|
|
291
358
|
}
|
|
292
359
|
|
|
293
360
|
// -----------------------------------------------------------------------
|
|
294
|
-
//
|
|
295
|
-
// createdAt descending
|
|
361
|
+
// get
|
|
296
362
|
// -----------------------------------------------------------------------
|
|
297
363
|
|
|
298
364
|
detach(id: string, ws: WebSocket): boolean {
|
|
@@ -307,7 +373,8 @@ class PtyManager {
|
|
|
307
373
|
}
|
|
308
374
|
|
|
309
375
|
// -----------------------------------------------------------------------
|
|
310
|
-
//
|
|
376
|
+
// list — running first, then recently exited, each group sorted by
|
|
377
|
+
// createdAt descending
|
|
311
378
|
// -----------------------------------------------------------------------
|
|
312
379
|
|
|
313
380
|
disconnectAll(): void {
|
|
@@ -342,7 +409,7 @@ class PtyManager {
|
|
|
342
409
|
}
|
|
343
410
|
|
|
344
411
|
// -----------------------------------------------------------------------
|
|
345
|
-
//
|
|
412
|
+
// kill — route through holder: SIGTERM, then SIGKILL after 5 s
|
|
346
413
|
// -----------------------------------------------------------------------
|
|
347
414
|
|
|
348
415
|
get(id: string): ManagedTerminal | null {
|
|
@@ -350,7 +417,7 @@ class PtyManager {
|
|
|
350
417
|
}
|
|
351
418
|
|
|
352
419
|
// -----------------------------------------------------------------------
|
|
353
|
-
//
|
|
420
|
+
// remove — remove an exited terminal from the map
|
|
354
421
|
// -----------------------------------------------------------------------
|
|
355
422
|
|
|
356
423
|
getScrollback(id: string): null | string {
|
|
@@ -363,9 +430,45 @@ class PtyManager {
|
|
|
363
430
|
}
|
|
364
431
|
|
|
365
432
|
// -----------------------------------------------------------------------
|
|
366
|
-
//
|
|
433
|
+
// resize
|
|
367
434
|
// -----------------------------------------------------------------------
|
|
368
435
|
|
|
436
|
+
/** Current highest assigned seq for a terminal, or null if unknown. */
|
|
437
|
+
getSeqCounter(id: string): null | number {
|
|
438
|
+
return this.terminals.get(id)?.seqCounter ?? null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Return the ring entries with seq > afterSeq, in order. Returns an empty
|
|
443
|
+
* array when the caller is already current, or null when the gap is
|
|
444
|
+
* unresolvable from the ring (caller must take a full snapshot). Unresolvable
|
|
445
|
+
* means any of:
|
|
446
|
+
* - afterSeq > seqCounter: the caller claims a seq we never produced — this
|
|
447
|
+
* happens when the seq counter reset across a server restart (the client
|
|
448
|
+
* is from a previous terminal lifetime), so its content is unrelated.
|
|
449
|
+
* - ring empty but afterSeq > 0: nothing buffered to bridge the gap.
|
|
450
|
+
* - afterSeq predates the oldest retained entry: the gap aged out.
|
|
451
|
+
*/
|
|
452
|
+
getSeqRingFrom(id: string, afterSeq: number): null | readonly SeqRingEntry[] {
|
|
453
|
+
const terminal = this.terminals.get(id);
|
|
454
|
+
if (!terminal) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
if (afterSeq > terminal.seqCounter) {
|
|
458
|
+
return null; // counter reset (restart) or client ahead of us — snapshot
|
|
459
|
+
}
|
|
460
|
+
const ring = terminal.seqRing;
|
|
461
|
+
if (ring.length === 0) {
|
|
462
|
+
// Caught up (afterSeq 0, nothing produced) vs. an impossible-to-bridge
|
|
463
|
+
// positive gap with no buffered frames.
|
|
464
|
+
return afterSeq <= 0 ? [] : null;
|
|
465
|
+
}
|
|
466
|
+
if (afterSeq < ring[0].seq - 1) {
|
|
467
|
+
return null; // gap predates the ring — caller must send a full snapshot
|
|
468
|
+
}
|
|
469
|
+
return ring.filter((e) => e.seq > afterSeq);
|
|
470
|
+
}
|
|
471
|
+
|
|
369
472
|
kill(id: string): boolean {
|
|
370
473
|
const terminal = this.terminals.get(id);
|
|
371
474
|
if (!terminal) {
|
|
@@ -406,7 +509,7 @@ class PtyManager {
|
|
|
406
509
|
}
|
|
407
510
|
|
|
408
511
|
// -----------------------------------------------------------------------
|
|
409
|
-
//
|
|
512
|
+
// attach — register a WebSocket client and replay scrollback
|
|
410
513
|
// -----------------------------------------------------------------------
|
|
411
514
|
|
|
412
515
|
list(): ManagedTerminal[] {
|
|
@@ -428,7 +531,7 @@ class PtyManager {
|
|
|
428
531
|
}
|
|
429
532
|
|
|
430
533
|
// -----------------------------------------------------------------------
|
|
431
|
-
//
|
|
534
|
+
// detach — remove a WebSocket client
|
|
432
535
|
// -----------------------------------------------------------------------
|
|
433
536
|
|
|
434
537
|
async reconnectAll(): Promise<void> {
|
|
@@ -452,8 +555,7 @@ class PtyManager {
|
|
|
452
555
|
}
|
|
453
556
|
|
|
454
557
|
// -----------------------------------------------------------------------
|
|
455
|
-
//
|
|
456
|
-
// also clean up old SQLite records
|
|
558
|
+
// getScrollback — return raw scrollback data for replay
|
|
457
559
|
// -----------------------------------------------------------------------
|
|
458
560
|
|
|
459
561
|
remove(id: string): boolean {
|
|
@@ -470,7 +572,8 @@ class PtyManager {
|
|
|
470
572
|
}
|
|
471
573
|
|
|
472
574
|
// -----------------------------------------------------------------------
|
|
473
|
-
//
|
|
575
|
+
// cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
|
|
576
|
+
// also clean up old SQLite records
|
|
474
577
|
// -----------------------------------------------------------------------
|
|
475
578
|
|
|
476
579
|
resize(id: string, cols: number, rows: number): boolean {
|
|
@@ -483,6 +586,7 @@ class PtyManager {
|
|
|
483
586
|
terminal.pty.resize(cols, rows);
|
|
484
587
|
terminal.cols = cols;
|
|
485
588
|
terminal.rows = rows;
|
|
589
|
+
terminal.emulator?.resize(cols, rows);
|
|
486
590
|
// Broadcast the new PTY size so attached clients (e.g. view-only
|
|
487
591
|
// guests) can follow the terminal dimensions.
|
|
488
592
|
const msg = JSON.stringify({ cols, rows, type: 'resize' });
|
|
@@ -495,6 +599,41 @@ class PtyManager {
|
|
|
495
599
|
}
|
|
496
600
|
}
|
|
497
601
|
|
|
602
|
+
// -----------------------------------------------------------------------
|
|
603
|
+
// destroy — emergency forced kill (kills holder processes too)
|
|
604
|
+
// -----------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Compute the current-screen snapshot from the emulator and send it as a
|
|
608
|
+
* single {type:'snapshot'} frame stamped with the current seq. Returns false
|
|
609
|
+
* if there is no emulator, the socket closed, or serialization failed.
|
|
610
|
+
* Reused by Phase 2 to resnapshot a client after a backpressure gap.
|
|
611
|
+
*/
|
|
612
|
+
async snapshotAndSend(terminal: ManagedTerminal, ws: WebSocket): Promise<boolean> {
|
|
613
|
+
if (!terminal.emulator) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const snap = await terminal.emulator.snapshot();
|
|
618
|
+
if (ws.readyState !== 1 /* OPEN */) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
this.safeSend(
|
|
622
|
+
ws,
|
|
623
|
+
JSON.stringify({
|
|
624
|
+
cols: snap.cols,
|
|
625
|
+
data: snap.data,
|
|
626
|
+
rows: snap.rows,
|
|
627
|
+
seq: terminal.seqCounter,
|
|
628
|
+
type: 'snapshot',
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
return true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
498
637
|
// -----------------------------------------------------------------------
|
|
499
638
|
// Private: reconnectOne — reconnect to a single persisted terminal
|
|
500
639
|
// -----------------------------------------------------------------------
|
|
@@ -516,17 +655,92 @@ class PtyManager {
|
|
|
516
655
|
}
|
|
517
656
|
}
|
|
518
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Assign the next sequence number to an output chunk and append it to the
|
|
660
|
+
* bounded replay ring. Returns the new seq. Phase 2 uses the ring to replay
|
|
661
|
+
* the gap to a reconnecting client without a full snapshot.
|
|
662
|
+
*/
|
|
663
|
+
private appendSeqRing(terminal: ManagedTerminal, data: string): number {
|
|
664
|
+
const seq = terminal.seqCounter + 1;
|
|
665
|
+
terminal.seqRing.push({ data, seq });
|
|
666
|
+
if (terminal.seqRing.length > SEQ_RING_MAX_ENTRIES) {
|
|
667
|
+
terminal.seqRing.shift();
|
|
668
|
+
}
|
|
669
|
+
terminal.seqCounter = seq;
|
|
670
|
+
return seq;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Mark a client for convergence (Phase 2). Its queued output is discarded and
|
|
675
|
+
* further live frames are withheld until its socket drains below the low-water
|
|
676
|
+
* mark (or a hard timeout elapses), at which point a fresh snapshot resets it
|
|
677
|
+
* to the current screen. This replaces silent byte-dropping so a slow or
|
|
678
|
+
* throttled client can never diverge permanently (G1).
|
|
679
|
+
*/
|
|
680
|
+
private beginResnapshot(terminal: ManagedTerminal, ws: WebSocket): void {
|
|
681
|
+
if (this.resnapshotPending.has(ws)) {
|
|
682
|
+
return; // already converging
|
|
683
|
+
}
|
|
684
|
+
this.resnapshotPending.add(ws);
|
|
685
|
+
|
|
686
|
+
// Drop the now-stale queue; the snapshot supersedes it.
|
|
687
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
688
|
+
if (buffer) {
|
|
689
|
+
buffer.data.length = 0;
|
|
690
|
+
buffer.size = 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Signal the gap so the client can show a "resyncing" state; the snapshot
|
|
694
|
+
// that follows performs the actual screen reset.
|
|
695
|
+
this.safeSend(ws, JSON.stringify({ bytes: 0, type: 'output-dropped' }));
|
|
696
|
+
|
|
697
|
+
const startedAt = Date.now();
|
|
698
|
+
const poll = (): void => {
|
|
699
|
+
if (!this.resnapshotPending.has(ws)) {
|
|
700
|
+
return; // resolved or cancelled elsewhere
|
|
701
|
+
}
|
|
702
|
+
// Give up if the client left or the terminal/emulator went away meanwhile.
|
|
703
|
+
if (ws.readyState !== 1 /* OPEN */ || !terminal.emulator || !terminal.clients.has(ws)) {
|
|
704
|
+
this.resnapshotPending.delete(ws);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const drained = ws.bufferedAmount <= RESNAPSHOT_LOW_WATER_BYTES;
|
|
708
|
+
const timedOut = Date.now() - startedAt > RESNAPSHOT_MAX_WAIT_MS;
|
|
709
|
+
if (drained || timedOut) {
|
|
710
|
+
// snapshotAndSend reads seqCounter after awaiting the emulator, so the
|
|
711
|
+
// client's lastSeq jumps to "now" and the withheld frames (already in
|
|
712
|
+
// the snapshot) are never replayed. Clear pending only after it sends.
|
|
713
|
+
void this.snapshotAndSend(terminal, ws).finally(() => {
|
|
714
|
+
this.resnapshotPending.delete(ws);
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
setTimeout(poll, RESNAPSHOT_POLL_MS);
|
|
719
|
+
};
|
|
720
|
+
setTimeout(poll, RESNAPSHOT_POLL_MS);
|
|
721
|
+
}
|
|
722
|
+
|
|
519
723
|
// -----------------------------------------------------------------------
|
|
520
724
|
// Private: handleReconnectFailure — handle failed reconnection
|
|
521
725
|
// -----------------------------------------------------------------------
|
|
522
726
|
|
|
523
727
|
private broadcastOutput(terminal: ManagedTerminal, data: string): void {
|
|
524
|
-
|
|
728
|
+
// Assign a sequence number and append to the replay ring before broadcasting.
|
|
729
|
+
const seq = this.appendSeqRing(terminal, data);
|
|
730
|
+
const msg = JSON.stringify({ data, seq, type: 'output' });
|
|
731
|
+
|
|
732
|
+
// Fallback mode (SHOOTER_SNAPSHOT_FALLBACK=raw): no emulator, so a slow
|
|
733
|
+
// client cannot be resnapshotted — keep the legacy drop-oldest behaviour.
|
|
734
|
+
if (!terminal.emulator) {
|
|
735
|
+
this.broadcastOutputLegacy(terminal, msg);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
525
738
|
|
|
739
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
526
740
|
for (const ws of terminal.clients) {
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
741
|
+
// A converging client receives no live frames until its snapshot is sent;
|
|
742
|
+
// buffering them here would just re-overflow the socket.
|
|
743
|
+
if (this.resnapshotPending.has(ws)) {
|
|
530
744
|
continue;
|
|
531
745
|
}
|
|
532
746
|
|
|
@@ -535,9 +749,41 @@ class PtyManager {
|
|
|
535
749
|
continue;
|
|
536
750
|
}
|
|
537
751
|
|
|
538
|
-
|
|
752
|
+
// Socket- or buffer-level overflow ⇒ the client cannot keep up. Converge
|
|
753
|
+
// it to the current screen via resnapshot instead of dropping bytes (G1).
|
|
754
|
+
if (
|
|
755
|
+
ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES ||
|
|
756
|
+
buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES
|
|
757
|
+
) {
|
|
758
|
+
this.beginResnapshot(terminal, ws);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
buffer.data.push(msg);
|
|
763
|
+
buffer.size += msgSize;
|
|
764
|
+
this.flushOutputBuffer(ws, buffer);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Legacy broadcast path used only when the emulator is disabled
|
|
770
|
+
* (SHOOTER_SNAPSHOT_FALLBACK=raw). Drops the oldest buffered output to make
|
|
771
|
+
* room and notifies the client; there is no snapshot to converge it to.
|
|
772
|
+
*/
|
|
773
|
+
private broadcastOutputLegacy(terminal: ManagedTerminal, msg: string): void {
|
|
774
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
775
|
+
for (const ws of terminal.clients) {
|
|
776
|
+
// Skip if the socket has too much queued already.
|
|
777
|
+
if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
|
|
778
|
+
this.safeSend(ws, JSON.stringify({ bytes: msgSize, type: 'output-dropped' }));
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
783
|
+
if (!buffer) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
539
786
|
|
|
540
|
-
// Check backpressure: if buffer exceeds limit, drop oldest data
|
|
541
787
|
if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
|
|
542
788
|
let droppedBytes = 0;
|
|
543
789
|
while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
|
|
@@ -548,21 +794,13 @@ class PtyManager {
|
|
|
548
794
|
droppedBytes += droppedSize;
|
|
549
795
|
}
|
|
550
796
|
}
|
|
551
|
-
|
|
552
|
-
// Notify client of dropped output
|
|
553
797
|
if (droppedBytes > 0) {
|
|
554
|
-
|
|
555
|
-
bytes: droppedBytes,
|
|
556
|
-
type: 'output-dropped',
|
|
557
|
-
});
|
|
558
|
-
this.safeSend(ws, dropMsg);
|
|
798
|
+
this.safeSend(ws, JSON.stringify({ bytes: droppedBytes, type: 'output-dropped' }));
|
|
559
799
|
}
|
|
560
800
|
}
|
|
561
801
|
|
|
562
|
-
// Buffer the message and attempt to send
|
|
563
802
|
buffer.data.push(msg);
|
|
564
803
|
buffer.size += msgSize;
|
|
565
|
-
|
|
566
804
|
this.flushOutputBuffer(ws, buffer);
|
|
567
805
|
}
|
|
568
806
|
}
|
|
@@ -599,6 +837,10 @@ class PtyManager {
|
|
|
599
837
|
terminal.openCodeNoopCb = null;
|
|
600
838
|
}
|
|
601
839
|
|
|
840
|
+
// Dispose the server-side emulator (frees its parser/buffer state)
|
|
841
|
+
terminal.emulator?.dispose();
|
|
842
|
+
terminal.emulator = null;
|
|
843
|
+
|
|
602
844
|
// Disconnect from holder (but don't kill it — it may already be gone)
|
|
603
845
|
terminal.pty.disconnect();
|
|
604
846
|
|
|
@@ -727,6 +969,7 @@ class PtyManager {
|
|
|
727
969
|
createdAt: new Date(record.createdAt),
|
|
728
970
|
currentCwd: null,
|
|
729
971
|
cwd: record.cwd,
|
|
972
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(record.cols, record.rows) : null,
|
|
730
973
|
exitCode: connectResult.exitCode,
|
|
731
974
|
exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
|
|
732
975
|
holderPid: record.holderPid ?? 0,
|
|
@@ -740,12 +983,22 @@ class PtyManager {
|
|
|
740
983
|
pty: client,
|
|
741
984
|
rows: record.rows,
|
|
742
985
|
scrollback: connectResult.scrollback,
|
|
986
|
+
seqCounter: 0,
|
|
987
|
+
seqRing: [],
|
|
743
988
|
sessionFile: record.sessionFile ?? null,
|
|
744
989
|
socketPath: record.socketPath,
|
|
745
990
|
status: connectResult.exited ? 'exited' : 'running',
|
|
746
991
|
watcherOffset: 0,
|
|
747
992
|
};
|
|
748
993
|
|
|
994
|
+
// Seed the fresh emulator with the holder's retained scrollback so a
|
|
995
|
+
// snapshot taken right after a server restart reflects the screen as it was,
|
|
996
|
+
// not a blank buffer. The scrollback is raw PTY bytes (escape sequences
|
|
997
|
+
// included), which the emulator parses into the current screen state.
|
|
998
|
+
if (terminal.emulator && connectResult.scrollback.length > 0) {
|
|
999
|
+
terminal.emulator.write(connectResult.scrollback);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
749
1002
|
// If the PTY already exited, update SQLite and add to Map for visibility
|
|
750
1003
|
if (connectResult.exited) {
|
|
751
1004
|
terminal.exitedAt = terminal.exitedAt ?? new Date();
|
|
@@ -1083,6 +1336,7 @@ class PtyManager {
|
|
|
1083
1336
|
});
|
|
1084
1337
|
|
|
1085
1338
|
client.onOutput((data: string) => {
|
|
1339
|
+
terminal.emulator?.write(data);
|
|
1086
1340
|
this.appendScrollback(terminal, data);
|
|
1087
1341
|
this.broadcastOutput(terminal, data);
|
|
1088
1342
|
});
|
|
@@ -26,6 +26,10 @@ class SessionWatcher {
|
|
|
26
26
|
string,
|
|
27
27
|
Map<string, { parts: MessagePart[]; timestamp: string }>
|
|
28
28
|
>();
|
|
29
|
+
// Last read offset per file, retained ACROSS stop() so a re-subscribe resumes where it left off
|
|
30
|
+
// instead of jumping to the current end of file (which would skip any lines — e.g. an agent-idle
|
|
31
|
+
// marker — written while no subscriber happened to be attached).
|
|
32
|
+
private lastOffsetPerFile = new Map<string, number>();
|
|
29
33
|
// Buffer for incomplete trailing lines (no terminating newline yet)
|
|
30
34
|
private lineBufferPerFile = new Map<string, string>();
|
|
31
35
|
// Track message index per file for generating fallback IDs
|
|
@@ -107,6 +111,8 @@ class SessionWatcher {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
void watched.watcher.close();
|
|
114
|
+
// Remember where we stopped reading so a future re-subscribe resumes from here, not from EOF.
|
|
115
|
+
this.lastOffsetPerFile.set(filePath, watched.offset);
|
|
110
116
|
this.watchedFiles.delete(filePath);
|
|
111
117
|
this.assistantTurnsPerFile.delete(filePath);
|
|
112
118
|
this.messageIndexPerFile.delete(filePath);
|
|
@@ -149,8 +155,12 @@ class SessionWatcher {
|
|
|
149
155
|
};
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
// Initialize tracking state for this file
|
|
153
|
-
|
|
158
|
+
// Initialize tracking state for this file. Resume from the last read offset if we watched this
|
|
159
|
+
// file before (so content written during the gap is not skipped); otherwise start at EOF.
|
|
160
|
+
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
161
|
+
const remembered = this.lastOffsetPerFile.get(filePath);
|
|
162
|
+
this.lastOffsetPerFile.delete(filePath);
|
|
163
|
+
const initialOffset = remembered !== undefined ? Math.min(remembered, fileSize) : fileSize;
|
|
154
164
|
this.assistantTurnsPerFile.set(filePath, new Map());
|
|
155
165
|
this.messageIndexPerFile.set(filePath, 0);
|
|
156
166
|
this.lineBufferPerFile.set(filePath, '');
|