@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.
Files changed (139) hide show
  1. package/build/client/_app/immutable/chunks/CbINytmr.js +3 -0
  2. package/build/client/_app/immutable/chunks/CbINytmr.js.br +0 -0
  3. package/build/client/_app/immutable/chunks/CbINytmr.js.gz +0 -0
  4. package/build/client/_app/immutable/chunks/D868VwmX.js +6 -0
  5. package/build/client/_app/immutable/chunks/D868VwmX.js.br +0 -0
  6. package/build/client/_app/immutable/chunks/D868VwmX.js.gz +0 -0
  7. package/build/client/_app/immutable/chunks/{CZg4kn4E.js → Dd1KNHg-.js} +1 -1
  8. package/build/client/_app/immutable/chunks/Dd1KNHg-.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/Dd1KNHg-.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/{DhK7PwI_.js → V8pbM9cl.js} +1 -1
  11. package/build/client/_app/immutable/chunks/V8pbM9cl.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/V8pbM9cl.js.gz +0 -0
  13. package/build/client/_app/immutable/entry/{app.CTqz33nP.js → app.CiQHPW0j.js} +2 -2
  14. package/build/client/_app/immutable/entry/app.CiQHPW0j.js.br +0 -0
  15. package/build/client/_app/immutable/entry/app.CiQHPW0j.js.gz +0 -0
  16. package/build/client/_app/immutable/entry/start.DUCXuMLl.js +1 -0
  17. package/build/client/_app/immutable/entry/start.DUCXuMLl.js.br +2 -0
  18. package/build/client/_app/immutable/entry/start.DUCXuMLl.js.gz +0 -0
  19. package/build/client/_app/immutable/nodes/{0.Qn7Ktiht.js → 0.BRFdS_ay.js} +1 -1
  20. package/build/client/_app/immutable/nodes/0.BRFdS_ay.js.br +0 -0
  21. package/build/client/_app/immutable/nodes/0.BRFdS_ay.js.gz +0 -0
  22. package/build/client/_app/immutable/nodes/{1.BxWOfNlo.js → 1.B1pgwYu3.js} +1 -1
  23. package/build/client/_app/immutable/nodes/1.B1pgwYu3.js.br +0 -0
  24. package/build/client/_app/immutable/nodes/1.B1pgwYu3.js.gz +0 -0
  25. package/build/client/_app/immutable/nodes/{10.BGPYD1s1.js → 10.558mUFIl.js} +1 -1
  26. package/build/client/_app/immutable/nodes/10.558mUFIl.js.br +0 -0
  27. package/build/client/_app/immutable/nodes/10.558mUFIl.js.gz +0 -0
  28. package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js → 11.CdmPyt4k.js} +2 -2
  29. package/build/client/_app/immutable/nodes/11.CdmPyt4k.js.br +0 -0
  30. package/build/client/_app/immutable/nodes/{11.BxY1PUjC.js.gz → 11.CdmPyt4k.js.gz} +0 -0
  31. package/build/client/_app/immutable/nodes/{2.Bc2qALkX.js → 2.1tiK5o4L.js} +1 -1
  32. package/build/client/_app/immutable/nodes/2.1tiK5o4L.js.br +0 -0
  33. package/build/client/_app/immutable/nodes/2.1tiK5o4L.js.gz +0 -0
  34. package/build/client/_app/immutable/nodes/{3.N2-A8noI.js → 3.DyQTorXE.js} +1 -1
  35. package/build/client/_app/immutable/nodes/3.DyQTorXE.js.br +0 -0
  36. package/build/client/_app/immutable/nodes/3.DyQTorXE.js.gz +0 -0
  37. package/build/client/_app/immutable/nodes/{6.BWF9Qx6F.js → 6.Chn2ZM2V.js} +1 -1
  38. package/build/client/_app/immutable/nodes/6.Chn2ZM2V.js.br +0 -0
  39. package/build/client/_app/immutable/nodes/6.Chn2ZM2V.js.gz +0 -0
  40. package/build/client/_app/immutable/nodes/{7.DHuDIdpz.js → 7.DhJ2K3GQ.js} +1 -1
  41. package/build/client/_app/immutable/nodes/7.DhJ2K3GQ.js.br +0 -0
  42. package/build/client/_app/immutable/nodes/7.DhJ2K3GQ.js.gz +0 -0
  43. package/build/client/_app/immutable/nodes/{8.D0Ijt9Vv.js → 8.B4pLxBkI.js} +1 -1
  44. package/build/client/_app/immutable/nodes/8.B4pLxBkI.js.br +0 -0
  45. package/build/client/_app/immutable/nodes/8.B4pLxBkI.js.gz +0 -0
  46. package/build/client/_app/immutable/nodes/{9.2Piwo35J.js → 9.CVsskPw5.js} +1 -1
  47. package/build/client/_app/immutable/nodes/9.CVsskPw5.js.br +0 -0
  48. package/build/client/_app/immutable/nodes/9.CVsskPw5.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-DZO0pCuJ.js} +2 -2
  53. package/build/server/chunks/{0-CVGsyVKN.js.map → 0-DZO0pCuJ.js.map} +1 -1
  54. package/build/server/chunks/{1-BAlAsKdp.js → 1-D2SDQFeq.js} +2 -2
  55. package/build/server/chunks/{1-BAlAsKdp.js.map → 1-D2SDQFeq.js.map} +1 -1
  56. package/build/server/chunks/{10-BUCX7Aqz.js → 10-CEJDEhpQ.js} +2 -2
  57. package/build/server/chunks/{10-BUCX7Aqz.js.map → 10-CEJDEhpQ.js.map} +1 -1
  58. package/build/server/chunks/{11-DHPvc2yA.js → 11-CMC_i3co.js} +2 -2
  59. package/build/server/chunks/{11-DHPvc2yA.js.map → 11-CMC_i3co.js.map} +1 -1
  60. package/build/server/chunks/{2-DLOMdCHW.js → 2-C1XSBNj7.js} +2 -2
  61. package/build/server/chunks/{2-DLOMdCHW.js.map → 2-C1XSBNj7.js.map} +1 -1
  62. package/build/server/chunks/{3-DCf69LYo.js → 3-DRjTDzaV.js} +2 -2
  63. package/build/server/chunks/{3-DCf69LYo.js.map → 3-DRjTDzaV.js.map} +1 -1
  64. package/build/server/chunks/{6-DUrC2Naz.js → 6-BcgshtK4.js} +2 -2
  65. package/build/server/chunks/{6-DUrC2Naz.js.map → 6-BcgshtK4.js.map} +1 -1
  66. package/build/server/chunks/{7-TXwjMHt2.js → 7-BBsuxiGz.js} +2 -2
  67. package/build/server/chunks/{7-TXwjMHt2.js.map → 7-BBsuxiGz.js.map} +1 -1
  68. package/build/server/chunks/{8-D2X_jBsT.js → 8-B0qM-Zzs.js} +2 -2
  69. package/build/server/chunks/{8-D2X_jBsT.js.map → 8-B0qM-Zzs.js.map} +1 -1
  70. package/build/server/chunks/{9-DK0hH5Xa.js → 9-XIfsp2D_.js} +2 -2
  71. package/build/server/chunks/{9-DK0hH5Xa.js.map → 9-XIfsp2D_.js.map} +1 -1
  72. package/build/server/chunks/{_server.ts-DiBMY7Ho.js → _server.ts-B-Gekwsu.js} +3 -2
  73. package/build/server/chunks/_server.ts-B-Gekwsu.js.map +1 -0
  74. package/build/server/chunks/{_server.ts-C0PO_cAu.js → _server.ts-BhP3b8A5.js} +3 -2
  75. package/build/server/chunks/{_server.ts-C0PO_cAu.js.map → _server.ts-BhP3b8A5.js.map} +1 -1
  76. package/build/server/chunks/{_server.ts-Bol54_Qo.js → _server.ts-CTdFxJdD.js} +3 -2
  77. package/build/server/chunks/_server.ts-CTdFxJdD.js.map +1 -0
  78. package/build/server/chunks/{_server.ts-B54Pvhgc.js → _server.ts-Cx0S__hk.js} +3 -2
  79. package/build/server/chunks/_server.ts-Cx0S__hk.js.map +1 -0
  80. package/build/server/chunks/{_server.ts-CZb-BI5H.js → _server.ts-DtT-ZXki.js} +3 -2
  81. package/build/server/chunks/_server.ts-DtT-ZXki.js.map +1 -0
  82. package/build/server/chunks/{pty-manager-CoWVT56F.js → pty-manager-BsHXoNks.js} +287 -27
  83. package/build/server/chunks/pty-manager-BsHXoNks.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 +5 -2
  90. package/src/lib/modules/client/terminal/xterm-wrapper.ts +56 -15
  91. package/src/lib/modules/server/terminal/pty-manager.ts +291 -35
  92. package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -0
  93. package/src/lib/modules/server/terminal/terminal-store.ts +10 -0
  94. package/src/lib/modules/server/ws/server.ts +18 -2
  95. package/src/lib/modules/server/ws/terminal-handler.ts +60 -14
  96. package/src/lib/types/generated/WsProtocol.ts +10 -1
  97. package/src/lib/types/server.ts +34 -1
  98. package/src/lib/types/terminal-client.ts +3 -0
  99. package/src/lib/types/ws.ts +7 -2
  100. package/src/routes/api/terminals/[id]/resize/+server.ts +3 -0
  101. package/build/client/_app/immutable/chunks/BfbPKMXz.js +0 -3
  102. package/build/client/_app/immutable/chunks/BfbPKMXz.js.br +0 -0
  103. package/build/client/_app/immutable/chunks/BfbPKMXz.js.gz +0 -0
  104. package/build/client/_app/immutable/chunks/CZg4kn4E.js.br +0 -0
  105. package/build/client/_app/immutable/chunks/CZg4kn4E.js.gz +0 -0
  106. package/build/client/_app/immutable/chunks/DhK7PwI_.js.br +0 -0
  107. package/build/client/_app/immutable/chunks/DhK7PwI_.js.gz +0 -0
  108. package/build/client/_app/immutable/chunks/J5-Cr5oR.js +0 -6
  109. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.br +0 -0
  110. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.gz +0 -0
  111. package/build/client/_app/immutable/entry/app.CTqz33nP.js.br +0 -0
  112. package/build/client/_app/immutable/entry/app.CTqz33nP.js.gz +0 -0
  113. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js +0 -1
  114. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.br +0 -2
  115. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.gz +0 -0
  116. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.br +0 -0
  117. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.gz +0 -0
  118. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.br +0 -0
  119. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.gz +0 -0
  120. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.br +0 -0
  121. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.gz +0 -0
  122. package/build/client/_app/immutable/nodes/11.BxY1PUjC.js.br +0 -0
  123. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.br +0 -0
  124. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.gz +0 -0
  125. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.br +0 -0
  126. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.gz +0 -0
  127. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.br +0 -0
  128. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.gz +0 -0
  129. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.br +0 -0
  130. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.gz +0 -0
  131. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.br +0 -0
  132. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.br +0 -0
  134. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.gz +0 -0
  135. package/build/server/chunks/_server.ts-B54Pvhgc.js.map +0 -1
  136. package/build/server/chunks/_server.ts-Bol54_Qo.js.map +0 -1
  137. package/build/server/chunks/_server.ts-CZb-BI5H.js.map +0 -1
  138. package/build/server/chunks/_server.ts-DiBMY7Ho.js.map +0 -1
  139. 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
- // Send cached scrollback in chunks
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
- // disconnectAllgraceful shutdown: disconnect clients, keep holders alive
185
+ // reconnectAllrecover 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
- // get
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
- // list — running first, then recently exited, each group sorted by
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
- // killroute through holder: SIGTERM, then SIGKILL after 5 s
384
+ // listrunning 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
- // removeremove an exited terminal from the map
420
+ // killroute 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
- // resize
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
- // attach — register a WebSocket client and replay scrollback
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
- // detachremove a WebSocket client
520
+ // attachregister 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
- // getScrollbackreturn raw scrollback data for replay
542
+ // detachremove 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
- // cleanupevict exited terminals older than 1 hour, cap at 10 exited;
466
- // also clean up old SQLite records
566
+ // getScrollbackreturn 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
- // destroyemergency forced kill (kills holder processes too)
583
+ // cleanupevict 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
- const msg = JSON.stringify({ data, type: 'output' });
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
- // 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' }));
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
- const msgSize = Buffer.byteLength(msg, 'utf8');
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
- const dropMsg = JSON.stringify({
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