@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +42 -0
- package/src/auth.mjs +314 -0
- package/src/cbor.mjs +389 -0
- package/src/client.mjs +1842 -0
- package/src/file-transfer.mjs +413 -0
- package/src/index.d.ts +1454 -0
- package/src/index.mjs +70 -0
- package/src/keystore.mjs +473 -0
- package/src/mcp-bridge.mjs +228 -0
- package/src/messages.gen.mjs +928 -0
- package/src/messages.mjs +3 -0
- package/src/recording.mjs +402 -0
- package/src/session.mjs +557 -0
- package/src/transport-ws.mjs +462 -0
- package/src/transport.mjs +343 -0
- package/src/virtual-session.mjs +160 -0
package/src/messages.mjs
ADDED
|
@@ -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
|
+
}
|