@2018kguo/jumpseat 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,21 @@
1
- # Temporary Holding Version
1
+ # @2018kguo/jumpseat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The browser client for [jumpseat](https://github.com/2018kguo/jumpseat): watch a
4
+ computer-use agent's Linux desktop live (H.264 over one websocket, decoded with
5
+ WebCodecs) and take over when it needs you. No dependencies.
6
+
7
+ ```ts
8
+ import { connect } from "@2018kguo/jumpseat";
9
+
10
+ const view = connect(element, `wss://host/stream?token=${token}`, {
11
+ connected: () => {},
12
+ dropped: () => {},
13
+ unsupported: () => {}, // no WebCodecs H.264 decoder in this browser
14
+ status: ({ kind, text }) => {}, // "acting" | "waiting" | "handoff" | "idle"
15
+ });
16
+ view.setControlling(true); // the person's keys, pointer and scrolls reach the desktop
17
+ ```
18
+
19
+ The server, the agent API and every option are in
20
+ [the repository](https://github.com/2018kguo/jumpseat#readme) and its
21
+ [reference](https://github.com/2018kguo/jumpseat/blob/main/docs/reference.md).
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The browser end of jumpseat: H.264 frames decoded with
3
+ * WebCodecs onto a canvas, and the user's input sent back while they have
4
+ * taken over. The wire format is in docs/reference.md.
5
+ */
6
+ /** What the agent is doing on the desktop, as the VM reports it. */
7
+ export type AgentStatus = {
8
+ kind: "idle" | "acting" | "waiting" | "handoff";
9
+ text: string;
10
+ };
11
+ export type DesktopStream = {
12
+ close(): void;
13
+ setControlling(controlling: boolean): void;
14
+ };
15
+ /**
16
+ * Whether this browser decodes the stream: WebCodecs exists and has an H.264
17
+ * decoder (some Linux Chromium builds ship without one).
18
+ */
19
+ export declare function supportsStream(): Promise<boolean>;
20
+ /**
21
+ * Streams the desktop at socketUrl into container, which it fills. A browser
22
+ * that cannot decode the stream gets on.unsupported (or on.dropped) instead.
23
+ */
24
+ export declare function connect(container: HTMLElement, socketUrl: string, on: {
25
+ connected: () => void;
26
+ dropped: () => void;
27
+ status?: (status: AgentStatus) => void;
28
+ /** The browser cannot decode H.264: the stream stops instead of dropping. */
29
+ unsupported?: () => void;
30
+ },
31
+ /**
32
+ * resize: ask the desktop to take the element's size, in CSS pixels, as it
33
+ * changes. The server honors it only with --resize.
34
+ */
35
+ options?: {
36
+ resize?: boolean;
37
+ }): DesktopStream;
@@ -0,0 +1,449 @@
1
+ // jumpseat.ts
2
+ var DESKTOP_WIDTH = 1280;
3
+ var DESKTOP_HEIGHT = 800;
4
+ function desktopPoint(rect, clientX, clientY, size = { height: DESKTOP_HEIGHT, width: DESKTOP_WIDTH }) {
5
+ const scale = Math.min(rect.width / size.width, rect.height / size.height);
6
+ if (scale <= 0) {
7
+ return null;
8
+ }
9
+ const left = rect.left + (rect.width - size.width * scale) / 2;
10
+ const top = rect.top + (rect.height - size.height * scale) / 2;
11
+ const x = Math.floor((clientX - left) / scale);
12
+ const y = Math.floor((clientY - top) / scale);
13
+ return x >= 0 && y >= 0 && x < size.width && y < size.height ? { x, y } : null;
14
+ }
15
+ function desktopKey(event) {
16
+ if (event.key === "Meta") {
17
+ return "Control";
18
+ }
19
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.length === 1 && event.key.charCodeAt(0) > 127) {
20
+ const latin = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(event.code);
21
+ if (latin) {
22
+ return (latin[1] ?? latin[2] ?? "").toLowerCase();
23
+ }
24
+ }
25
+ return event.key;
26
+ }
27
+ var STATUS_KINDS = new Set(["idle", "acting", "waiting", "handoff"]);
28
+ function parseStreamText(raw) {
29
+ let value;
30
+ try {
31
+ value = JSON.parse(raw);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (typeof value !== "object" || value === null) {
36
+ return null;
37
+ }
38
+ const m = value;
39
+ const num = (v) => typeof v === "number" && Number.isFinite(v);
40
+ const str = (v) => typeof v === "string";
41
+ if (m.t === "p" && num(m.x) && num(m.y)) {
42
+ return { t: "p", x: m.x, y: m.y };
43
+ }
44
+ if (m.t === "c" && str(m.text)) {
45
+ return { t: "c", text: m.text };
46
+ }
47
+ if (m.t === "status" && STATUS_KINDS.has(m.kind) && str(m.text)) {
48
+ return { t: "status", kind: m.kind, text: m.text };
49
+ }
50
+ if (m.t === "size" && num(m.w) && num(m.h) && m.w > 0 && m.h > 0) {
51
+ return { t: "size", w: m.w, h: m.h };
52
+ }
53
+ if (m.t === "cursor" && str(m.png) && num(m.x) && num(m.y)) {
54
+ return { t: "cursor", png: m.png, x: m.x, y: m.y };
55
+ }
56
+ return null;
57
+ }
58
+ var HEARTBEAT_MS = 5000;
59
+ var FIRST_FRAME_MS = 20000;
60
+ var X_BUTTONS = { 0: 1, 1: 2, 2: 3 };
61
+ var POINTER_ARROW = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='20'%3E%3Cpath d='M1 1v15l4-4 3 7 3-1-3-7h6z' fill='white' stroke='black'/%3E%3C/svg%3E")`;
62
+ var STREAM_CODEC = "avc1.640029";
63
+ async function supportsStream() {
64
+ if (typeof VideoDecoder === "undefined") {
65
+ return false;
66
+ }
67
+ try {
68
+ return (await VideoDecoder.isConfigSupported({ codec: STREAM_CODEC })).supported === true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+ function connect(container, socketUrl, on, options = {}) {
74
+ if (typeof VideoDecoder === "undefined") {
75
+ setTimeout(on.unsupported ?? on.dropped);
76
+ return { close() {}, setControlling() {} };
77
+ }
78
+ const canvas = document.createElement("canvas");
79
+ const size = { height: DESKTOP_HEIGHT, width: DESKTOP_WIDTH };
80
+ canvas.width = size.width;
81
+ canvas.height = size.height;
82
+ canvas.tabIndex = 0;
83
+ canvas.style.cssText = "width:100%;height:100%;object-fit:contain;outline:none;display:block";
84
+ const pointer = document.createElement("div");
85
+ pointer.style.cssText = `position:absolute;width:16px;height:20px;pointer-events:none;display:none;background:${POINTER_ARROW} no-repeat;background-size:contain`;
86
+ let cursor = { css: "default", height: 20, width: 16, x: 0, y: 0 };
87
+ const label = document.createElement("span");
88
+ label.textContent = "Agent";
89
+ label.style.cssText = "position:absolute;left:100%;top:100%;padding:1px 6px;border-radius:9999px;background:#4f46e5;color:#fff;font:600 11px/16px system-ui,sans-serif;white-space:nowrap";
90
+ pointer.append(label);
91
+ container.style.position = "relative";
92
+ container.append(canvas, pointer);
93
+ const context = canvas.getContext("2d");
94
+ let closed = false;
95
+ let controlling = false;
96
+ let configured = false;
97
+ let connected = false;
98
+ let copiedAt = 0;
99
+ const decoder = new VideoDecoder({
100
+ output: (frame) => {
101
+ if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
102
+ canvas.width = frame.displayWidth;
103
+ canvas.height = frame.displayHeight;
104
+ }
105
+ context?.drawImage(frame, 0, 0);
106
+ sendAlways({ t: "a", ts: Math.round(frame.timestamp / 1000) });
107
+ frame.close();
108
+ },
109
+ error: () => drop()
110
+ });
111
+ const socket = new WebSocket(socketUrl);
112
+ socket.binaryType = "arraybuffer";
113
+ function stop(notify) {
114
+ if (!closed) {
115
+ closed = true;
116
+ clearInterval(heartbeat);
117
+ clearTimeout(firstFrame);
118
+ resizer?.disconnect();
119
+ socket.close();
120
+ notify();
121
+ }
122
+ }
123
+ const drop = () => stop(on.dropped);
124
+ supportsStream().then((ok) => ok || stop(on.unsupported ?? on.dropped));
125
+ const sendAlways = (message) => {
126
+ if (socket.readyState === WebSocket.OPEN) {
127
+ socket.send(JSON.stringify(message));
128
+ }
129
+ };
130
+ const send = (message) => {
131
+ if (controlling) {
132
+ sendAlways(message);
133
+ }
134
+ };
135
+ const heartbeat = setInterval(() => sendAlways({ t: "h" }), HEARTBEAT_MS);
136
+ let firstFrame = setTimeout(drop, FIRST_FRAME_MS);
137
+ socket.addEventListener("message", (event) => {
138
+ if (typeof event.data === "string") {
139
+ const message = parseStreamText(event.data);
140
+ if (!message) {
141
+ return;
142
+ }
143
+ if (message.t === "size") {
144
+ size.width = message.w;
145
+ size.height = message.h;
146
+ configured = false;
147
+ } else if (message.t === "cursor") {
148
+ const url = `url(data:image/png;base64,${message.png})`;
149
+ cursor = {
150
+ css: `${url} ${message.x} ${message.y}, default`,
151
+ height: cursor.height,
152
+ width: cursor.width,
153
+ x: message.x,
154
+ y: message.y
155
+ };
156
+ pointer.style.background = `${url} no-repeat`;
157
+ pointer.style.backgroundSize = "contain";
158
+ const image = new Image;
159
+ image.addEventListener("load", () => {
160
+ cursor.width = image.width;
161
+ cursor.height = image.height;
162
+ });
163
+ image.src = `data:image/png;base64,${message.png}`;
164
+ if (controlling) {
165
+ canvas.style.cursor = cursor.css;
166
+ }
167
+ } else if (message.t === "status") {
168
+ on.status?.({ kind: message.kind, text: message.text });
169
+ } else if (message.t === "p") {
170
+ const rect = canvas.getBoundingClientRect();
171
+ const scale = Math.min(rect.width / size.width, rect.height / size.height);
172
+ pointer.style.left = `${(rect.width - size.width * scale) / 2 + (message.x - cursor.x) * scale}px`;
173
+ pointer.style.top = `${(rect.height - size.height * scale) / 2 + (message.y - cursor.y) * scale}px`;
174
+ pointer.style.width = `${cursor.width * scale}px`;
175
+ pointer.style.height = `${cursor.height * scale}px`;
176
+ pointer.style.display = controlling ? "none" : "block";
177
+ } else if (controlling && performance.now() - copiedAt < 2000) {
178
+ navigator.clipboard?.writeText(message.text).catch(() => {});
179
+ }
180
+ return;
181
+ }
182
+ if (!(event.data instanceof ArrayBuffer)) {
183
+ return;
184
+ }
185
+ const data = new Uint8Array(event.data);
186
+ const key = data[0] === 1;
187
+ const stamp = Number(new DataView(event.data).getBigUint64(1));
188
+ if (!configured) {
189
+ if (!key) {
190
+ return;
191
+ }
192
+ decoder.configure({
193
+ codec: STREAM_CODEC,
194
+ optimizeForLatency: true
195
+ });
196
+ configured = true;
197
+ }
198
+ decoder.decode(new EncodedVideoChunk({
199
+ data: data.subarray(9),
200
+ timestamp: stamp * 1000,
201
+ type: key ? "key" : "delta"
202
+ }));
203
+ if (!connected) {
204
+ connected = true;
205
+ clearTimeout(firstFrame);
206
+ on.connected();
207
+ }
208
+ });
209
+ socket.addEventListener("close", drop);
210
+ let resizeTimer;
211
+ const askSize = () => {
212
+ const { width, height } = container.getBoundingClientRect();
213
+ sendAlways({ t: "z", w: Math.round(width), h: Math.round(height) });
214
+ };
215
+ const resizer = options.resize ? new ResizeObserver(() => {
216
+ clearTimeout(resizeTimer);
217
+ resizeTimer = setTimeout(askSize, 200);
218
+ }) : null;
219
+ resizer?.observe(container);
220
+ if (resizer) {
221
+ socket.addEventListener("open", askSize);
222
+ }
223
+ socket.addEventListener("error", drop);
224
+ const at = (event) => desktopPoint(canvas.getBoundingClientRect(), event.clientX, event.clientY, size);
225
+ canvas.addEventListener("pointermove", (event) => {
226
+ if (event.pointerType === "touch") {
227
+ return;
228
+ }
229
+ const point = at(event);
230
+ if (point) {
231
+ send({ t: "m", ...point });
232
+ }
233
+ });
234
+ const onButton = (down) => (event) => {
235
+ const button = X_BUTTONS[event.button];
236
+ if (!controlling || button === undefined || event.pointerType === "touch") {
237
+ return;
238
+ }
239
+ const rect = canvas.getBoundingClientRect();
240
+ const point = at(event) ?? (down ? null : desktopPoint(rect, Math.min(Math.max(event.clientX, rect.left), rect.right - 1), Math.min(Math.max(event.clientY, rect.top), rect.bottom - 1), size));
241
+ if (!point) {
242
+ return;
243
+ }
244
+ if (down) {
245
+ canvas.focus();
246
+ canvas.setPointerCapture(event.pointerId);
247
+ }
248
+ send({ t: "b", b: button, down, ...point });
249
+ event.preventDefault();
250
+ };
251
+ canvas.addEventListener("pointerdown", onButton(true));
252
+ canvas.addEventListener("pointerup", onButton(false));
253
+ attachTouch(canvas, {
254
+ at,
255
+ controlling: () => controlling,
256
+ send
257
+ });
258
+ canvas.addEventListener("wheel", (event) => {
259
+ if (!controlling) {
260
+ return;
261
+ }
262
+ const unit = (event.deltaMode === 1 ? 40 : event.deltaMode === 2 ? 800 : 1) * desktopPerCssPixel(canvas);
263
+ send({ t: "w", sx: event.deltaX * unit, sy: event.deltaY * unit });
264
+ event.preventDefault();
265
+ }, { passive: false });
266
+ canvas.addEventListener("contextmenu", (event) => {
267
+ if (controlling) {
268
+ event.preventDefault();
269
+ }
270
+ });
271
+ const onKey = (down) => (event) => {
272
+ if (!controlling) {
273
+ return;
274
+ }
275
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") {
276
+ return;
277
+ }
278
+ if (event.repeat) {
279
+ event.preventDefault();
280
+ return;
281
+ }
282
+ if (down && event.key === "AltGraph") {
283
+ send({ t: "k", key: "Control", down: false });
284
+ }
285
+ if (down && (event.metaKey || event.ctrlKey) && /^[cx]$/i.test(event.key)) {
286
+ copiedAt = performance.now();
287
+ }
288
+ send({ t: "k", key: desktopKey(event), down });
289
+ if (!down && event.key === "Meta") {
290
+ send({ t: "r" });
291
+ }
292
+ event.preventDefault();
293
+ };
294
+ canvas.addEventListener("keydown", onKey(true));
295
+ canvas.addEventListener("keyup", onKey(false));
296
+ canvas.addEventListener("blur", () => send({ t: "r" }));
297
+ const onPaste = (event) => {
298
+ const text = event.clipboardData?.getData("text/plain");
299
+ if (!controlling || document.activeElement !== canvas || !text || text.length > 64 * 1024) {
300
+ return;
301
+ }
302
+ send({ t: "c", text });
303
+ for (const [key, down] of [
304
+ ["Control", true],
305
+ ["v", true],
306
+ ["v", false],
307
+ ["Control", false]
308
+ ]) {
309
+ send({ t: "k", key, down });
310
+ }
311
+ event.preventDefault();
312
+ };
313
+ document.addEventListener("paste", onPaste);
314
+ const onVisibility = () => {
315
+ sendAlways({ t: "v", hidden: document.hidden });
316
+ clearTimeout(firstFrame);
317
+ if (!document.hidden && !connected && !closed) {
318
+ firstFrame = setTimeout(drop, FIRST_FRAME_MS);
319
+ }
320
+ };
321
+ document.addEventListener("visibilitychange", onVisibility);
322
+ socket.addEventListener("open", () => {
323
+ if (document.hidden) {
324
+ onVisibility();
325
+ }
326
+ });
327
+ return {
328
+ close() {
329
+ closed = true;
330
+ clearInterval(heartbeat);
331
+ clearTimeout(firstFrame);
332
+ resizer?.disconnect();
333
+ socket.close();
334
+ if (decoder.state !== "closed") {
335
+ decoder.close();
336
+ }
337
+ document.removeEventListener("paste", onPaste);
338
+ document.removeEventListener("visibilitychange", onVisibility);
339
+ canvas.remove();
340
+ pointer.remove();
341
+ },
342
+ setControlling(next) {
343
+ if (!next) {
344
+ send({ t: "r" });
345
+ }
346
+ controlling = next;
347
+ pointer.style.display = "none";
348
+ canvas.style.cursor = next ? cursor.css : "default";
349
+ if (next) {
350
+ canvas.focus();
351
+ }
352
+ }
353
+ };
354
+ }
355
+ var LONG_PRESS_MS = 600;
356
+ var TAP_SLOP_PX = 10;
357
+ function attachTouch(canvas, view) {
358
+ canvas.style.touchAction = "none";
359
+ const fingers = new Map;
360
+ let start = null;
361
+ let pressed = false;
362
+ let longPress;
363
+ const reset = () => {
364
+ clearTimeout(longPress);
365
+ start = null;
366
+ };
367
+ canvas.addEventListener("pointerdown", (event) => {
368
+ if (event.pointerType !== "touch" || !view.controlling()) {
369
+ return;
370
+ }
371
+ event.preventDefault();
372
+ fingers.set(event.pointerId, { x: event.clientX, y: event.clientY });
373
+ if (fingers.size > 1) {
374
+ if (pressed && start) {
375
+ view.send({ t: "b", b: 1, down: false, x: start.x, y: start.y });
376
+ pressed = false;
377
+ }
378
+ reset();
379
+ return;
380
+ }
381
+ const point = view.at(event);
382
+ if (!point) {
383
+ return;
384
+ }
385
+ canvas.focus();
386
+ start = { clientX: event.clientX, clientY: event.clientY, ...point };
387
+ longPress = setTimeout(() => {
388
+ if (start && !pressed) {
389
+ view.send({ t: "b", b: 3, down: true, x: start.x, y: start.y });
390
+ view.send({ t: "b", b: 3, down: false, x: start.x, y: start.y });
391
+ start = null;
392
+ }
393
+ }, LONG_PRESS_MS);
394
+ });
395
+ canvas.addEventListener("pointermove", (event) => {
396
+ const last = fingers.get(event.pointerId);
397
+ if (event.pointerType !== "touch" || !last || !view.controlling()) {
398
+ return;
399
+ }
400
+ fingers.set(event.pointerId, { x: event.clientX, y: event.clientY });
401
+ if (fingers.size > 1) {
402
+ const unit = desktopPerCssPixel(canvas);
403
+ view.send({
404
+ t: "w",
405
+ sx: (last.x - event.clientX) * unit,
406
+ sy: (last.y - event.clientY) * unit
407
+ });
408
+ return;
409
+ }
410
+ if (!start) {
411
+ return;
412
+ }
413
+ const point = view.at(event);
414
+ if (!pressed && Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > TAP_SLOP_PX) {
415
+ clearTimeout(longPress);
416
+ view.send({ t: "b", b: 1, down: true, x: start.x, y: start.y });
417
+ pressed = true;
418
+ }
419
+ if (pressed && point) {
420
+ view.send({ t: "m", ...point });
421
+ }
422
+ });
423
+ const lift = (event) => {
424
+ if (event.pointerType !== "touch" || !fingers.delete(event.pointerId)) {
425
+ return;
426
+ }
427
+ if (start && fingers.size === 0) {
428
+ const point = view.at(event) ?? start;
429
+ if (!pressed && event.type === "pointerup") {
430
+ view.send({ t: "b", b: 1, down: true, x: start.x, y: start.y });
431
+ view.send({ t: "b", b: 1, down: false, ...point });
432
+ } else if (pressed) {
433
+ view.send({ t: "b", b: 1, down: false, ...point });
434
+ }
435
+ }
436
+ pressed = false;
437
+ reset();
438
+ };
439
+ canvas.addEventListener("pointerup", lift);
440
+ canvas.addEventListener("pointercancel", lift);
441
+ }
442
+ function desktopPerCssPixel(canvas) {
443
+ const rect = canvas.getBoundingClientRect();
444
+ return 1 / Math.min(rect.width / canvas.width, rect.height / canvas.height);
445
+ }
446
+ export {
447
+ connect,
448
+ supportsStream
449
+ };
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@2018kguo/jumpseat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Watch and take over a computer-use agent's desktop: the browser client for jumpseat (H.264 over a websocket, WebCodecs).",
5
+ "keywords": [
6
+ "computer-use",
7
+ "ai-agents",
8
+ "remote-desktop",
9
+ "webcodecs",
10
+ "h264",
11
+ "websocket",
12
+ "human-in-the-loop"
13
+ ],
14
+ "homepage": "https://github.com/2018kguo/jumpseat#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/2018kguo/jumpseat.git",
18
+ "directory": "client"
19
+ },
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "main": "dist/jumpseat.js",
23
+ "types": "dist/jumpseat.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/jumpseat.d.ts",
27
+ "default": "./dist/jumpseat.js"
28
+ }
29
+ },
30
+ "sideEffects": false,
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "build": "bun build jumpseat.ts --format=esm --target=browser --outfile dist/jumpseat.js && bunx tsc jumpseat.ts --declaration --emitDeclarationOnly --target es2022 --lib es2022,dom --outDir dist",
37
+ "build:demo": "bun build jumpseat.ts --format=esm --target=browser --outfile ../server/web/jumpseat.js"
38
+ }
39
+ }