@juspay/shooter 1.22.0 → 1.24.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/CbINytmr.js +3 -0
- package/build/client/_app/immutable/chunks/CbINytmr.js.br +0 -0
- package/build/client/_app/immutable/chunks/CbINytmr.js.gz +0 -0
- package/build/client/_app/immutable/chunks/D868VwmX.js +6 -0
- package/build/client/_app/immutable/chunks/D868VwmX.js.br +0 -0
- package/build/client/_app/immutable/chunks/D868VwmX.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{CZg4kn4E.js → Dd1KNHg-.js} +1 -1
- package/build/client/_app/immutable/chunks/Dd1KNHg-.js.br +0 -0
- package/build/client/_app/immutable/chunks/Dd1KNHg-.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{DhK7PwI_.js → V8pbM9cl.js} +1 -1
- package/build/client/_app/immutable/chunks/V8pbM9cl.js.br +0 -0
- package/build/client/_app/immutable/chunks/V8pbM9cl.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.CTqz33nP.js → app.CiQHPW0j.js} +2 -2
- package/build/client/_app/immutable/entry/app.CiQHPW0j.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CiQHPW0j.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.DUCXuMLl.js +1 -0
- package/build/client/_app/immutable/entry/start.DUCXuMLl.js.br +2 -0
- package/build/client/_app/immutable/entry/start.DUCXuMLl.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.Qn7Ktiht.js → 0.BRFdS_ay.js} +1 -1
- package/build/client/_app/immutable/nodes/0.BRFdS_ay.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.BRFdS_ay.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.BxWOfNlo.js → 1.B1pgwYu3.js} +1 -1
- package/build/client/_app/immutable/nodes/1.B1pgwYu3.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.B1pgwYu3.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{10.BGPYD1s1.js → 10.558mUFIl.js} +1 -1
- package/build/client/_app/immutable/nodes/10.558mUFIl.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.558mUFIl.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js → 11.CdmPyt4k.js} +2 -2
- package/build/client/_app/immutable/nodes/11.CdmPyt4k.js.br +0 -0
- package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js.gz → 11.CdmPyt4k.js.gz} +0 -0
- package/build/client/_app/immutable/nodes/{2.Bc2qALkX.js → 2.1tiK5o4L.js} +1 -1
- package/build/client/_app/immutable/nodes/2.1tiK5o4L.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.1tiK5o4L.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.N2-A8noI.js → 3.DyQTorXE.js} +1 -1
- package/build/client/_app/immutable/nodes/3.DyQTorXE.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.DyQTorXE.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.BWF9Qx6F.js → 6.Chn2ZM2V.js} +1 -1
- package/build/client/_app/immutable/nodes/6.Chn2ZM2V.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.Chn2ZM2V.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.DHuDIdpz.js → 7.DhJ2K3GQ.js} +1 -1
- package/build/client/_app/immutable/nodes/7.DhJ2K3GQ.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DhJ2K3GQ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.D0Ijt9Vv.js → 8.B4pLxBkI.js} +1 -1
- package/build/client/_app/immutable/nodes/8.B4pLxBkI.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.B4pLxBkI.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.2Piwo35J.js → 9.CVsskPw5.js} +1 -1
- package/build/client/_app/immutable/nodes/9.CVsskPw5.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.CVsskPw5.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-DZO0pCuJ.js} +2 -2
- package/build/server/chunks/{0-CVGsyVKN.js.map → 0-DZO0pCuJ.js.map} +1 -1
- package/build/server/chunks/{1-BAlAsKdp.js → 1-D2SDQFeq.js} +2 -2
- package/build/server/chunks/{1-BAlAsKdp.js.map → 1-D2SDQFeq.js.map} +1 -1
- package/build/server/chunks/{10-BUCX7Aqz.js → 10-CEJDEhpQ.js} +2 -2
- package/build/server/chunks/{10-BUCX7Aqz.js.map → 10-CEJDEhpQ.js.map} +1 -1
- package/build/server/chunks/{11-DHPvc2yA.js → 11-CMC_i3co.js} +2 -2
- package/build/server/chunks/{11-DHPvc2yA.js.map → 11-CMC_i3co.js.map} +1 -1
- package/build/server/chunks/{2-DLOMdCHW.js → 2-C1XSBNj7.js} +2 -2
- package/build/server/chunks/{2-DLOMdCHW.js.map → 2-C1XSBNj7.js.map} +1 -1
- package/build/server/chunks/{3-DCf69LYo.js → 3-DRjTDzaV.js} +2 -2
- package/build/server/chunks/{3-DCf69LYo.js.map → 3-DRjTDzaV.js.map} +1 -1
- package/build/server/chunks/{6-DUrC2Naz.js → 6-BcgshtK4.js} +2 -2
- package/build/server/chunks/{6-DUrC2Naz.js.map → 6-BcgshtK4.js.map} +1 -1
- package/build/server/chunks/{7-TXwjMHt2.js → 7-BBsuxiGz.js} +2 -2
- package/build/server/chunks/{7-TXwjMHt2.js.map → 7-BBsuxiGz.js.map} +1 -1
- package/build/server/chunks/{8-D2X_jBsT.js → 8-B0qM-Zzs.js} +2 -2
- package/build/server/chunks/{8-D2X_jBsT.js.map → 8-B0qM-Zzs.js.map} +1 -1
- package/build/server/chunks/{9-DK0hH5Xa.js → 9-XIfsp2D_.js} +2 -2
- package/build/server/chunks/{9-DK0hH5Xa.js.map → 9-XIfsp2D_.js.map} +1 -1
- package/build/server/chunks/{_server.ts-DiBMY7Ho.js → _server.ts-B-Gekwsu.js} +3 -2
- package/build/server/chunks/_server.ts-B-Gekwsu.js.map +1 -0
- package/build/server/chunks/{_server.ts-C0PO_cAu.js → _server.ts-BhP3b8A5.js} +3 -2
- package/build/server/chunks/{_server.ts-C0PO_cAu.js.map → _server.ts-BhP3b8A5.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Bol54_Qo.js → _server.ts-CTdFxJdD.js} +3 -2
- package/build/server/chunks/_server.ts-CTdFxJdD.js.map +1 -0
- package/build/server/chunks/{_server.ts-B54Pvhgc.js → _server.ts-Cx0S__hk.js} +3 -2
- package/build/server/chunks/_server.ts-Cx0S__hk.js.map +1 -0
- package/build/server/chunks/{_server.ts-CZb-BI5H.js → _server.ts-DtT-ZXki.js} +3 -2
- package/build/server/chunks/_server.ts-DtT-ZXki.js.map +1 -0
- package/build/server/chunks/{pty-manager-CoWVT56F.js → pty-manager-BsHXoNks.js} +287 -27
- package/build/server/chunks/pty-manager-BsHXoNks.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 +5 -2
- package/src/lib/modules/client/terminal/xterm-wrapper.ts +56 -15
- package/src/lib/modules/server/terminal/pty-manager.ts +291 -35
- package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -0
- package/src/lib/modules/server/terminal/terminal-store.ts +10 -0
- package/src/lib/modules/server/ws/server.ts +18 -2
- package/src/lib/modules/server/ws/terminal-handler.ts +60 -14
- package/src/lib/types/generated/WsProtocol.ts +10 -1
- package/src/lib/types/server.ts +34 -1
- package/src/lib/types/terminal-client.ts +3 -0
- package/src/lib/types/ws.ts +7 -2
- package/src/routes/api/terminals/[id]/resize/+server.ts +3 -0
- 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/DhK7PwI_.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.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,70 @@ 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
|
+
// Phase 3: level-triggered size push on join (fixes G8). Every joiner —
|
|
89
|
+
// interactive or view-only, on any of the paths below — immediately learns
|
|
90
|
+
// the PTY's current size without waiting for the next edge-triggered resize.
|
|
91
|
+
// Sent first (a direct send, not a broadcast) so it applies before the
|
|
92
|
+
// snapshot/scrollback paints.
|
|
93
|
+
this.safeSend(ws, JSON.stringify({ cols: terminal.cols, rows: terminal.rows, type: 'resize' }));
|
|
94
|
+
|
|
95
|
+
const wantsSnapshot = opts?.snapshot === true;
|
|
96
|
+
const lastSeq = opts?.lastSeq ?? 0;
|
|
97
|
+
|
|
98
|
+
// Reconnect resume (Phase 2): the client already applied output up to
|
|
99
|
+
// lastSeq. If those missing frames are still in the ring, replay just the
|
|
100
|
+
// gap and go live — a seamless catch-up with no screen flash. This branch
|
|
101
|
+
// is fully synchronous, so no live frame can interleave between computing
|
|
102
|
+
// the gap and registering the client (no missed frames). Falls through to a
|
|
103
|
+
// fresh snapshot/scrollback when the gap predates the ring (getSeqRingFrom
|
|
104
|
+
// returns null) or the seq counter reset across a server restart.
|
|
105
|
+
if (wantsSnapshot && lastSeq > 0) {
|
|
106
|
+
const gap = this.getSeqRingFrom(id, lastSeq);
|
|
107
|
+
if (gap !== null) {
|
|
108
|
+
for (const entry of gap) {
|
|
109
|
+
this.safeSend(ws, JSON.stringify({ data: entry.data, seq: entry.seq, type: 'output' }));
|
|
110
|
+
}
|
|
111
|
+
terminal.clients.add(ws);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (wantsSnapshot && terminal.emulator) {
|
|
117
|
+
// Snapshot-capable client: send the current-screen snapshot FIRST, then
|
|
118
|
+
// start the live tail (add to clients). Adding to clients only after the
|
|
119
|
+
// snapshot is sent guarantees no live frame precedes or duplicates it —
|
|
120
|
+
// the emulator already includes any output produced while snapshotting,
|
|
121
|
+
// and the snapshot's seq lets the client drop already-included frames.
|
|
122
|
+
void this.snapshotAndSend(terminal, ws).then((ok) => {
|
|
123
|
+
if (ws.readyState !== 1 /* OPEN */) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!ok) {
|
|
127
|
+
// Snapshot failed — fall back to the legacy raw scrollback replay.
|
|
128
|
+
terminal.clients.add(ws);
|
|
129
|
+
void this.sendScrollback(terminal, ws);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
terminal.clients.add(ws);
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
terminal.clients.add(ws);
|
|
138
|
+
// Send cached scrollback in chunks (legacy / non-snapshot-capable clients).
|
|
74
139
|
void this.sendScrollback(terminal, ws);
|
|
75
140
|
|
|
76
141
|
return true;
|
|
77
142
|
}
|
|
78
143
|
|
|
79
|
-
// -----------------------------------------------------------------------
|
|
80
|
-
// reconnectAll — recover persisted terminals on server startup
|
|
81
|
-
// -----------------------------------------------------------------------
|
|
82
|
-
|
|
83
144
|
cleanup(): void {
|
|
84
145
|
const now = Date.now();
|
|
85
146
|
const exited: { exitedAt: number; id: string }[] = [];
|
|
@@ -121,7 +182,7 @@ class PtyManager {
|
|
|
121
182
|
}
|
|
122
183
|
|
|
123
184
|
// -----------------------------------------------------------------------
|
|
124
|
-
//
|
|
185
|
+
// reconnectAll — recover persisted terminals on server startup
|
|
125
186
|
// -----------------------------------------------------------------------
|
|
126
187
|
|
|
127
188
|
async create(
|
|
@@ -190,12 +251,14 @@ class PtyManager {
|
|
|
190
251
|
const now = new Date();
|
|
191
252
|
const terminal: ManagedTerminal = {
|
|
192
253
|
args: launchArgs,
|
|
254
|
+
authorityConnectionId: null, // Phase 3: claimed by the first interactive resize
|
|
193
255
|
clients: new Set(),
|
|
194
256
|
cols,
|
|
195
257
|
command,
|
|
196
258
|
createdAt: now,
|
|
197
259
|
currentCwd: null,
|
|
198
260
|
cwd,
|
|
261
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(cols, rows) : null,
|
|
199
262
|
exitCode: connectResult.exitCode,
|
|
200
263
|
exitedAt: null,
|
|
201
264
|
holderPid,
|
|
@@ -209,6 +272,8 @@ class PtyManager {
|
|
|
209
272
|
pty: client,
|
|
210
273
|
rows,
|
|
211
274
|
scrollback: connectResult.scrollback,
|
|
275
|
+
seqCounter: 0,
|
|
276
|
+
seqRing: [],
|
|
212
277
|
sessionFile: null,
|
|
213
278
|
socketPath,
|
|
214
279
|
status: connectResult.exited ? 'exited' : 'running',
|
|
@@ -252,7 +317,7 @@ class PtyManager {
|
|
|
252
317
|
}
|
|
253
318
|
|
|
254
319
|
// -----------------------------------------------------------------------
|
|
255
|
-
//
|
|
320
|
+
// disconnectAll — graceful shutdown: disconnect clients, keep holders alive
|
|
256
321
|
// -----------------------------------------------------------------------
|
|
257
322
|
|
|
258
323
|
destroy(): void {
|
|
@@ -301,8 +366,7 @@ class PtyManager {
|
|
|
301
366
|
}
|
|
302
367
|
|
|
303
368
|
// -----------------------------------------------------------------------
|
|
304
|
-
//
|
|
305
|
-
// createdAt descending
|
|
369
|
+
// get
|
|
306
370
|
// -----------------------------------------------------------------------
|
|
307
371
|
|
|
308
372
|
detach(id: string, ws: WebSocket): boolean {
|
|
@@ -317,7 +381,8 @@ class PtyManager {
|
|
|
317
381
|
}
|
|
318
382
|
|
|
319
383
|
// -----------------------------------------------------------------------
|
|
320
|
-
//
|
|
384
|
+
// list — running first, then recently exited, each group sorted by
|
|
385
|
+
// createdAt descending
|
|
321
386
|
// -----------------------------------------------------------------------
|
|
322
387
|
|
|
323
388
|
disconnectAll(): void {
|
|
@@ -352,7 +417,7 @@ class PtyManager {
|
|
|
352
417
|
}
|
|
353
418
|
|
|
354
419
|
// -----------------------------------------------------------------------
|
|
355
|
-
//
|
|
420
|
+
// kill — route through holder: SIGTERM, then SIGKILL after 5 s
|
|
356
421
|
// -----------------------------------------------------------------------
|
|
357
422
|
|
|
358
423
|
get(id: string): ManagedTerminal | null {
|
|
@@ -360,7 +425,7 @@ class PtyManager {
|
|
|
360
425
|
}
|
|
361
426
|
|
|
362
427
|
// -----------------------------------------------------------------------
|
|
363
|
-
//
|
|
428
|
+
// remove — remove an exited terminal from the map
|
|
364
429
|
// -----------------------------------------------------------------------
|
|
365
430
|
|
|
366
431
|
getScrollback(id: string): null | string {
|
|
@@ -373,9 +438,45 @@ class PtyManager {
|
|
|
373
438
|
}
|
|
374
439
|
|
|
375
440
|
// -----------------------------------------------------------------------
|
|
376
|
-
//
|
|
441
|
+
// resize
|
|
377
442
|
// -----------------------------------------------------------------------
|
|
378
443
|
|
|
444
|
+
/** Current highest assigned seq for a terminal, or null if unknown. */
|
|
445
|
+
getSeqCounter(id: string): null | number {
|
|
446
|
+
return this.terminals.get(id)?.seqCounter ?? null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Return the ring entries with seq > afterSeq, in order. Returns an empty
|
|
451
|
+
* array when the caller is already current, or null when the gap is
|
|
452
|
+
* unresolvable from the ring (caller must take a full snapshot). Unresolvable
|
|
453
|
+
* means any of:
|
|
454
|
+
* - afterSeq > seqCounter: the caller claims a seq we never produced — this
|
|
455
|
+
* happens when the seq counter reset across a server restart (the client
|
|
456
|
+
* is from a previous terminal lifetime), so its content is unrelated.
|
|
457
|
+
* - ring empty but afterSeq > 0: nothing buffered to bridge the gap.
|
|
458
|
+
* - afterSeq predates the oldest retained entry: the gap aged out.
|
|
459
|
+
*/
|
|
460
|
+
getSeqRingFrom(id: string, afterSeq: number): null | readonly SeqRingEntry[] {
|
|
461
|
+
const terminal = this.terminals.get(id);
|
|
462
|
+
if (!terminal) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
if (afterSeq > terminal.seqCounter) {
|
|
466
|
+
return null; // counter reset (restart) or client ahead of us — snapshot
|
|
467
|
+
}
|
|
468
|
+
const ring = terminal.seqRing;
|
|
469
|
+
if (ring.length === 0) {
|
|
470
|
+
// Caught up (afterSeq 0, nothing produced) vs. an impossible-to-bridge
|
|
471
|
+
// positive gap with no buffered frames.
|
|
472
|
+
return afterSeq <= 0 ? [] : null;
|
|
473
|
+
}
|
|
474
|
+
if (afterSeq < ring[0].seq - 1) {
|
|
475
|
+
return null; // gap predates the ring — caller must send a full snapshot
|
|
476
|
+
}
|
|
477
|
+
return ring.filter((e) => e.seq > afterSeq);
|
|
478
|
+
}
|
|
479
|
+
|
|
379
480
|
kill(id: string): boolean {
|
|
380
481
|
const terminal = this.terminals.get(id);
|
|
381
482
|
if (!terminal) {
|
|
@@ -416,7 +517,7 @@ class PtyManager {
|
|
|
416
517
|
}
|
|
417
518
|
|
|
418
519
|
// -----------------------------------------------------------------------
|
|
419
|
-
//
|
|
520
|
+
// attach — register a WebSocket client and replay scrollback
|
|
420
521
|
// -----------------------------------------------------------------------
|
|
421
522
|
|
|
422
523
|
list(): ManagedTerminal[] {
|
|
@@ -438,7 +539,7 @@ class PtyManager {
|
|
|
438
539
|
}
|
|
439
540
|
|
|
440
541
|
// -----------------------------------------------------------------------
|
|
441
|
-
//
|
|
542
|
+
// detach — remove a WebSocket client
|
|
442
543
|
// -----------------------------------------------------------------------
|
|
443
544
|
|
|
444
545
|
async reconnectAll(): Promise<void> {
|
|
@@ -462,8 +563,7 @@ class PtyManager {
|
|
|
462
563
|
}
|
|
463
564
|
|
|
464
565
|
// -----------------------------------------------------------------------
|
|
465
|
-
//
|
|
466
|
-
// also clean up old SQLite records
|
|
566
|
+
// getScrollback — return raw scrollback data for replay
|
|
467
567
|
// -----------------------------------------------------------------------
|
|
468
568
|
|
|
469
569
|
remove(id: string): boolean {
|
|
@@ -480,7 +580,8 @@ class PtyManager {
|
|
|
480
580
|
}
|
|
481
581
|
|
|
482
582
|
// -----------------------------------------------------------------------
|
|
483
|
-
//
|
|
583
|
+
// cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
|
|
584
|
+
// also clean up old SQLite records
|
|
484
585
|
// -----------------------------------------------------------------------
|
|
485
586
|
|
|
486
587
|
resize(id: string, cols: number, rows: number): boolean {
|
|
@@ -493,6 +594,10 @@ class PtyManager {
|
|
|
493
594
|
terminal.pty.resize(cols, rows);
|
|
494
595
|
terminal.cols = cols;
|
|
495
596
|
terminal.rows = rows;
|
|
597
|
+
terminal.emulator?.resize(cols, rows);
|
|
598
|
+
// Phase 3: persist so a server restart restores the latest size, not the
|
|
599
|
+
// creation-time default (fixes G5).
|
|
600
|
+
terminalStore.resizeDims(id, cols, rows);
|
|
496
601
|
// Broadcast the new PTY size so attached clients (e.g. view-only
|
|
497
602
|
// guests) can follow the terminal dimensions.
|
|
498
603
|
const msg = JSON.stringify({ cols, rows, type: 'resize' });
|
|
@@ -505,6 +610,41 @@ class PtyManager {
|
|
|
505
610
|
}
|
|
506
611
|
}
|
|
507
612
|
|
|
613
|
+
// -----------------------------------------------------------------------
|
|
614
|
+
// destroy — emergency forced kill (kills holder processes too)
|
|
615
|
+
// -----------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Compute the current-screen snapshot from the emulator and send it as a
|
|
619
|
+
* single {type:'snapshot'} frame stamped with the current seq. Returns false
|
|
620
|
+
* if there is no emulator, the socket closed, or serialization failed.
|
|
621
|
+
* Reused by Phase 2 to resnapshot a client after a backpressure gap.
|
|
622
|
+
*/
|
|
623
|
+
async snapshotAndSend(terminal: ManagedTerminal, ws: WebSocket): Promise<boolean> {
|
|
624
|
+
if (!terminal.emulator) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
const snap = await terminal.emulator.snapshot();
|
|
629
|
+
if (ws.readyState !== 1 /* OPEN */) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
this.safeSend(
|
|
633
|
+
ws,
|
|
634
|
+
JSON.stringify({
|
|
635
|
+
cols: snap.cols,
|
|
636
|
+
data: snap.data,
|
|
637
|
+
rows: snap.rows,
|
|
638
|
+
seq: terminal.seqCounter,
|
|
639
|
+
type: 'snapshot',
|
|
640
|
+
})
|
|
641
|
+
);
|
|
642
|
+
return true;
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
508
648
|
// -----------------------------------------------------------------------
|
|
509
649
|
// Private: reconnectOne — reconnect to a single persisted terminal
|
|
510
650
|
// -----------------------------------------------------------------------
|
|
@@ -526,17 +666,92 @@ class PtyManager {
|
|
|
526
666
|
}
|
|
527
667
|
}
|
|
528
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Assign the next sequence number to an output chunk and append it to the
|
|
671
|
+
* bounded replay ring. Returns the new seq. Phase 2 uses the ring to replay
|
|
672
|
+
* the gap to a reconnecting client without a full snapshot.
|
|
673
|
+
*/
|
|
674
|
+
private appendSeqRing(terminal: ManagedTerminal, data: string): number {
|
|
675
|
+
const seq = terminal.seqCounter + 1;
|
|
676
|
+
terminal.seqRing.push({ data, seq });
|
|
677
|
+
if (terminal.seqRing.length > SEQ_RING_MAX_ENTRIES) {
|
|
678
|
+
terminal.seqRing.shift();
|
|
679
|
+
}
|
|
680
|
+
terminal.seqCounter = seq;
|
|
681
|
+
return seq;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Mark a client for convergence (Phase 2). Its queued output is discarded and
|
|
686
|
+
* further live frames are withheld until its socket drains below the low-water
|
|
687
|
+
* mark (or a hard timeout elapses), at which point a fresh snapshot resets it
|
|
688
|
+
* to the current screen. This replaces silent byte-dropping so a slow or
|
|
689
|
+
* throttled client can never diverge permanently (G1).
|
|
690
|
+
*/
|
|
691
|
+
private beginResnapshot(terminal: ManagedTerminal, ws: WebSocket): void {
|
|
692
|
+
if (this.resnapshotPending.has(ws)) {
|
|
693
|
+
return; // already converging
|
|
694
|
+
}
|
|
695
|
+
this.resnapshotPending.add(ws);
|
|
696
|
+
|
|
697
|
+
// Drop the now-stale queue; the snapshot supersedes it.
|
|
698
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
699
|
+
if (buffer) {
|
|
700
|
+
buffer.data.length = 0;
|
|
701
|
+
buffer.size = 0;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Signal the gap so the client can show a "resyncing" state; the snapshot
|
|
705
|
+
// that follows performs the actual screen reset.
|
|
706
|
+
this.safeSend(ws, JSON.stringify({ bytes: 0, type: 'output-dropped' }));
|
|
707
|
+
|
|
708
|
+
const startedAt = Date.now();
|
|
709
|
+
const poll = (): void => {
|
|
710
|
+
if (!this.resnapshotPending.has(ws)) {
|
|
711
|
+
return; // resolved or cancelled elsewhere
|
|
712
|
+
}
|
|
713
|
+
// Give up if the client left or the terminal/emulator went away meanwhile.
|
|
714
|
+
if (ws.readyState !== 1 /* OPEN */ || !terminal.emulator || !terminal.clients.has(ws)) {
|
|
715
|
+
this.resnapshotPending.delete(ws);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const drained = ws.bufferedAmount <= RESNAPSHOT_LOW_WATER_BYTES;
|
|
719
|
+
const timedOut = Date.now() - startedAt > RESNAPSHOT_MAX_WAIT_MS;
|
|
720
|
+
if (drained || timedOut) {
|
|
721
|
+
// snapshotAndSend reads seqCounter after awaiting the emulator, so the
|
|
722
|
+
// client's lastSeq jumps to "now" and the withheld frames (already in
|
|
723
|
+
// the snapshot) are never replayed. Clear pending only after it sends.
|
|
724
|
+
void this.snapshotAndSend(terminal, ws).finally(() => {
|
|
725
|
+
this.resnapshotPending.delete(ws);
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
setTimeout(poll, RESNAPSHOT_POLL_MS);
|
|
730
|
+
};
|
|
731
|
+
setTimeout(poll, RESNAPSHOT_POLL_MS);
|
|
732
|
+
}
|
|
733
|
+
|
|
529
734
|
// -----------------------------------------------------------------------
|
|
530
735
|
// Private: handleReconnectFailure — handle failed reconnection
|
|
531
736
|
// -----------------------------------------------------------------------
|
|
532
737
|
|
|
533
738
|
private broadcastOutput(terminal: ManagedTerminal, data: string): void {
|
|
534
|
-
|
|
739
|
+
// Assign a sequence number and append to the replay ring before broadcasting.
|
|
740
|
+
const seq = this.appendSeqRing(terminal, data);
|
|
741
|
+
const msg = JSON.stringify({ data, seq, type: 'output' });
|
|
742
|
+
|
|
743
|
+
// Fallback mode (SHOOTER_SNAPSHOT_FALLBACK=raw): no emulator, so a slow
|
|
744
|
+
// client cannot be resnapshotted — keep the legacy drop-oldest behaviour.
|
|
745
|
+
if (!terminal.emulator) {
|
|
746
|
+
this.broadcastOutputLegacy(terminal, msg);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
535
749
|
|
|
750
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
536
751
|
for (const ws of terminal.clients) {
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
752
|
+
// A converging client receives no live frames until its snapshot is sent;
|
|
753
|
+
// buffering them here would just re-overflow the socket.
|
|
754
|
+
if (this.resnapshotPending.has(ws)) {
|
|
540
755
|
continue;
|
|
541
756
|
}
|
|
542
757
|
|
|
@@ -545,9 +760,41 @@ class PtyManager {
|
|
|
545
760
|
continue;
|
|
546
761
|
}
|
|
547
762
|
|
|
548
|
-
|
|
763
|
+
// Socket- or buffer-level overflow ⇒ the client cannot keep up. Converge
|
|
764
|
+
// it to the current screen via resnapshot instead of dropping bytes (G1).
|
|
765
|
+
if (
|
|
766
|
+
ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES ||
|
|
767
|
+
buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES
|
|
768
|
+
) {
|
|
769
|
+
this.beginResnapshot(terminal, ws);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
buffer.data.push(msg);
|
|
774
|
+
buffer.size += msgSize;
|
|
775
|
+
this.flushOutputBuffer(ws, buffer);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Legacy broadcast path used only when the emulator is disabled
|
|
781
|
+
* (SHOOTER_SNAPSHOT_FALLBACK=raw). Drops the oldest buffered output to make
|
|
782
|
+
* room and notifies the client; there is no snapshot to converge it to.
|
|
783
|
+
*/
|
|
784
|
+
private broadcastOutputLegacy(terminal: ManagedTerminal, msg: string): void {
|
|
785
|
+
const msgSize = Buffer.byteLength(msg, 'utf8');
|
|
786
|
+
for (const ws of terminal.clients) {
|
|
787
|
+
// Skip if the socket has too much queued already.
|
|
788
|
+
if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
|
|
789
|
+
this.safeSend(ws, JSON.stringify({ bytes: msgSize, type: 'output-dropped' }));
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const buffer = terminal.outputBuffers.get(ws);
|
|
794
|
+
if (!buffer) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
549
797
|
|
|
550
|
-
// Check backpressure: if buffer exceeds limit, drop oldest data
|
|
551
798
|
if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
|
|
552
799
|
let droppedBytes = 0;
|
|
553
800
|
while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
|
|
@@ -558,21 +805,13 @@ class PtyManager {
|
|
|
558
805
|
droppedBytes += droppedSize;
|
|
559
806
|
}
|
|
560
807
|
}
|
|
561
|
-
|
|
562
|
-
// Notify client of dropped output
|
|
563
808
|
if (droppedBytes > 0) {
|
|
564
|
-
|
|
565
|
-
bytes: droppedBytes,
|
|
566
|
-
type: 'output-dropped',
|
|
567
|
-
});
|
|
568
|
-
this.safeSend(ws, dropMsg);
|
|
809
|
+
this.safeSend(ws, JSON.stringify({ bytes: droppedBytes, type: 'output-dropped' }));
|
|
569
810
|
}
|
|
570
811
|
}
|
|
571
812
|
|
|
572
|
-
// Buffer the message and attempt to send
|
|
573
813
|
buffer.data.push(msg);
|
|
574
814
|
buffer.size += msgSize;
|
|
575
|
-
|
|
576
815
|
this.flushOutputBuffer(ws, buffer);
|
|
577
816
|
}
|
|
578
817
|
}
|
|
@@ -609,6 +848,10 @@ class PtyManager {
|
|
|
609
848
|
terminal.openCodeNoopCb = null;
|
|
610
849
|
}
|
|
611
850
|
|
|
851
|
+
// Dispose the server-side emulator (frees its parser/buffer state)
|
|
852
|
+
terminal.emulator?.dispose();
|
|
853
|
+
terminal.emulator = null;
|
|
854
|
+
|
|
612
855
|
// Disconnect from holder (but don't kill it — it may already be gone)
|
|
613
856
|
terminal.pty.disconnect();
|
|
614
857
|
|
|
@@ -731,12 +974,14 @@ class PtyManager {
|
|
|
731
974
|
|
|
732
975
|
const terminal: ManagedTerminal = {
|
|
733
976
|
args: parsedArgs,
|
|
977
|
+
authorityConnectionId: null, // Phase 3: claimed by the first interactive resize
|
|
734
978
|
clients: new Set(),
|
|
735
979
|
cols: record.cols,
|
|
736
980
|
command: record.command,
|
|
737
981
|
createdAt: new Date(record.createdAt),
|
|
738
982
|
currentCwd: null,
|
|
739
983
|
cwd: record.cwd,
|
|
984
|
+
emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(record.cols, record.rows) : null,
|
|
740
985
|
exitCode: connectResult.exitCode,
|
|
741
986
|
exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
|
|
742
987
|
holderPid: record.holderPid ?? 0,
|
|
@@ -750,12 +995,22 @@ class PtyManager {
|
|
|
750
995
|
pty: client,
|
|
751
996
|
rows: record.rows,
|
|
752
997
|
scrollback: connectResult.scrollback,
|
|
998
|
+
seqCounter: 0,
|
|
999
|
+
seqRing: [],
|
|
753
1000
|
sessionFile: record.sessionFile ?? null,
|
|
754
1001
|
socketPath: record.socketPath,
|
|
755
1002
|
status: connectResult.exited ? 'exited' : 'running',
|
|
756
1003
|
watcherOffset: 0,
|
|
757
1004
|
};
|
|
758
1005
|
|
|
1006
|
+
// Seed the fresh emulator with the holder's retained scrollback so a
|
|
1007
|
+
// snapshot taken right after a server restart reflects the screen as it was,
|
|
1008
|
+
// not a blank buffer. The scrollback is raw PTY bytes (escape sequences
|
|
1009
|
+
// included), which the emulator parses into the current screen state.
|
|
1010
|
+
if (terminal.emulator && connectResult.scrollback.length > 0) {
|
|
1011
|
+
terminal.emulator.write(connectResult.scrollback);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
759
1014
|
// If the PTY already exited, update SQLite and add to Map for visibility
|
|
760
1015
|
if (connectResult.exited) {
|
|
761
1016
|
terminal.exitedAt = terminal.exitedAt ?? new Date();
|
|
@@ -1093,6 +1348,7 @@ class PtyManager {
|
|
|
1093
1348
|
});
|
|
1094
1349
|
|
|
1095
1350
|
client.onOutput((data: string) => {
|
|
1351
|
+
terminal.emulator?.write(data);
|
|
1096
1352
|
this.appendScrollback(terminal, data);
|
|
1097
1353
|
this.broadcastOutput(terminal, data);
|
|
1098
1354
|
});
|
|
@@ -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
|
+
}
|
|
@@ -172,6 +172,16 @@ export class TerminalStore {
|
|
|
172
172
|
.run(new Date().toISOString(), id);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Phase 3: persist the current PTY dimensions so a server restart restores the
|
|
177
|
+
* latest size rather than the creation-time default (fixes G5). Dedicated
|
|
178
|
+
* prepared UPDATE — called on every authoritative resize, so it avoids the
|
|
179
|
+
* generic update()'s Object.entries() overhead on this hot path.
|
|
180
|
+
*/
|
|
181
|
+
resizeDims(id: string, cols: number, rows: number): void {
|
|
182
|
+
this.db.prepare('UPDATE terminals SET cols = ?, rows = ? WHERE id = ?').run(cols, rows, id);
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
update(id: string, fields: Partial<TerminalRecord>): void {
|
|
176
186
|
const entries = Object.entries(fields).filter(
|
|
177
187
|
([key, val]) => key !== 'id' && val !== undefined
|