@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.
Files changed (136) hide show
  1. package/build/client/_app/immutable/chunks/Bj5wFimK.js +3 -0
  2. package/build/client/_app/immutable/chunks/Bj5wFimK.js.br +0 -0
  3. package/build/client/_app/immutable/chunks/Bj5wFimK.js.gz +0 -0
  4. package/build/client/_app/immutable/chunks/{DhK7PwI_.js → BjYr_-Ss.js} +1 -1
  5. package/build/client/_app/immutable/chunks/BjYr_-Ss.js.br +0 -0
  6. package/build/client/_app/immutable/chunks/{DhK7PwI_.js.gz → BjYr_-Ss.js.gz} +0 -0
  7. package/build/client/_app/immutable/chunks/DULfdsh6.js +6 -0
  8. package/build/client/_app/immutable/chunks/DULfdsh6.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/DULfdsh6.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/{CZg4kn4E.js → fcNfTA-E.js} +1 -1
  11. package/build/client/_app/immutable/chunks/fcNfTA-E.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/fcNfTA-E.js.gz +0 -0
  13. package/build/client/_app/immutable/entry/{app.CTqz33nP.js → app.Bvoqymnp.js} +2 -2
  14. package/build/client/_app/immutable/entry/app.Bvoqymnp.js.br +0 -0
  15. package/build/client/_app/immutable/entry/app.Bvoqymnp.js.gz +0 -0
  16. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js +1 -0
  17. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.br +2 -0
  18. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.gz +0 -0
  19. package/build/client/_app/immutable/nodes/{0.Qn7Ktiht.js → 0.Bv_TwEnq.js} +1 -1
  20. package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.br +0 -0
  21. package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.gz +0 -0
  22. package/build/client/_app/immutable/nodes/{1.BxWOfNlo.js → 1.7lffTIeb.js} +1 -1
  23. package/build/client/_app/immutable/nodes/1.7lffTIeb.js.br +0 -0
  24. package/build/client/_app/immutable/nodes/1.7lffTIeb.js.gz +0 -0
  25. package/build/client/_app/immutable/nodes/{10.BGPYD1s1.js → 10.ChiIrIDl.js} +1 -1
  26. package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.br +0 -0
  27. package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.gz +0 -0
  28. package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js → 11.DO3vyXEv.js} +2 -2
  29. package/build/client/_app/immutable/nodes/11.DO3vyXEv.js.br +0 -0
  30. package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js.gz → 11.DO3vyXEv.js.gz} +0 -0
  31. package/build/client/_app/immutable/nodes/{2.Bc2qALkX.js → 2.iMIqsE7n.js} +1 -1
  32. package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.br +0 -0
  33. package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.gz +0 -0
  34. package/build/client/_app/immutable/nodes/{3.N2-A8noI.js → 3.CArnSHOO.js} +1 -1
  35. package/build/client/_app/immutable/nodes/3.CArnSHOO.js.br +0 -0
  36. package/build/client/_app/immutable/nodes/3.CArnSHOO.js.gz +0 -0
  37. package/build/client/_app/immutable/nodes/{6.BWF9Qx6F.js → 6.B8l1RwkB.js} +1 -1
  38. package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.br +0 -0
  39. package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.gz +0 -0
  40. package/build/client/_app/immutable/nodes/{7.DHuDIdpz.js → 7.BPyfhDis.js} +1 -1
  41. package/build/client/_app/immutable/nodes/7.BPyfhDis.js.br +0 -0
  42. package/build/client/_app/immutable/nodes/7.BPyfhDis.js.gz +0 -0
  43. package/build/client/_app/immutable/nodes/{8.D0Ijt9Vv.js → 8.D_vszZ9E.js} +1 -1
  44. package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.br +0 -0
  45. package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.gz +0 -0
  46. package/build/client/_app/immutable/nodes/{9.2Piwo35J.js → 9.Drah-do-.js} +1 -1
  47. package/build/client/_app/immutable/nodes/9.Drah-do-.js.br +0 -0
  48. package/build/client/_app/immutable/nodes/9.Drah-do-.js.gz +0 -0
  49. package/build/client/_app/version.json +1 -1
  50. package/build/client/_app/version.json.br +0 -0
  51. package/build/client/_app/version.json.gz +0 -0
  52. package/build/server/chunks/{0-CVGsyVKN.js → 0-DAB_6Vm1.js} +2 -2
  53. package/build/server/chunks/{0-CVGsyVKN.js.map → 0-DAB_6Vm1.js.map} +1 -1
  54. package/build/server/chunks/{1-BAlAsKdp.js → 1-D-qMYaCx.js} +2 -2
  55. package/build/server/chunks/{1-BAlAsKdp.js.map → 1-D-qMYaCx.js.map} +1 -1
  56. package/build/server/chunks/{10-BUCX7Aqz.js → 10-CeFFGo-X.js} +2 -2
  57. package/build/server/chunks/{10-BUCX7Aqz.js.map → 10-CeFFGo-X.js.map} +1 -1
  58. package/build/server/chunks/{11-DHPvc2yA.js → 11-DRMu_ATU.js} +2 -2
  59. package/build/server/chunks/{11-DHPvc2yA.js.map → 11-DRMu_ATU.js.map} +1 -1
  60. package/build/server/chunks/{2-DLOMdCHW.js → 2-B7OLBMNH.js} +2 -2
  61. package/build/server/chunks/{2-DLOMdCHW.js.map → 2-B7OLBMNH.js.map} +1 -1
  62. package/build/server/chunks/{3-DCf69LYo.js → 3-B38ZarLw.js} +2 -2
  63. package/build/server/chunks/{3-DCf69LYo.js.map → 3-B38ZarLw.js.map} +1 -1
  64. package/build/server/chunks/{6-DUrC2Naz.js → 6-DP46cUej.js} +2 -2
  65. package/build/server/chunks/{6-DUrC2Naz.js.map → 6-DP46cUej.js.map} +1 -1
  66. package/build/server/chunks/{7-TXwjMHt2.js → 7-B29_3ar6.js} +2 -2
  67. package/build/server/chunks/{7-TXwjMHt2.js.map → 7-B29_3ar6.js.map} +1 -1
  68. package/build/server/chunks/{8-D2X_jBsT.js → 8-DCnSDVrX.js} +2 -2
  69. package/build/server/chunks/{8-D2X_jBsT.js.map → 8-DCnSDVrX.js.map} +1 -1
  70. package/build/server/chunks/{9-DK0hH5Xa.js → 9-BwqDc8wC.js} +2 -2
  71. package/build/server/chunks/{9-DK0hH5Xa.js.map → 9-BwqDc8wC.js.map} +1 -1
  72. package/build/server/chunks/{_server.ts-B54Pvhgc.js → _server.ts-Blx6TuRU.js} +3 -2
  73. package/build/server/chunks/_server.ts-Blx6TuRU.js.map +1 -0
  74. package/build/server/chunks/{_server.ts-C0PO_cAu.js → _server.ts-CYWXjihn.js} +3 -2
  75. package/build/server/chunks/{_server.ts-C0PO_cAu.js.map → _server.ts-CYWXjihn.js.map} +1 -1
  76. package/build/server/chunks/{_server.ts-DiBMY7Ho.js → _server.ts-D0___krA.js} +3 -2
  77. package/build/server/chunks/_server.ts-D0___krA.js.map +1 -0
  78. package/build/server/chunks/{_server.ts-CZb-BI5H.js → _server.ts-Da1kSClZ.js} +3 -2
  79. package/build/server/chunks/_server.ts-Da1kSClZ.js.map +1 -0
  80. package/build/server/chunks/{_server.ts-Bol54_Qo.js → _server.ts-l3cd4Cto.js} +3 -2
  81. package/build/server/chunks/_server.ts-l3cd4Cto.js.map +1 -0
  82. package/build/server/chunks/{pty-manager-CoWVT56F.js → pty-manager-DDjG7DlH.js} +272 -27
  83. package/build/server/chunks/pty-manager-DDjG7DlH.js.map +1 -0
  84. package/build/server/index.js +1 -1
  85. package/build/server/index.js.map +1 -1
  86. package/build/server/manifest.js +16 -16
  87. package/build/server/manifest.js.map +1 -1
  88. package/package.json +4 -2
  89. package/server.ts +2 -2
  90. package/src/lib/modules/client/terminal/xterm-wrapper.ts +52 -12
  91. package/src/lib/modules/server/terminal/pty-manager.ts +279 -35
  92. package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -0
  93. package/src/lib/modules/server/ws/server.ts +18 -2
  94. package/src/lib/modules/server/ws/terminal-handler.ts +11 -6
  95. package/src/lib/types/generated/WsProtocol.ts +10 -1
  96. package/src/lib/types/server.ts +27 -1
  97. package/src/lib/types/terminal-client.ts +3 -0
  98. package/src/lib/types/ws.ts +3 -2
  99. package/build/client/_app/immutable/chunks/BfbPKMXz.js +0 -3
  100. package/build/client/_app/immutable/chunks/BfbPKMXz.js.br +0 -0
  101. package/build/client/_app/immutable/chunks/BfbPKMXz.js.gz +0 -0
  102. package/build/client/_app/immutable/chunks/CZg4kn4E.js.br +0 -0
  103. package/build/client/_app/immutable/chunks/CZg4kn4E.js.gz +0 -0
  104. package/build/client/_app/immutable/chunks/DhK7PwI_.js.br +0 -0
  105. package/build/client/_app/immutable/chunks/J5-Cr5oR.js +0 -6
  106. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.br +0 -0
  107. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.gz +0 -0
  108. package/build/client/_app/immutable/entry/app.CTqz33nP.js.br +0 -0
  109. package/build/client/_app/immutable/entry/app.CTqz33nP.js.gz +0 -0
  110. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js +0 -1
  111. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.br +0 -2
  112. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.gz +0 -0
  113. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.br +0 -0
  114. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.gz +0 -0
  115. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.br +0 -0
  116. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.gz +0 -0
  117. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.br +0 -0
  118. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.gz +0 -0
  119. package/build/client/_app/immutable/nodes/11.BxY1PUjC.js.br +0 -0
  120. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.br +0 -0
  121. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.gz +0 -0
  122. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.br +0 -0
  123. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.gz +0 -0
  124. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.br +0 -0
  125. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.gz +0 -0
  126. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.br +0 -0
  127. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.gz +0 -0
  128. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.br +0 -0
  129. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.gz +0 -0
  130. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.br +0 -0
  131. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.gz +0 -0
  132. package/build/server/chunks/_server.ts-B54Pvhgc.js.map +0 -1
  133. package/build/server/chunks/_server.ts-Bol54_Qo.js.map +0 -1
  134. package/build/server/chunks/_server.ts-CZb-BI5H.js.map +0 -1
  135. package/build/server/chunks/_server.ts-DiBMY7Ho.js.map +0 -1
  136. 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
