@johnhenry/wsh 0.0.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.
@@ -0,0 +1,3 @@
1
+ // Auto-generated messages — DO NOT EDIT messages.gen.mjs directly
2
+ // Edit web/packages/wsh/spec/wsh-v1.yaml and run: node web/packages/wsh/spec/codegen.mjs
3
+ export * from './messages.gen.mjs';
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Session recording and playback for wsh PTY sessions.
3
+ *
4
+ * Records all terminal I/O with timestamps for later replay.
5
+ * The export format is compatible with the asciicast v2 structure
6
+ * used by asciinema, enabling interop with standard terminal players.
7
+ */
8
+
9
+ // ── SessionRecorder ──────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Records PTY I/O events with relative timestamps for session replay.
13
+ */
14
+ export class SessionRecorder {
15
+ /** @type {string} */
16
+ sessionId;
17
+
18
+ /** @type {number} Unix epoch ms when recording started. */
19
+ startTime;
20
+
21
+ /** @type {Array<{ t: number, type: string, data: * }>} */
22
+ entries = [];
23
+
24
+ /** @type {number} Terminal width at recording start. */
25
+ #width = 80;
26
+
27
+ /** @type {number} Terminal height at recording start. */
28
+ #height = 24;
29
+
30
+ /**
31
+ * @param {string} sessionId - Unique identifier for the recorded session
32
+ * @param {object} [opts]
33
+ * @param {number} [opts.width=80] - Initial terminal width
34
+ * @param {number} [opts.height=24] - Initial terminal height
35
+ */
36
+ constructor(sessionId, { width = 80, height = 24 } = {}) {
37
+ if (!sessionId) throw new Error('sessionId is required');
38
+ this.sessionId = sessionId;
39
+ this.startTime = Date.now();
40
+ this.entries = [];
41
+ this.#width = width;
42
+ this.#height = height;
43
+ }
44
+
45
+ /**
46
+ * Record an event.
47
+ *
48
+ * @param {'input' | 'output' | 'resize' | 'open' | 'exit'} type - Event type
49
+ * @param {*} data - Event payload:
50
+ * - input/output: string of terminal data
51
+ * - resize: { cols, rows }
52
+ * - open: { command?, env? }
53
+ * - exit: { code }
54
+ */
55
+ record(type, data) {
56
+ const t = Date.now() - this.startTime;
57
+ this.entries.push({ t, type, data });
58
+
59
+ // Track terminal dimensions on resize
60
+ if (type === 'resize' && data) {
61
+ if (data.cols) this.#width = data.cols;
62
+ if (data.rows) this.#height = data.rows;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Export the recording as a JSON-serializable object.
68
+ *
69
+ * The format is compatible with asciicast v2:
70
+ * - version: 2
71
+ * - width, height: terminal dimensions at start
72
+ * - timestamp: Unix epoch seconds when recording started
73
+ * - env: optional environment metadata
74
+ * - events: array of [time_seconds, event_type, data]
75
+ *
76
+ * Event types in asciicast:
77
+ * "o" = output, "i" = input, "r" = resize
78
+ * We extend with: "open", "exit" for session lifecycle.
79
+ *
80
+ * @returns {object}
81
+ */
82
+ toJSON() {
83
+ const events = this.entries.map(({ t, type, data }) => {
84
+ const timeSec = t / 1000;
85
+ const eventType = ASCIICAST_TYPE_MAP[type] || type;
86
+
87
+ // Serialize data depending on type
88
+ let eventData;
89
+ if (type === 'resize' && data) {
90
+ eventData = `${data.cols}x${data.rows}`;
91
+ } else if (type === 'exit' && data) {
92
+ eventData = String(data.code ?? 0);
93
+ } else if (type === 'open' && data) {
94
+ eventData = JSON.stringify(data);
95
+ } else {
96
+ eventData = typeof data === 'string' ? data : JSON.stringify(data);
97
+ }
98
+
99
+ return [timeSec, eventType, eventData];
100
+ });
101
+
102
+ return {
103
+ version: 2,
104
+ width: this.#width,
105
+ height: this.#height,
106
+ timestamp: Math.floor(this.startTime / 1000),
107
+ env: { TERM: 'xterm-256color', SHELL: '/bin/bash' },
108
+ sessionId: this.sessionId,
109
+ events,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Import a recording from JSON (as produced by toJSON or parsed from asciicast).
115
+ *
116
+ * @param {object | string} json - Recording object or JSON string
117
+ * @returns {SessionRecorder}
118
+ */
119
+ static fromJSON(json) {
120
+ const data = typeof json === 'string' ? JSON.parse(json) : json;
121
+
122
+ if (!data.version || data.version < 2) {
123
+ throw new Error(`Unsupported recording version: ${data.version}`);
124
+ }
125
+
126
+ const recorder = new SessionRecorder(data.sessionId || 'imported', {
127
+ width: data.width || 80,
128
+ height: data.height || 24,
129
+ });
130
+
131
+ recorder.startTime = (data.timestamp || Math.floor(Date.now() / 1000)) * 1000;
132
+ recorder.entries = [];
133
+
134
+ if (Array.isArray(data.events)) {
135
+ for (const event of data.events) {
136
+ if (!Array.isArray(event) || event.length < 3) continue;
137
+
138
+ const [timeSec, eventType, eventData] = event;
139
+ const t = Math.round(timeSec * 1000);
140
+ const type = REVERSE_TYPE_MAP[eventType] || eventType;
141
+
142
+ // Parse data back into structured form
143
+ let parsedData;
144
+ if (type === 'resize' && typeof eventData === 'string') {
145
+ const match = eventData.match(/^(\d+)x(\d+)$/);
146
+ parsedData = match
147
+ ? { cols: parseInt(match[1], 10), rows: parseInt(match[2], 10) }
148
+ : eventData;
149
+ } else if (type === 'exit') {
150
+ parsedData = { code: parseInt(eventData, 10) || 0 };
151
+ } else if (type === 'open') {
152
+ try {
153
+ parsedData = JSON.parse(eventData);
154
+ } catch {
155
+ parsedData = eventData;
156
+ }
157
+ } else {
158
+ parsedData = eventData;
159
+ }
160
+
161
+ recorder.entries.push({ t, type, data: parsedData });
162
+ }
163
+ }
164
+
165
+ return recorder;
166
+ }
167
+
168
+ /**
169
+ * Get the total duration of the recording in milliseconds.
170
+ * @returns {number}
171
+ */
172
+ get duration() {
173
+ if (this.entries.length === 0) return 0;
174
+ return this.entries[this.entries.length - 1].t;
175
+ }
176
+
177
+ /**
178
+ * Get the number of recorded events.
179
+ * @returns {number}
180
+ */
181
+ get length() {
182
+ return this.entries.length;
183
+ }
184
+ }
185
+
186
+ // Asciicast type mapping
187
+ const ASCIICAST_TYPE_MAP = {
188
+ output: 'o',
189
+ input: 'i',
190
+ resize: 'r',
191
+ open: 'open',
192
+ exit: 'exit',
193
+ };
194
+
195
+ const REVERSE_TYPE_MAP = {
196
+ o: 'output',
197
+ i: 'input',
198
+ r: 'resize',
199
+ };
200
+
201
+ // ── SessionPlayer ────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Replays a recorded session with original timing.
205
+ */
206
+ export class SessionPlayer {
207
+ /** @type {object} The recording data (from toJSON / fromJSON). */
208
+ #recording;
209
+
210
+ /** @type {Array<{ t: number, type: string, data: * }>} Parsed entries. */
211
+ #entries;
212
+
213
+ /**
214
+ * @param {object} recording - Recording from SessionRecorder.toJSON() or fromJSON().toJSON()
215
+ */
216
+ constructor(recording) {
217
+ if (!recording) throw new Error('Recording is required');
218
+
219
+ // Accept either a SessionRecorder instance or plain JSON object
220
+ const data = recording.toJSON ? recording.toJSON() : recording;
221
+ this.#recording = data;
222
+
223
+ // Pre-parse events back into internal format
224
+ this.#entries = (data.events || []).map(([timeSec, eventType, eventData]) => ({
225
+ t: Math.round(timeSec * 1000),
226
+ type: REVERSE_TYPE_MAP[eventType] || eventType,
227
+ data: eventData,
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * Get the recording metadata.
233
+ * @returns {{ width: number, height: number, duration: number, eventCount: number }}
234
+ */
235
+ get metadata() {
236
+ const lastEntry = this.#entries[this.#entries.length - 1];
237
+ return {
238
+ width: this.#recording.width,
239
+ height: this.#recording.height,
240
+ duration: lastEntry ? lastEntry.t : 0,
241
+ eventCount: this.#entries.length,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Replay the recording with original timing.
247
+ *
248
+ * Calls `onData(data)` for each output event at the recorded time intervals,
249
+ * adjusted by the speed multiplier.
250
+ *
251
+ * @param {function(string): void} onData - Callback for output data
252
+ * @param {object} [opts]
253
+ * @param {number} [opts.speed=1] - Playback speed multiplier (2 = double speed)
254
+ * @param {function(string, *): void} [opts.onEvent] - Callback for all events (type, data)
255
+ * @returns {{ pause: function, resume: function, stop: function, seek: function(number) }}
256
+ */
257
+ play(onData, { speed = 1, onEvent } = {}) {
258
+ if (typeof onData !== 'function') {
259
+ throw new Error('onData callback is required');
260
+ }
261
+ if (speed <= 0) {
262
+ throw new Error('Speed must be positive');
263
+ }
264
+
265
+ let currentIndex = 0;
266
+ let paused = false;
267
+ let stopped = false;
268
+ let timerId = null;
269
+ let pauseTime = 0; // When playback was paused (relative ms)
270
+ let playbackStart = 0; // When playback began (performance.now)
271
+ let timeOffset = 0; // Accumulated pause time offset
272
+
273
+ const entries = this.#entries;
274
+
275
+ const getElapsed = () => {
276
+ return (performance.now() - playbackStart - timeOffset) * speed;
277
+ };
278
+
279
+ const scheduleNext = () => {
280
+ if (stopped || paused || currentIndex >= entries.length) return;
281
+
282
+ const entry = entries[currentIndex];
283
+ const elapsed = getElapsed();
284
+ const delay = Math.max(0, (entry.t - elapsed) / speed);
285
+
286
+ timerId = setTimeout(() => {
287
+ if (stopped || paused) return;
288
+
289
+ const { type, data } = entries[currentIndex];
290
+ currentIndex++;
291
+
292
+ // Deliver output data
293
+ if (type === 'output' || type === 'o') {
294
+ try {
295
+ onData(data);
296
+ } catch { /* ignore callback errors */ }
297
+ }
298
+
299
+ // Deliver all events if listener registered
300
+ if (onEvent) {
301
+ try {
302
+ onEvent(type, data);
303
+ } catch { /* ignore callback errors */ }
304
+ }
305
+
306
+ // Schedule the next event
307
+ scheduleNext();
308
+ }, delay);
309
+ };
310
+
311
+ // Start playback
312
+ playbackStart = performance.now();
313
+ scheduleNext();
314
+
315
+ // Return playback controller
316
+ const controller = {
317
+ /**
318
+ * Pause playback.
319
+ */
320
+ pause() {
321
+ if (paused || stopped) return;
322
+ paused = true;
323
+ pauseTime = performance.now();
324
+ if (timerId !== null) {
325
+ clearTimeout(timerId);
326
+ timerId = null;
327
+ }
328
+ },
329
+
330
+ /**
331
+ * Resume playback after pause.
332
+ */
333
+ resume() {
334
+ if (!paused || stopped) return;
335
+ paused = false;
336
+ timeOffset += performance.now() - pauseTime;
337
+ scheduleNext();
338
+ },
339
+
340
+ /**
341
+ * Stop playback entirely.
342
+ */
343
+ stop() {
344
+ stopped = true;
345
+ paused = false;
346
+ if (timerId !== null) {
347
+ clearTimeout(timerId);
348
+ timerId = null;
349
+ }
350
+ },
351
+
352
+ /**
353
+ * Seek to a specific time in the recording.
354
+ *
355
+ * Immediately replays all output events up to the target time,
356
+ * then resumes normal timed playback from that point.
357
+ *
358
+ * @param {number} timeMs - Target time in milliseconds from start
359
+ */
360
+ seek(timeMs) {
361
+ if (stopped) return;
362
+ if (timeMs < 0) timeMs = 0;
363
+
364
+ // Cancel any pending timer
365
+ if (timerId !== null) {
366
+ clearTimeout(timerId);
367
+ timerId = null;
368
+ }
369
+
370
+ // Find the entry index at or just past the target time
371
+ let seekIndex = 0;
372
+ for (let i = 0; i < entries.length; i++) {
373
+ if (entries[i].t > timeMs) break;
374
+ seekIndex = i + 1;
375
+
376
+ // Replay output events up to the seek point
377
+ const { type, data } = entries[i];
378
+ if (type === 'output' || type === 'o') {
379
+ try {
380
+ onData(data);
381
+ } catch { /* ignore */ }
382
+ }
383
+ }
384
+
385
+ currentIndex = seekIndex;
386
+
387
+ // Reset timing to match the seek position
388
+ playbackStart = performance.now();
389
+ timeOffset = 0;
390
+ // Adjust so elapsed time starts at the seek position
391
+ playbackStart -= timeMs / speed;
392
+
393
+ // Resume scheduling if not paused
394
+ if (!paused) {
395
+ scheduleNext();
396
+ }
397
+ },
398
+ };
399
+
400
+ return controller;
401
+ }
402
+ }