@juspay/shooter 1.22.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/build/client/_app/immutable/chunks/Bj5wFimK.js +3 -0
- 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/{DhK7PwI_.js → BjYr_-Ss.js} +1 -1
- package/build/client/_app/immutable/chunks/BjYr_-Ss.js.br +0 -0
- package/build/client/_app/immutable/chunks/{DhK7PwI_.js.gz → BjYr_-Ss.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/{CZg4kn4E.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.CTqz33nP.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.Qn7Ktiht.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.BxWOfNlo.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.BGPYD1s1.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.BxY1PUjC.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.BxY1PUjC.js.gz → 11.DO3vyXEv.js.gz} +0 -0
- package/build/client/_app/immutable/nodes/{2.Bc2qALkX.js → 2.iMIqsE7n.js} +1 -1
- 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.N2-A8noI.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/{6.BWF9Qx6F.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.DHuDIdpz.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.D0Ijt9Vv.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.2Piwo35J.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/server/chunks/{0-CVGsyVKN.js → 0-DAB_6Vm1.js} +2 -2
- package/build/server/chunks/{0-CVGsyVKN.js.map → 0-DAB_6Vm1.js.map} +1 -1
- package/build/server/chunks/{1-BAlAsKdp.js → 1-D-qMYaCx.js} +2 -2
- package/build/server/chunks/{1-BAlAsKdp.js.map → 1-D-qMYaCx.js.map} +1 -1
- package/build/server/chunks/{10-BUCX7Aqz.js → 10-CeFFGo-X.js} +2 -2
- package/build/server/chunks/{10-BUCX7Aqz.js.map → 10-CeFFGo-X.js.map} +1 -1
- package/build/server/chunks/{11-DHPvc2yA.js → 11-DRMu_ATU.js} +2 -2
- package/build/server/chunks/{11-DHPvc2yA.js.map → 11-DRMu_ATU.js.map} +1 -1
- package/build/server/chunks/{2-DLOMdCHW.js → 2-B7OLBMNH.js} +2 -2
- package/build/server/chunks/{2-DLOMdCHW.js.map → 2-B7OLBMNH.js.map} +1 -1
- package/build/server/chunks/{3-DCf69LYo.js → 3-B38ZarLw.js} +2 -2
- package/build/server/chunks/{3-DCf69LYo.js.map → 3-B38ZarLw.js.map} +1 -1
- package/build/server/chunks/{6-DUrC2Naz.js → 6-DP46cUej.js} +2 -2
- package/build/server/chunks/{6-DUrC2Naz.js.map → 6-DP46cUej.js.map} +1 -1
- package/build/server/chunks/{7-TXwjMHt2.js → 7-B29_3ar6.js} +2 -2
- package/build/server/chunks/{7-TXwjMHt2.js.map → 7-B29_3ar6.js.map} +1 -1
- package/build/server/chunks/{8-D2X_jBsT.js → 8-DCnSDVrX.js} +2 -2
- package/build/server/chunks/{8-D2X_jBsT.js.map → 8-DCnSDVrX.js.map} +1 -1
- package/build/server/chunks/{9-DK0hH5Xa.js → 9-BwqDc8wC.js} +2 -2
- package/build/server/chunks/{9-DK0hH5Xa.js.map → 9-BwqDc8wC.js.map} +1 -1
- package/build/server/chunks/{_server.ts-B54Pvhgc.js → _server.ts-Blx6TuRU.js} +3 -2
- package/build/server/chunks/_server.ts-Blx6TuRU.js.map +1 -0
- package/build/server/chunks/{_server.ts-C0PO_cAu.js → _server.ts-CYWXjihn.js} +3 -2
- package/build/server/chunks/{_server.ts-C0PO_cAu.js.map → _server.ts-CYWXjihn.js.map} +1 -1
- package/build/server/chunks/{_server.ts-DiBMY7Ho.js → _server.ts-D0___krA.js} +3 -2
- package/build/server/chunks/_server.ts-D0___krA.js.map +1 -0
- package/build/server/chunks/{_server.ts-CZb-BI5H.js → _server.ts-Da1kSClZ.js} +3 -2
- package/build/server/chunks/_server.ts-Da1kSClZ.js.map +1 -0
- package/build/server/chunks/{_server.ts-Bol54_Qo.js → _server.ts-l3cd4Cto.js} +3 -2
- package/build/server/chunks/_server.ts-l3cd4Cto.js.map +1 -0
- package/build/server/chunks/{pty-manager-CoWVT56F.js → pty-manager-DDjG7DlH.js} +272 -27
- package/build/server/chunks/pty-manager-DDjG7DlH.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +16 -16
- package/build/server/manifest.js.map +1 -1
- package/package.json +4 -2
- package/server.ts +2 -2
- package/src/lib/modules/client/terminal/xterm-wrapper.ts +52 -12
- package/src/lib/modules/server/terminal/pty-manager.ts +279 -35
- package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -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/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/build/client/_app/immutable/chunks/BfbPKMXz.js +0 -3
- package/build/client/_app/immutable/chunks/BfbPKMXz.js.br +0 -0
- package/build/client/_app/immutable/chunks/BfbPKMXz.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CZg4kn4E.js.br +0 -0
- package/build/client/_app/immutable/chunks/CZg4kn4E.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DhK7PwI_.js.br +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.CTqz33nP.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CTqz33nP.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js +0 -1
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.br +0 -2
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.gz +0 -0
- package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.gz +0 -0
- package/build/client/_app/immutable/nodes/11.BxY1PUjC.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.N2-A8noI.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.N2-A8noI.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.2Piwo35J.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.2Piwo35J.js.gz +0 -0
- package/build/server/chunks/_server.ts-B54Pvhgc.js.map +0 -1
- package/build/server/chunks/_server.ts-Bol54_Qo.js.map +0 -1
- package/build/server/chunks/_server.ts-CZb-BI5H.js.map +0 -1
- package/build/server/chunks/_server.ts-DiBMY7Ho.js.map +0 -1
- package/build/server/chunks/pty-manager-CoWVT56F.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';
|
|
@@ -21,6 +22,7 @@ import { broadcastEvent } from '../ws/server.js';
|
|
|
21
22
|
import { withAgentPermissionMode } from './agent-launch.js';
|
|
22
23
|
import { HolderClient } from './holder-client';
|
|
23
24
|
import { openCodeWatcher } from './opencode-watcher';
|
|
25
|
+
import { TerminalEmulator } from './terminal-emulator';
|
|
24
26
|
import { terminalStore } from './terminal-store';
|
|
25
27
|
|
|
26
28
|
export type { ManagedTerminal };
|
|
@@ -32,6 +34,16 @@ export type { ManagedTerminal };
|
|
|
32
34
|
const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512 KB cached scrollback cap
|
|
33
35
|
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024; // 1 MB per client
|
|
34
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;
|
|
35
47
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
36
48
|
const EXITED_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
37
49
|
const MAX_EXITED_TERMINALS = 10;
|
|
@@ -48,6 +60,10 @@ const __dirname = path.dirname(__filename);
|
|
|
48
60
|
|
|
49
61
|
class PtyManager {
|
|
50
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>();
|
|
51
67
|
private terminals = new Map<string, ManagedTerminal>();
|
|
52
68
|
|
|
53
69
|
constructor() {
|
|
@@ -61,25 +77,63 @@ class PtyManager {
|
|
|
61
77
|
// persists to SQLite
|
|
62
78
|
// -----------------------------------------------------------------------
|
|
63
79
|
|
|
64
|
-
attach(id: string, ws: WebSocket): boolean {
|
|
80
|
+
attach(id: string, ws: WebSocket, opts?: { lastSeq?: number; snapshot?: boolean }): boolean {
|
|
65
81
|
const terminal = this.terminals.get(id);
|
|
66
82
|
if (!terminal) {
|
|
67
83
|
return false;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
terminal.clients.add(ws);
|
|
71
86
|
terminal.outputBuffers.set(ws, { data: [], size: 0 });
|
|
72
87
|
|
|
73
|
-
|
|
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).
|
|
74
132
|
void this.sendScrollback(terminal, ws);
|
|
75
133
|
|
|
76
134
|
return true;
|
|
77
135
|
}
|
|
78
136
|
|
|
79
|
-
// -----------------------------------------------------------------------
|
|
80
|
-
// reconnectAll — recover persisted terminals on server startup
|
|
81
|
-
// -----------------------------------------------------------------------
|
|
82
|
-
|
|
83
137
|
cleanup(): void {
|
|
84
138
|
const now = Date.now();
|
|
85
139
|
const exited: { exitedAt: number; id: string }[] = [];
|
|
@@ -121,7 +175,7 @@ class PtyManager {
|
|
|
121
175
|
}
|
|
122
176
|
|
|
123
177
|
// -----------------------------------------------------------------------
|
|
124
|
-
//
|
|
178
|
+
// reconnectAll — recover persisted terminals on server startup
|
|
125
179
|
// -----------------------------------------------------------------------
|
|
126
180
|
|
|
127
181
|
async create(
|
|
@@ -196,6 +250,7 @@ class PtyManager {
|
|
|
196
250
|
createdAt: now,
|
|
197
251
|
currentCwd: null,
|
|
198
252
|
cwd,
|
|
253
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(cols, rows) : null,
|
|
199
254
|
exitCode: connectResult.exitCode,
|
|
200
255
|
exitedAt: null,
|
|
201
256
|
holderPid,
|
|
@@ -209,6 +264,8 @@ class PtyManager {
|
|
|
209
264
|
pty: client,
|
|
210
265
|
rows,
|
|
211
266
|
scrollback: connectResult.scrollback,
|
|
267
|
+
seqCounter: 0,
|
|
268
|
+
seqRing: [],
|
|
212
269
|
sessionFile: null,
|
|
213
270
|
socketPath,
|
|
214
271
|
status: connectResult.exited ? 'exited' : 'running',
|
|
@@ -252,7 +309,7 @@ class PtyManager {
|
|
|
252
309
|
}
|
|
253
310
|
|
|
254
311
|
// -----------------------------------------------------------------------
|
|
255
|
-
//
|
|
312
|
+
// disconnectAll — graceful shutdown: disconnect clients, keep holders alive
|
|
256
313
|
// -----------------------------------------------------------------------
|
|
257
314
|
|
|
258
315
|
destroy(): void {
|
|
@@ -301,8 +358,7 @@ class PtyManager {
|
|
|
301
358
|
}
|
|
302
359
|
|
|
303
360
|
// -----------------------------------------------------------------------
|
|
304
|
-
//
|
|
305
|
-
// createdAt descending
|
|
361
|
+
// get
|
|
306
362
|
// -----------------------------------------------------------------------
|
|
307
363
|
|
|
308
364
|
detach(id: string, ws: WebSocket): boolean {
|
|
@@ -317,7 +373,8 @@ class PtyManager {
|
|
|
317
373
|
}
|
|
318
374
|
|
|
319
375
|
// -----------------------------------------------------------------------
|
|
320
|
-
//
|
|
376
|
+
// list — running first, then recently exited, each group sorted by
|
|
377
|
+
// createdAt descending
|
|
321
378
|
// -----------------------------------------------------------------------
|
|
322
379
|
|
|
323
380
|
disconnectAll(): void {
|
|
@@ -352,7 +409,7 @@ class PtyManager {
|
|
|
352
409
|
}
|
|
353
410
|
|
|
354
411
|
// -----------------------------------------------------------------------
|
|
355
|
-
//
|
|
412
|
+
// kill — route through holder: SIGTERM, then SIGKILL after 5 s
|
|
356
413
|
// -----------------------------------------------------------------------
|
|
357
414
|
|
|
358
415
|
get(id: string): ManagedTerminal | null {
|
|
@@ -360,7 +417,7 @@ class PtyManager {
|
|
|
360
417
|
}
|
|
361
418
|
|
|
362
419
|
// -----------------------------------------------------------------------
|
|
363
|
-
//
|
|
420
|
+
// remove — remove an exited terminal from the map
|
|
364
421
|
// -----------------------------------------------------------------------
|
|
365
422
|
|
|
366
423
|
getScrollback(id: string): null | string {
|
|
@@ -373,9 +430,45 @@ class PtyManager {
|
|
|
373
430
|
}
|
|
374
431
|
|
|
375
432
|
// -----------------------------------------------------------------------
|
|
376
|
-
//
|
|
433
|
+
// resize
|
|
377
434
|
// -----------------------------------------------------------------------
|
|
378
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
|
+
|
|
379
472
|
kill(id: string): boolean {
|
|
380
473
|
const terminal = this.terminals.get(id);
|
|
381
474
|
if (!terminal) {
|
|
@@ -416,7 +509,7 @@ class PtyManager {
|
|
|
416
509
|
}
|
|
417
510
|
|
|
418
511
|
// -----------------------------------------------------------------------
|
|
419
|
-
//
|
|
512
|
+
// attach — register a WebSocket client and replay scrollback
|
|
420
513
|
// -----------------------------------------------------------------------
|
|
421
514
|
|
|
422
515
|
list(): ManagedTerminal[] {
|
|
@@ -438,7 +531,7 @@ class PtyManager {
|
|
|
438
531
|
}
|
|
439
532
|
|
|
440
533
|
// -----------------------------------------------------------------------
|
|
441
|
-
//
|
|
534
|
+
// detach — remove a WebSocket client
|
|
442
535
|
// -----------------------------------------------------------------------
|
|
443
536
|
|
|
444
537
|
async reconnectAll(): Promise<void> {
|
|
@@ -462,8 +555,7 @@ class PtyManager {
|
|
|
462
555
|
}
|
|
463
556
|
|
|
464
557
|
// -----------------------------------------------------------------------
|
|
465
|
-
//
|
|
466
|
-
// also clean up old SQLite records
|
|
558
|
+
// getScrollback — return raw scrollback data for replay
|
|
467
559
|
// -----------------------------------------------------------------------
|
|
468
560
|
|
|
469
561
|
remove(id: string): boolean {
|
|
@@ -480,7 +572,8 @@ class PtyManager {
|
|
|
480
572
|
}
|
|
481
573
|
|
|
482
574
|
// -----------------------------------------------------------------------
|
|
483
|
-
//
|
|
575
|
+
// cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
|
|
576
|
+
// also clean up old SQLite records
|
|
484
577
|
// -----------------------------------------------------------------------
|
|
485
578
|
|
|
486
579
|
resize(id: string, cols: number, rows: number): boolean {
|
|
@@ -493,6 +586,7 @@ class PtyManager {
|
|
|
493
586
|
terminal.pty.resize(cols, rows);
|
|
494
587
|
terminal.cols = cols;
|
|
495
588
|
terminal.rows = rows;
|
|
589
|
+
terminal.emulator?.resize(cols, rows);
|
|
496
590
|
// Broadcast the new PTY size so attached clients (e.g. view-only
|
|
497
591
|
// guests) can follow the terminal dimensions.
|
|
498
592
|
const msg = JSON.stringify({ cols, rows, type: 'resize' });
|
|
@@ -505,6 +599,41 @@ class PtyManager {
|
|
|
505
599
|
}
|
|
506
600
|
}
|
|
507
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
|
+
|
|
508
637
|
// -----------------------------------------------------------------------
|
|
509
638
|
// Private: reconnectOne — reconnect to a single persisted terminal
|
|
510
639
|
// -----------------------------------------------------------------------
|
|
@@ -526,17 +655,92 @@ class PtyManager {
|
|
|
526
655
|
}
|
|
527
656
|
}
|
|
528
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
|
+
|
|
529
723
|
// -----------------------------------------------------------------------
|
|
530
724
|
// Private: handleReconnectFailure — handle failed reconnection
|
|
531
725
|
// -----------------------------------------------------------------------
|
|
532
726
|
|
|
533
727
|
private broadcastOutput(terminal: ManagedTerminal, data: string): void {
|
|
534
|
-
|
|
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
|
+
}
|
|
535
738
|
|
|
739
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
536
740
|
for (const ws of terminal.clients) {
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
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)) {
|
|
540
744
|
continue;
|
|
541
745
|
}
|
|
542
746
|
|
|
@@ -545,9 +749,41 @@ class PtyManager {
|
|
|
545
749
|
continue;
|
|
546
750
|
}
|
|
547
751
|
|
|
548
|
-
|
|
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
|
+
}
|
|
549
786
|
|
|
550
|
-
// Check backpressure: if buffer exceeds limit, drop oldest data
|
|
551
787
|
if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
|
|
552
788
|
let droppedBytes = 0;
|
|
553
789
|
while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
|
|
@@ -558,21 +794,13 @@ class PtyManager {
|
|
|
558
794
|
droppedBytes += droppedSize;
|
|
559
795
|
}
|
|
560
796
|
}
|
|
561
|
-
|
|
562
|
-
// Notify client of dropped output
|
|
563
797
|
if (droppedBytes > 0) {
|
|
564
|
-
|
|
565
|
-
bytes: droppedBytes,
|
|
566
|
-
type: 'output-dropped',
|
|
567
|
-
});
|
|
568
|
-
this.safeSend(ws, dropMsg);
|
|
798
|
+
this.safeSend(ws, JSON.stringify({ bytes: droppedBytes, type: 'output-dropped' }));
|
|
569
799
|
}
|
|
570
800
|
}
|
|
571
801
|
|
|
572
|
-
// Buffer the message and attempt to send
|
|
573
802
|
buffer.data.push(msg);
|
|
574
803
|
buffer.size += msgSize;
|
|
575
|
-
|
|
576
804
|
this.flushOutputBuffer(ws, buffer);
|
|
577
805
|
}
|
|
578
806
|
}
|
|
@@ -609,6 +837,10 @@ class PtyManager {
|
|
|
609
837
|
terminal.openCodeNoopCb = null;
|
|
610
838
|
}
|
|
611
839
|
|
|
840
|
+
// Dispose the server-side emulator (frees its parser/buffer state)
|
|
841
|
+
terminal.emulator?.dispose();
|
|
842
|
+
terminal.emulator = null;
|
|
843
|
+
|
|
612
844
|
// Disconnect from holder (but don't kill it — it may already be gone)
|
|
613
845
|
terminal.pty.disconnect();
|
|
614
846
|
|
|
@@ -737,6 +969,7 @@ class PtyManager {
|
|
|
737
969
|
createdAt: new Date(record.createdAt),
|
|
738
970
|
currentCwd: null,
|
|
739
971
|
cwd: record.cwd,
|
|
972
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(record.cols, record.rows) : null,
|
|
740
973
|
exitCode: connectResult.exitCode,
|
|
741
974
|
exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
|
|
742
975
|
holderPid: record.holderPid ?? 0,
|
|
@@ -750,12 +983,22 @@ class PtyManager {
|
|
|
750
983
|
pty: client,
|
|
751
984
|
rows: record.rows,
|
|
752
985
|
scrollback: connectResult.scrollback,
|
|
986
|
+
seqCounter: 0,
|
|
987
|
+
seqRing: [],
|
|
753
988
|
sessionFile: record.sessionFile ?? null,
|
|
754
989
|
socketPath: record.socketPath,
|
|
755
990
|
status: connectResult.exited ? 'exited' : 'running',
|
|
756
991
|
watcherOffset: 0,
|
|
757
992
|
};
|
|
758
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
|
+
|
|
759
1002
|
// If the PTY already exited, update SQLite and add to Map for visibility
|
|
760
1003
|
if (connectResult.exited) {
|
|
761
1004
|
terminal.exitedAt = terminal.exitedAt ?? new Date();
|
|
@@ -1093,6 +1336,7 @@ class PtyManager {
|
|
|
1093
1336
|
});
|
|
1094
1337
|
|
|
1095
1338
|
client.onOutput((data: string) => {
|
|
1339
|
+
terminal.emulator?.write(data);
|
|
1096
1340
|
this.appendScrollback(terminal, data);
|
|
1097
1341
|
this.broadcastOutput(terminal, data);
|
|
1098
1342
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side authoritative terminal emulator (Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Wraps a DOM-free @xterm/headless Terminal + @xterm/addon-serialize. Every
|
|
5
|
+
* PTY output chunk is fed in via write(); snapshot() returns a VT-escape string
|
|
6
|
+
* that reconstructs the CURRENT screen — including the alternate buffer (TUIs
|
|
7
|
+
* like vim/htop) and modes — when written into a fresh terminal. This is what
|
|
8
|
+
* a new or reconnecting client receives instead of a raw scrollback replay,
|
|
9
|
+
* fixing late-join corruption (G2) and the scrollback/live duplication race (G3).
|
|
10
|
+
*
|
|
11
|
+
* Caveats handled here (see the keystone spike): @xterm/addon-serialize does
|
|
12
|
+
* NOT serialize cursor visibility (DECTCEM ?25l), so we track it from the byte
|
|
13
|
+
* stream and re-emit it; cursor position restores functionally but not
|
|
14
|
+
* byte-exactly (do not assert byte-equality). Pinned to @xterm/headless@6.0.0
|
|
15
|
+
* and @xterm/addon-serialize@0.14.0 (serialize() reaches into _core internals).
|
|
16
|
+
*
|
|
17
|
+
* Interop note: both packages export via CJS in a way tsx/Node cannot resolve
|
|
18
|
+
* as named ESM imports, so the runtime values come through createRequire() while
|
|
19
|
+
* the types are imported separately.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { TerminalSnapshot } from '$lib/types';
|
|
23
|
+
import type { SerializeAddon as SerializeAddonInstance } from '@xterm/addon-serialize';
|
|
24
|
+
import type { Terminal as HeadlessTerminal } from '@xterm/headless';
|
|
25
|
+
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Terminal } = require('@xterm/headless') as {
|
|
30
|
+
Terminal: new (options?: object) => HeadlessTerminal;
|
|
31
|
+
};
|
|
32
|
+
const { SerializeAddon } = require('@xterm/addon-serialize') as {
|
|
33
|
+
SerializeAddon: new () => SerializeAddonInstance;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Scrollback lines retained in the emulator and included in snapshots. */
|
|
37
|
+
const SNAPSHOT_SCROLLBACK_LINES = 1000;
|
|
38
|
+
|
|
39
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
40
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
41
|
+
|
|
42
|
+
export class TerminalEmulator {
|
|
43
|
+
private cursorHidden = false;
|
|
44
|
+
private readonly serializer: SerializeAddonInstance;
|
|
45
|
+
private readonly term: HeadlessTerminal;
|
|
46
|
+
|
|
47
|
+
constructor(cols: number, rows: number) {
|
|
48
|
+
this.term = new Terminal({
|
|
49
|
+
allowProposedApi: true,
|
|
50
|
+
cols: cols > 0 ? cols : 80,
|
|
51
|
+
rows: rows > 0 ? rows : 24,
|
|
52
|
+
scrollback: SNAPSHOT_SCROLLBACK_LINES,
|
|
53
|
+
});
|
|
54
|
+
this.serializer = new SerializeAddon();
|
|
55
|
+
// @xterm/addon-serialize types its addon against @xterm/xterm's Terminal,
|
|
56
|
+
// but we run it on @xterm/headless's Terminal. Runtime-compatible; the cast
|
|
57
|
+
// bridges the two structurally-different Terminal types at loadAddon only.
|
|
58
|
+
this.term.loadAddon(this.serializer as unknown as Parameters<HeadlessTerminal['loadAddon']>[0]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dispose(): void {
|
|
62
|
+
try {
|
|
63
|
+
this.serializer.dispose();
|
|
64
|
+
this.term.dispose();
|
|
65
|
+
} catch {
|
|
66
|
+
// Already disposed — ignore.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
resize(cols: number, rows: number): void {
|
|
71
|
+
if (cols > 0 && rows > 0) {
|
|
72
|
+
this.term.resize(cols, rows);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Capture the current screen as a VT-escape string. Serialization runs inside
|
|
78
|
+
* a write() callback so all previously-written bytes are parsed first.
|
|
79
|
+
*/
|
|
80
|
+
snapshot(): Promise<TerminalSnapshot> {
|
|
81
|
+
return new Promise<TerminalSnapshot>((resolve) => {
|
|
82
|
+
this.term.write('', () => {
|
|
83
|
+
let data = this.serializer.serialize({ scrollback: SNAPSHOT_SCROLLBACK_LINES });
|
|
84
|
+
// SerializeAddon omits cursor visibility — re-emit when hidden.
|
|
85
|
+
if (this.cursorHidden) {
|
|
86
|
+
data += HIDE_CURSOR;
|
|
87
|
+
}
|
|
88
|
+
resolve({ cols: this.term.cols, data, rows: this.term.rows });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
write(data: string): void {
|
|
94
|
+
// Track cursor visibility (DECTCEM) from the stream; last toggle wins.
|
|
95
|
+
const hideIdx = data.lastIndexOf(HIDE_CURSOR);
|
|
96
|
+
const showIdx = data.lastIndexOf(SHOW_CURSOR);
|
|
97
|
+
if (hideIdx !== -1 || showIdx !== -1) {
|
|
98
|
+
this.cursorHidden = hideIdx > showIdx;
|
|
99
|
+
}
|
|
100
|
+
this.term.write(data);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -56,8 +56,24 @@ export function setupWebSocketHandlers(
|
|
|
56
56
|
): void {
|
|
57
57
|
const host = request.headers.host ?? 'localhost';
|
|
58
58
|
let pathname: string;
|
|
59
|
+
let snapshotCapable = false;
|
|
60
|
+
let lastSeq = 0;
|
|
59
61
|
try {
|
|
60
|
-
|
|
62
|
+
const url = new URL(request.url || '/', `http://${host}`);
|
|
63
|
+
pathname = url.pathname;
|
|
64
|
+
// Capability negotiation: clients that understand the {snapshot} frame
|
|
65
|
+
// advertise ?caps=snapshot. Others fall back to raw scrollback replay.
|
|
66
|
+
snapshotCapable = url.searchParams.get('caps') === 'snapshot';
|
|
67
|
+
// Reconnect resume (Phase 2): a returning client passes the highest output
|
|
68
|
+
// seq it already applied so the server can replay just the gap (or snapshot
|
|
69
|
+
// if the gap aged out of the ring). Absent / non-numeric ⇒ fresh join.
|
|
70
|
+
const rawLastSeq = url.searchParams.get('lastSeq');
|
|
71
|
+
if (rawLastSeq !== null) {
|
|
72
|
+
const parsed = Number(rawLastSeq);
|
|
73
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
74
|
+
lastSeq = Math.floor(parsed);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
61
77
|
} catch {
|
|
62
78
|
socket.destroy();
|
|
63
79
|
return;
|
|
@@ -103,7 +119,7 @@ export function setupWebSocketHandlers(
|
|
|
103
119
|
|
|
104
120
|
if (terminalMatch) {
|
|
105
121
|
const terminalId = terminalMatch[1];
|
|
106
|
-
handleTerminalConnection(ws, terminalId, scope);
|
|
122
|
+
handleTerminalConnection(ws, terminalId, scope, snapshotCapable, lastSeq);
|
|
107
123
|
} else if (superSessionMatch) {
|
|
108
124
|
const superSessionId = superSessionMatch[1];
|
|
109
125
|
handleSuperSessionConnection(ws, superSessionId);
|