- // Send cached scrollback in chunks
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
- // disconnectAllgraceful shutdown: disconnect clients, keep holders alive
178
+ // reconnectAllrecover 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
- // get
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
- // list — running first, then recently exited, each group sorted by
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
- // killroute through holder: SIGTERM, then SIGKILL after 5 s
376
+ // listrunning 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
- // removeremove an exited terminal from the map
412
+ // killroute 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
- // resize
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
- // attach — register a WebSocket client and replay scrollback
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
- // detachremove a WebSocket client
512
+ // attachregister 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
- // getScrollbackreturn raw scrollback data for replay
534
+ // detachremove 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
- // cleanupevict exited terminals older than 1 hour, cap at 10 exited;
466
- // also clean up old SQLite records
558
+ // getScrollbackreturn 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
- // destroyemergency forced kill (kills holder processes too)
575
+ // cleanupevict 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
- const msg = JSON.stringify({ data, type: 'output' });
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
- // Skip if WebSocket has too much queued already
538
- if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
539
- this.safeSend(ws, JSON.stringify({ bytes: data.length, type: 'output-dropped' }));
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
- const msgSize = Buffer.byteLength(msg, 'utf8');
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
- const dropMsg = JSON.stringify({
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
- pathname = new URL(request.url || '/', `http://${host}`).pathname;
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);