@alfe.ai/remote 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/dist/index.cjs +356 -0
- package/dist/index.d.cts +243 -0
- package/dist/index.d.ts +243 -0
- package/dist/index.js +321 -0
- package/package.json +29 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
24
|
+
let ws = require("ws");
|
|
25
|
+
ws = __toESM(ws);
|
|
26
|
+
//#region src/remote-protocol.ts
|
|
27
|
+
/**
|
|
28
|
+
* Remote-relay framing protocol — multiplexes interactive surfaces (browser
|
|
29
|
+
* screencast + input, terminal PTY) over the single outbound WS between the
|
|
30
|
+
* agent plugin and the Alfe remote relay service. The relay routes frames by
|
|
31
|
+
* `sessionId` (bytes 1-4) without parsing payloads.
|
|
32
|
+
*
|
|
33
|
+
* 5-byte header (WS preserves order, so no sequence numbers):
|
|
34
|
+
* Byte 0 Frame type (uint8)
|
|
35
|
+
* Bytes 1-4 Session ID (uint32, big-endian) — one viewer attachment
|
|
36
|
+
* Bytes 5+ Payload — JSON (utf-8) for control/input, raw bytes for media
|
|
37
|
+
*
|
|
38
|
+
* Direction is enforced at the relay:
|
|
39
|
+
* • plugin → viewer: SESSION_STATE, NAVIGATION, SCREENCAST_FRAME,
|
|
40
|
+
* TAKEOVER_GRANTED/DENIED, CONTROL_REVOKED, TERMINAL_DATA
|
|
41
|
+
* • viewer → plugin: SESSION_OPEN, SESSION_CLOSE, SCREENCAST_ACK, INPUT_*,
|
|
42
|
+
* RESIZE, TAKEOVER_REQUEST, RELEASE_CONTROL, TERMINAL_INPUT, TERMINAL_RESIZE
|
|
43
|
+
*
|
|
44
|
+
* Keep this file byte-identical with `services/remote/src/remote-protocol.ts`.
|
|
45
|
+
*/
|
|
46
|
+
const REMOTE_HEADER_SIZE = 5;
|
|
47
|
+
const RemoteFrameType = {
|
|
48
|
+
SESSION_OPEN: 1,
|
|
49
|
+
SESSION_CLOSE: 2,
|
|
50
|
+
SESSION_STATE: 3,
|
|
51
|
+
NAVIGATION: 4,
|
|
52
|
+
SCREENCAST_FRAME: 16,
|
|
53
|
+
SCREENCAST_ACK: 17,
|
|
54
|
+
INPUT_MOUSE: 32,
|
|
55
|
+
INPUT_WHEEL: 33,
|
|
56
|
+
INPUT_KEY: 34,
|
|
57
|
+
RESIZE: 35,
|
|
58
|
+
TAKEOVER_REQUEST: 48,
|
|
59
|
+
TAKEOVER_GRANTED: 49,
|
|
60
|
+
TAKEOVER_DENIED: 50,
|
|
61
|
+
RELEASE_CONTROL: 51,
|
|
62
|
+
CONTROL_REVOKED: 52,
|
|
63
|
+
TERMINAL_DATA: 64,
|
|
64
|
+
TERMINAL_INPUT: 65,
|
|
65
|
+
TERMINAL_RESIZE: 66
|
|
66
|
+
};
|
|
67
|
+
const MIN_FRAME_TYPE = RemoteFrameType.SESSION_OPEN;
|
|
68
|
+
const MAX_FRAME_TYPE = RemoteFrameType.TERMINAL_RESIZE;
|
|
69
|
+
function isRemoteFrame(data) {
|
|
70
|
+
if (data.length < 5) return false;
|
|
71
|
+
const type = data[0];
|
|
72
|
+
return type >= MIN_FRAME_TYPE && type <= MAX_FRAME_TYPE;
|
|
73
|
+
}
|
|
74
|
+
function encodeFrame(type, sessionId, payload) {
|
|
75
|
+
const body = payload ?? Buffer.alloc(0);
|
|
76
|
+
const buf = Buffer.allocUnsafe(5 + body.length);
|
|
77
|
+
buf.writeUInt8(type, 0);
|
|
78
|
+
buf.writeUInt32BE(sessionId >>> 0, 1);
|
|
79
|
+
if (body.length > 0) body.copy(buf, 5);
|
|
80
|
+
return buf;
|
|
81
|
+
}
|
|
82
|
+
function decodeFrame(buf) {
|
|
83
|
+
if (buf.length < 5) throw new Error(`Remote frame too short: ${String(buf.length)} bytes`);
|
|
84
|
+
return {
|
|
85
|
+
type: buf.readUInt8(0),
|
|
86
|
+
sessionId: buf.readUInt32BE(1),
|
|
87
|
+
payload: buf.subarray(5)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function encodeJsonFrame(type, sessionId, obj) {
|
|
91
|
+
return encodeFrame(type, sessionId, Buffer.from(JSON.stringify(obj), "utf-8"));
|
|
92
|
+
}
|
|
93
|
+
/** Best-effort JSON decode — returns null on a malformed payload so a single
|
|
94
|
+
* corrupt frame can't crash the per-session handler. Callers assert the shape
|
|
95
|
+
* (e.g. `decodeJson(p) as SessionOpenPayload | null`). */
|
|
96
|
+
function decodeJson(payload) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(payload.toString("utf-8"));
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Encode a screencast frame: [metaLen:u16BE][meta JSON][raw JPEG]. The JPEG is
|
|
104
|
+
* forwarded raw (already base64-decoded from CDP) to avoid base64 bloat. */
|
|
105
|
+
function encodeScreencastFrame(sessionId, meta, jpeg) {
|
|
106
|
+
const metaJson = Buffer.from(JSON.stringify(meta), "utf-8");
|
|
107
|
+
const body = Buffer.allocUnsafe(2 + metaJson.length + jpeg.length);
|
|
108
|
+
body.writeUInt16BE(metaJson.length, 0);
|
|
109
|
+
metaJson.copy(body, 2);
|
|
110
|
+
jpeg.copy(body, 2 + metaJson.length);
|
|
111
|
+
return encodeFrame(RemoteFrameType.SCREENCAST_FRAME, sessionId, body);
|
|
112
|
+
}
|
|
113
|
+
function decodeScreencastFrame(payload) {
|
|
114
|
+
if (payload.length < 2) return null;
|
|
115
|
+
const metaLen = payload.readUInt16BE(0);
|
|
116
|
+
if (payload.length < 2 + metaLen) return null;
|
|
117
|
+
const meta = decodeJson(payload.subarray(2, 2 + metaLen));
|
|
118
|
+
if (!meta) return null;
|
|
119
|
+
return {
|
|
120
|
+
meta,
|
|
121
|
+
jpeg: payload.subarray(2 + metaLen)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Maximum WS message size (must align across daemon plugin and relay). */
|
|
125
|
+
const REMOTE_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/client.ts
|
|
128
|
+
/**
|
|
129
|
+
* RemoteServiceClient — runtime-agnostic outbound WS client to the Alfe remote
|
|
130
|
+
* relay service. Brings up a persistent WebSocket, decodes binary frames and
|
|
131
|
+
* hands them to a surface-agnostic `onFrame` callback, exposes `sendFrame` for
|
|
132
|
+
* outbound frames, and reconnects with backoff.
|
|
133
|
+
*
|
|
134
|
+
* One client per agent. Multiple viewer attachments (browser + terminal) are
|
|
135
|
+
* multiplexed by the relay over the single WS using `sessionId`.
|
|
136
|
+
*
|
|
137
|
+
* Reconnect + heartbeat logic mirrors `@alfe.ai/console-client`'s client.
|
|
138
|
+
*/
|
|
139
|
+
const RECONNECT_DELAYS = [
|
|
140
|
+
1e3,
|
|
141
|
+
2e3,
|
|
142
|
+
4e3,
|
|
143
|
+
8e3,
|
|
144
|
+
16e3,
|
|
145
|
+
3e4
|
|
146
|
+
];
|
|
147
|
+
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
148
|
+
const defaultLogger = {
|
|
149
|
+
info: (msg) => {
|
|
150
|
+
console.log(`[remote-client] ${msg}`);
|
|
151
|
+
},
|
|
152
|
+
warn: (msg) => {
|
|
153
|
+
console.warn(`[remote-client] ${msg}`);
|
|
154
|
+
},
|
|
155
|
+
error: (msg) => {
|
|
156
|
+
console.error(`[remote-client] ${msg}`);
|
|
157
|
+
},
|
|
158
|
+
debug: () => {}
|
|
159
|
+
};
|
|
160
|
+
function wsDataToBuffer(data) {
|
|
161
|
+
if (Buffer.isBuffer(data)) return data;
|
|
162
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
163
|
+
return Buffer.from(data);
|
|
164
|
+
}
|
|
165
|
+
var RemoteServiceClient = class {
|
|
166
|
+
ws = null;
|
|
167
|
+
stopped = false;
|
|
168
|
+
connected = false;
|
|
169
|
+
retryCount = 0;
|
|
170
|
+
retryTimer = null;
|
|
171
|
+
heartbeatTimer = null;
|
|
172
|
+
isAlive = false;
|
|
173
|
+
log;
|
|
174
|
+
constructor(options) {
|
|
175
|
+
this.options = options;
|
|
176
|
+
this.log = options.logger ?? defaultLogger;
|
|
177
|
+
}
|
|
178
|
+
get isConnected() {
|
|
179
|
+
return this.connected && this.ws?.readyState === ws.default.OPEN;
|
|
180
|
+
}
|
|
181
|
+
/** Backpressure signal for the screencast pump — bytes queued but not flushed. */
|
|
182
|
+
get bufferedAmount() {
|
|
183
|
+
return this.ws?.bufferedAmount ?? 0;
|
|
184
|
+
}
|
|
185
|
+
start() {
|
|
186
|
+
this.log.debug("RemoteServiceClient starting...");
|
|
187
|
+
this.stopped = false;
|
|
188
|
+
this.doConnect();
|
|
189
|
+
}
|
|
190
|
+
stop() {
|
|
191
|
+
this.log.debug("RemoteServiceClient stopping...");
|
|
192
|
+
this.stopped = true;
|
|
193
|
+
if (this.retryTimer) {
|
|
194
|
+
clearTimeout(this.retryTimer);
|
|
195
|
+
this.retryTimer = null;
|
|
196
|
+
}
|
|
197
|
+
this.clearHeartbeat();
|
|
198
|
+
if (this.ws) {
|
|
199
|
+
try {
|
|
200
|
+
this.ws.close(1e3, "client shutdown");
|
|
201
|
+
} catch {}
|
|
202
|
+
this.ws = null;
|
|
203
|
+
}
|
|
204
|
+
this.connected = false;
|
|
205
|
+
}
|
|
206
|
+
/** Send a pre-encoded binary frame. No-op if the socket isn't open. */
|
|
207
|
+
sendFrame(buf) {
|
|
208
|
+
if (this.ws?.readyState === ws.default.OPEN) this.ws.send(buf, { binary: true });
|
|
209
|
+
}
|
|
210
|
+
doConnect() {
|
|
211
|
+
if (this.stopped) return;
|
|
212
|
+
this.log.info(`Connecting to remote relay at ${this.options.wsUrl}...`);
|
|
213
|
+
this.ws = new ws.default(this.options.wsUrl, {
|
|
214
|
+
headers: { authorization: `Bearer ${this.options.apiKey}` },
|
|
215
|
+
maxPayload: REMOTE_MAX_PAYLOAD,
|
|
216
|
+
handshakeTimeout: 1e4
|
|
217
|
+
});
|
|
218
|
+
this.ws.on("open", () => {
|
|
219
|
+
this.log.info("Connected to remote relay");
|
|
220
|
+
this.connected = true;
|
|
221
|
+
this.retryCount = 0;
|
|
222
|
+
this.startHeartbeat();
|
|
223
|
+
this.options.onConnectionChange?.(true);
|
|
224
|
+
});
|
|
225
|
+
this.ws.on("message", (data, isBinary) => {
|
|
226
|
+
this.isAlive = true;
|
|
227
|
+
if (!isBinary) return;
|
|
228
|
+
const buf = wsDataToBuffer(data);
|
|
229
|
+
if (!isRemoteFrame(buf)) return;
|
|
230
|
+
try {
|
|
231
|
+
this.options.onFrame(decodeFrame(buf));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
this.log.warn(`Failed to handle remote frame: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
this.ws.on("ping", () => {
|
|
237
|
+
this.isAlive = true;
|
|
238
|
+
this.ws?.pong();
|
|
239
|
+
});
|
|
240
|
+
this.ws.on("pong", () => {
|
|
241
|
+
this.isAlive = true;
|
|
242
|
+
});
|
|
243
|
+
this.ws.on("close", (code, reason) => {
|
|
244
|
+
this.clearHeartbeat();
|
|
245
|
+
this.log.warn(`Disconnected from remote relay (${String(code)}: ${reason.toString()})`);
|
|
246
|
+
this.connected = false;
|
|
247
|
+
this.options.onConnectionChange?.(false);
|
|
248
|
+
this.scheduleReconnect();
|
|
249
|
+
});
|
|
250
|
+
this.ws.on("error", (err) => {
|
|
251
|
+
this.log.error(`Remote relay WS error: ${err.message}`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
scheduleReconnect() {
|
|
255
|
+
if (this.stopped) return;
|
|
256
|
+
const delay = RECONNECT_DELAYS[Math.min(this.retryCount, RECONNECT_DELAYS.length - 1)];
|
|
257
|
+
this.retryCount += 1;
|
|
258
|
+
this.log.info(`Reconnecting to remote relay in ${String(delay)}ms (attempt ${String(this.retryCount)})...`);
|
|
259
|
+
this.retryTimer = setTimeout(() => {
|
|
260
|
+
this.retryTimer = null;
|
|
261
|
+
this.doConnect();
|
|
262
|
+
}, delay);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Detect a silently-dropped connection. Each tick: if no inbound liveness
|
|
266
|
+
* (pong/ping/message) was seen since the last tick, force-close so the
|
|
267
|
+
* `close` handler reconnects; otherwise send a fresh ping and arm the next.
|
|
268
|
+
*/
|
|
269
|
+
startHeartbeat() {
|
|
270
|
+
this.clearHeartbeat();
|
|
271
|
+
this.isAlive = true;
|
|
272
|
+
const timer = setInterval(() => {
|
|
273
|
+
if (this.ws?.readyState !== ws.default.OPEN) return;
|
|
274
|
+
if (!this.isAlive) {
|
|
275
|
+
this.log.warn("Remote relay heartbeat timed out — terminating stale connection");
|
|
276
|
+
this.ws.terminate();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.isAlive = false;
|
|
280
|
+
try {
|
|
281
|
+
this.ws.ping();
|
|
282
|
+
} catch {}
|
|
283
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
284
|
+
timer.unref();
|
|
285
|
+
this.heartbeatTimer = timer;
|
|
286
|
+
}
|
|
287
|
+
clearHeartbeat() {
|
|
288
|
+
if (this.heartbeatTimer) {
|
|
289
|
+
clearInterval(this.heartbeatTimer);
|
|
290
|
+
this.heartbeatTimer = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/turn-controller.ts
|
|
296
|
+
var TurnController = class {
|
|
297
|
+
owner = "agent";
|
|
298
|
+
waiters = [];
|
|
299
|
+
onOwnerChange;
|
|
300
|
+
constructor(options = {}) {
|
|
301
|
+
this.onOwnerChange = options.onOwnerChange;
|
|
302
|
+
}
|
|
303
|
+
get currentOwner() {
|
|
304
|
+
return this.owner;
|
|
305
|
+
}
|
|
306
|
+
get humanInControl() {
|
|
307
|
+
return this.owner === "human";
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
311
|
+
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
312
|
+
* Every automation op awaits this before touching the page.
|
|
313
|
+
*/
|
|
314
|
+
acquireAgent() {
|
|
315
|
+
if (this.owner === "agent") return Promise.resolve();
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
this.waiters.push(resolve);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
322
|
+
* controller). "Human request always wins over the agent" — the agent parks
|
|
323
|
+
* at its next `acquireAgent()`.
|
|
324
|
+
*/
|
|
325
|
+
grantHuman() {
|
|
326
|
+
if (this.owner === "human") return false;
|
|
327
|
+
this.setOwner("human");
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
/** Return control to the agent and drain any parked automation ops. */
|
|
331
|
+
releaseHuman() {
|
|
332
|
+
if (this.owner === "agent") return;
|
|
333
|
+
this.setOwner("agent");
|
|
334
|
+
const parked = this.waiters;
|
|
335
|
+
this.waiters = [];
|
|
336
|
+
for (const resolve of parked) resolve();
|
|
337
|
+
}
|
|
338
|
+
setOwner(owner) {
|
|
339
|
+
if (this.owner === owner) return;
|
|
340
|
+
this.owner = owner;
|
|
341
|
+
this.onOwnerChange?.(owner);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
//#endregion
|
|
345
|
+
exports.REMOTE_HEADER_SIZE = REMOTE_HEADER_SIZE;
|
|
346
|
+
exports.REMOTE_MAX_PAYLOAD = REMOTE_MAX_PAYLOAD;
|
|
347
|
+
exports.RemoteFrameType = RemoteFrameType;
|
|
348
|
+
exports.RemoteServiceClient = RemoteServiceClient;
|
|
349
|
+
exports.TurnController = TurnController;
|
|
350
|
+
exports.decodeFrame = decodeFrame;
|
|
351
|
+
exports.decodeJson = decodeJson;
|
|
352
|
+
exports.decodeScreencastFrame = decodeScreencastFrame;
|
|
353
|
+
exports.encodeFrame = encodeFrame;
|
|
354
|
+
exports.encodeJsonFrame = encodeJsonFrame;
|
|
355
|
+
exports.encodeScreencastFrame = encodeScreencastFrame;
|
|
356
|
+
exports.isRemoteFrame = isRemoteFrame;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//#region src/remote-protocol.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Remote-relay framing protocol — multiplexes interactive surfaces (browser
|
|
4
|
+
* screencast + input, terminal PTY) over the single outbound WS between the
|
|
5
|
+
* agent plugin and the Alfe remote relay service. The relay routes frames by
|
|
6
|
+
* `sessionId` (bytes 1-4) without parsing payloads.
|
|
7
|
+
*
|
|
8
|
+
* 5-byte header (WS preserves order, so no sequence numbers):
|
|
9
|
+
* Byte 0 Frame type (uint8)
|
|
10
|
+
* Bytes 1-4 Session ID (uint32, big-endian) — one viewer attachment
|
|
11
|
+
* Bytes 5+ Payload — JSON (utf-8) for control/input, raw bytes for media
|
|
12
|
+
*
|
|
13
|
+
* Direction is enforced at the relay:
|
|
14
|
+
* • plugin → viewer: SESSION_STATE, NAVIGATION, SCREENCAST_FRAME,
|
|
15
|
+
* TAKEOVER_GRANTED/DENIED, CONTROL_REVOKED, TERMINAL_DATA
|
|
16
|
+
* • viewer → plugin: SESSION_OPEN, SESSION_CLOSE, SCREENCAST_ACK, INPUT_*,
|
|
17
|
+
* RESIZE, TAKEOVER_REQUEST, RELEASE_CONTROL, TERMINAL_INPUT, TERMINAL_RESIZE
|
|
18
|
+
*
|
|
19
|
+
* Keep this file byte-identical with `services/remote/src/remote-protocol.ts`.
|
|
20
|
+
*/
|
|
21
|
+
declare const REMOTE_HEADER_SIZE = 5;
|
|
22
|
+
declare const RemoteFrameType: {
|
|
23
|
+
/** viewer→plugin: a viewer attached. Payload: SessionOpenPayload. */
|
|
24
|
+
readonly SESSION_OPEN: 1;
|
|
25
|
+
/** either direction: a viewer detached / session torn down. */
|
|
26
|
+
readonly SESSION_CLOSE: 2;
|
|
27
|
+
/** plugin→viewer: current surface state. Payload: SessionStatePayload. */
|
|
28
|
+
readonly SESSION_STATE: 3;
|
|
29
|
+
/** plugin→viewer (browser): the page navigated. Payload: { url }. */
|
|
30
|
+
readonly NAVIGATION: 4;
|
|
31
|
+
/** plugin→viewer: [metaLen:u16BE][meta JSON][raw JPEG]. */
|
|
32
|
+
readonly SCREENCAST_FRAME: 16;
|
|
33
|
+
/** viewer→plugin: { frameSeq } — drives ack-gated backpressure. */
|
|
34
|
+
readonly SCREENCAST_ACK: 17;
|
|
35
|
+
readonly INPUT_MOUSE: 32;
|
|
36
|
+
readonly INPUT_WHEEL: 33;
|
|
37
|
+
readonly INPUT_KEY: 34;
|
|
38
|
+
/** viewer→plugin: { width, height, dpr }. */
|
|
39
|
+
readonly RESIZE: 35;
|
|
40
|
+
readonly TAKEOVER_REQUEST: 48;
|
|
41
|
+
readonly TAKEOVER_GRANTED: 49;
|
|
42
|
+
readonly TAKEOVER_DENIED: 50;
|
|
43
|
+
readonly RELEASE_CONTROL: 51;
|
|
44
|
+
readonly CONTROL_REVOKED: 52;
|
|
45
|
+
/** plugin→viewer: raw PTY output bytes. */
|
|
46
|
+
readonly TERMINAL_DATA: 64;
|
|
47
|
+
/** viewer→plugin: raw keystroke bytes. */
|
|
48
|
+
readonly TERMINAL_INPUT: 65;
|
|
49
|
+
/** viewer→plugin: { cols, rows }. */
|
|
50
|
+
readonly TERMINAL_RESIZE: 66;
|
|
51
|
+
};
|
|
52
|
+
type RemoteFrameType = (typeof RemoteFrameType)[keyof typeof RemoteFrameType];
|
|
53
|
+
type RemoteSurface = "browser" | "terminal";
|
|
54
|
+
interface RemoteFrame {
|
|
55
|
+
type: RemoteFrameType;
|
|
56
|
+
sessionId: number;
|
|
57
|
+
payload: Buffer;
|
|
58
|
+
}
|
|
59
|
+
interface SessionOpenPayload {
|
|
60
|
+
surface: RemoteSurface;
|
|
61
|
+
/** Browser: initial viewport in device pixels. */
|
|
62
|
+
width?: number;
|
|
63
|
+
height?: number;
|
|
64
|
+
dpr?: number;
|
|
65
|
+
/** Terminal: initial PTY dimensions. */
|
|
66
|
+
cols?: number;
|
|
67
|
+
rows?: number;
|
|
68
|
+
}
|
|
69
|
+
interface SessionStatePayload {
|
|
70
|
+
surface: RemoteSurface;
|
|
71
|
+
url?: string;
|
|
72
|
+
title?: string;
|
|
73
|
+
loading?: boolean;
|
|
74
|
+
/** Who currently holds the write token (browser only). */
|
|
75
|
+
controller?: "agent" | "human" | "none";
|
|
76
|
+
}
|
|
77
|
+
interface ScreencastFrameMeta {
|
|
78
|
+
deviceWidth: number;
|
|
79
|
+
deviceHeight: number;
|
|
80
|
+
frameSeq: number;
|
|
81
|
+
offsetTop?: number;
|
|
82
|
+
pageScaleFactor?: number;
|
|
83
|
+
scrollOffsetX?: number;
|
|
84
|
+
scrollOffsetY?: number;
|
|
85
|
+
}
|
|
86
|
+
interface MouseInputPayload {
|
|
87
|
+
type: "mousemoved" | "mousepressed" | "mousereleased";
|
|
88
|
+
/** Normalized canvas coordinates in [0,1]. */
|
|
89
|
+
nx: number;
|
|
90
|
+
ny: number;
|
|
91
|
+
button?: "none" | "left" | "middle" | "right";
|
|
92
|
+
buttons?: number;
|
|
93
|
+
clickCount?: number;
|
|
94
|
+
modifiers?: number;
|
|
95
|
+
}
|
|
96
|
+
interface WheelInputPayload {
|
|
97
|
+
nx: number;
|
|
98
|
+
ny: number;
|
|
99
|
+
deltaX: number;
|
|
100
|
+
deltaY: number;
|
|
101
|
+
modifiers?: number;
|
|
102
|
+
}
|
|
103
|
+
interface KeyInputPayload {
|
|
104
|
+
type: "keydown" | "keyup" | "char";
|
|
105
|
+
key?: string;
|
|
106
|
+
code?: string;
|
|
107
|
+
text?: string;
|
|
108
|
+
modifiers?: number;
|
|
109
|
+
}
|
|
110
|
+
interface ResizePayload {
|
|
111
|
+
width: number;
|
|
112
|
+
height: number;
|
|
113
|
+
dpr?: number;
|
|
114
|
+
}
|
|
115
|
+
interface TerminalResizePayload {
|
|
116
|
+
cols: number;
|
|
117
|
+
rows: number;
|
|
118
|
+
}
|
|
119
|
+
declare function isRemoteFrame(data: Buffer): boolean;
|
|
120
|
+
declare function encodeFrame(type: RemoteFrameType, sessionId: number, payload?: Buffer): Buffer;
|
|
121
|
+
declare function decodeFrame(buf: Buffer): RemoteFrame;
|
|
122
|
+
declare function encodeJsonFrame(type: RemoteFrameType, sessionId: number, obj: unknown): Buffer;
|
|
123
|
+
/** Best-effort JSON decode — returns null on a malformed payload so a single
|
|
124
|
+
* corrupt frame can't crash the per-session handler. Callers assert the shape
|
|
125
|
+
* (e.g. `decodeJson(p) as SessionOpenPayload | null`). */
|
|
126
|
+
declare function decodeJson(payload: Buffer): unknown;
|
|
127
|
+
/** Encode a screencast frame: [metaLen:u16BE][meta JSON][raw JPEG]. The JPEG is
|
|
128
|
+
* forwarded raw (already base64-decoded from CDP) to avoid base64 bloat. */
|
|
129
|
+
declare function encodeScreencastFrame(sessionId: number, meta: ScreencastFrameMeta, jpeg: Buffer): Buffer;
|
|
130
|
+
declare function decodeScreencastFrame(payload: Buffer): {
|
|
131
|
+
meta: ScreencastFrameMeta;
|
|
132
|
+
jpeg: Buffer;
|
|
133
|
+
} | null;
|
|
134
|
+
/** Maximum WS message size (must align across daemon plugin and relay). */
|
|
135
|
+
declare const REMOTE_MAX_PAYLOAD: number;
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/surface.d.ts
|
|
138
|
+
interface SurfaceHandler {
|
|
139
|
+
/** Which surface this handler serves. */
|
|
140
|
+
readonly surface: RemoteSurface;
|
|
141
|
+
/** A viewer attached — open the surface for this session. */
|
|
142
|
+
openSession(sessionId: number, open: SessionOpenPayload): void | Promise<void>;
|
|
143
|
+
/** A subsequent frame for one of this handler's sessions (not SESSION_OPEN). */
|
|
144
|
+
handleFrame(frame: RemoteFrame): void;
|
|
145
|
+
/** The viewer detached — tear down this session's resources. */
|
|
146
|
+
closeSession(sessionId: number): void;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/types.d.ts
|
|
150
|
+
interface Logger {
|
|
151
|
+
info(msg: string, ...args: unknown[]): void;
|
|
152
|
+
warn(msg: string, ...args: unknown[]): void;
|
|
153
|
+
error(msg: string, ...args: unknown[]): void;
|
|
154
|
+
debug(msg: string, ...args: unknown[]): void;
|
|
155
|
+
}
|
|
156
|
+
interface RemoteServiceClientOptions {
|
|
157
|
+
/** Relay WebSocket URL (e.g. wss://remote.dev.alfe.ai/ws). */
|
|
158
|
+
wsUrl: string;
|
|
159
|
+
/** Agent API key for Bearer auth on the WS upgrade. */
|
|
160
|
+
apiKey: string;
|
|
161
|
+
/**
|
|
162
|
+
* Handler for every well-formed inbound frame from the relay. The plugin
|
|
163
|
+
* dispatches by `frame.sessionId` to the owning surface handler. Kept on the
|
|
164
|
+
* transport as a single callback so this package stays surface-agnostic.
|
|
165
|
+
*/
|
|
166
|
+
onFrame: (frame: RemoteFrame) => void;
|
|
167
|
+
/** Called when the WS connection state changes. */
|
|
168
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
169
|
+
/** Optional logger (defaults to console). */
|
|
170
|
+
logger?: Logger;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/client.d.ts
|
|
174
|
+
declare class RemoteServiceClient {
|
|
175
|
+
private readonly options;
|
|
176
|
+
private ws;
|
|
177
|
+
private stopped;
|
|
178
|
+
private connected;
|
|
179
|
+
private retryCount;
|
|
180
|
+
private retryTimer;
|
|
181
|
+
private heartbeatTimer;
|
|
182
|
+
private isAlive;
|
|
183
|
+
private readonly log;
|
|
184
|
+
constructor(options: RemoteServiceClientOptions);
|
|
185
|
+
get isConnected(): boolean;
|
|
186
|
+
/** Backpressure signal for the screencast pump — bytes queued but not flushed. */
|
|
187
|
+
get bufferedAmount(): number;
|
|
188
|
+
start(): void;
|
|
189
|
+
stop(): void;
|
|
190
|
+
/** Send a pre-encoded binary frame. No-op if the socket isn't open. */
|
|
191
|
+
sendFrame(buf: Buffer): void;
|
|
192
|
+
private doConnect;
|
|
193
|
+
private scheduleReconnect;
|
|
194
|
+
/**
|
|
195
|
+
* Detect a silently-dropped connection. Each tick: if no inbound liveness
|
|
196
|
+
* (pong/ping/message) was seen since the last tick, force-close so the
|
|
197
|
+
* `close` handler reconnects; otherwise send a fresh ping and arm the next.
|
|
198
|
+
*/
|
|
199
|
+
private startHeartbeat;
|
|
200
|
+
private clearHeartbeat;
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/turn-controller.d.ts
|
|
204
|
+
/**
|
|
205
|
+
* TurnController — the write-mutex between agent automation and a human during
|
|
206
|
+
* a browser co-browse handoff. The plugin owns the single CDP session, so this
|
|
207
|
+
* lives plugin-side; the relay only forwards the control frames.
|
|
208
|
+
*
|
|
209
|
+
* Only *writes* are gated — screencast reads always flow so the human sees the
|
|
210
|
+
* live page regardless of who holds the token. Agent automation ops call
|
|
211
|
+
* `acquireAgent()` and park while the human is in control; the human's input is
|
|
212
|
+
* injected only while `owner === 'human'`.
|
|
213
|
+
*/
|
|
214
|
+
type TurnOwner = "agent" | "human";
|
|
215
|
+
interface TurnControllerOptions {
|
|
216
|
+
/** Fired whenever ownership changes, so the plugin can emit SESSION_STATE. */
|
|
217
|
+
onOwnerChange?: (owner: TurnOwner) => void;
|
|
218
|
+
}
|
|
219
|
+
declare class TurnController {
|
|
220
|
+
private owner;
|
|
221
|
+
private waiters;
|
|
222
|
+
private readonly onOwnerChange?;
|
|
223
|
+
constructor(options?: TurnControllerOptions);
|
|
224
|
+
get currentOwner(): TurnOwner;
|
|
225
|
+
get humanInControl(): boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
228
|
+
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
229
|
+
* Every automation op awaits this before touching the page.
|
|
230
|
+
*/
|
|
231
|
+
acquireAgent(): Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
234
|
+
* controller). "Human request always wins over the agent" — the agent parks
|
|
235
|
+
* at its next `acquireAgent()`.
|
|
236
|
+
*/
|
|
237
|
+
grantHuman(): boolean;
|
|
238
|
+
/** Return control to the agent and drain any parked automation ops. */
|
|
239
|
+
releaseHuman(): void;
|
|
240
|
+
private setOwner;
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
export { KeyInputPayload, type Logger, MouseInputPayload, REMOTE_HEADER_SIZE, REMOTE_MAX_PAYLOAD, RemoteFrame, RemoteFrameType, RemoteServiceClient, type RemoteServiceClientOptions, RemoteSurface, ResizePayload, ScreencastFrameMeta, SessionOpenPayload, SessionStatePayload, type SurfaceHandler, TerminalResizePayload, TurnController, type TurnControllerOptions, type TurnOwner, WheelInputPayload, decodeFrame, decodeJson, decodeScreencastFrame, encodeFrame, encodeJsonFrame, encodeScreencastFrame, isRemoteFrame };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//#region src/remote-protocol.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Remote-relay framing protocol — multiplexes interactive surfaces (browser
|
|
4
|
+
* screencast + input, terminal PTY) over the single outbound WS between the
|
|
5
|
+
* agent plugin and the Alfe remote relay service. The relay routes frames by
|
|
6
|
+
* `sessionId` (bytes 1-4) without parsing payloads.
|
|
7
|
+
*
|
|
8
|
+
* 5-byte header (WS preserves order, so no sequence numbers):
|
|
9
|
+
* Byte 0 Frame type (uint8)
|
|
10
|
+
* Bytes 1-4 Session ID (uint32, big-endian) — one viewer attachment
|
|
11
|
+
* Bytes 5+ Payload — JSON (utf-8) for control/input, raw bytes for media
|
|
12
|
+
*
|
|
13
|
+
* Direction is enforced at the relay:
|
|
14
|
+
* • plugin → viewer: SESSION_STATE, NAVIGATION, SCREENCAST_FRAME,
|
|
15
|
+
* TAKEOVER_GRANTED/DENIED, CONTROL_REVOKED, TERMINAL_DATA
|
|
16
|
+
* • viewer → plugin: SESSION_OPEN, SESSION_CLOSE, SCREENCAST_ACK, INPUT_*,
|
|
17
|
+
* RESIZE, TAKEOVER_REQUEST, RELEASE_CONTROL, TERMINAL_INPUT, TERMINAL_RESIZE
|
|
18
|
+
*
|
|
19
|
+
* Keep this file byte-identical with `services/remote/src/remote-protocol.ts`.
|
|
20
|
+
*/
|
|
21
|
+
declare const REMOTE_HEADER_SIZE = 5;
|
|
22
|
+
declare const RemoteFrameType: {
|
|
23
|
+
/** viewer→plugin: a viewer attached. Payload: SessionOpenPayload. */
|
|
24
|
+
readonly SESSION_OPEN: 1;
|
|
25
|
+
/** either direction: a viewer detached / session torn down. */
|
|
26
|
+
readonly SESSION_CLOSE: 2;
|
|
27
|
+
/** plugin→viewer: current surface state. Payload: SessionStatePayload. */
|
|
28
|
+
readonly SESSION_STATE: 3;
|
|
29
|
+
/** plugin→viewer (browser): the page navigated. Payload: { url }. */
|
|
30
|
+
readonly NAVIGATION: 4;
|
|
31
|
+
/** plugin→viewer: [metaLen:u16BE][meta JSON][raw JPEG]. */
|
|
32
|
+
readonly SCREENCAST_FRAME: 16;
|
|
33
|
+
/** viewer→plugin: { frameSeq } — drives ack-gated backpressure. */
|
|
34
|
+
readonly SCREENCAST_ACK: 17;
|
|
35
|
+
readonly INPUT_MOUSE: 32;
|
|
36
|
+
readonly INPUT_WHEEL: 33;
|
|
37
|
+
readonly INPUT_KEY: 34;
|
|
38
|
+
/** viewer→plugin: { width, height, dpr }. */
|
|
39
|
+
readonly RESIZE: 35;
|
|
40
|
+
readonly TAKEOVER_REQUEST: 48;
|
|
41
|
+
readonly TAKEOVER_GRANTED: 49;
|
|
42
|
+
readonly TAKEOVER_DENIED: 50;
|
|
43
|
+
readonly RELEASE_CONTROL: 51;
|
|
44
|
+
readonly CONTROL_REVOKED: 52;
|
|
45
|
+
/** plugin→viewer: raw PTY output bytes. */
|
|
46
|
+
readonly TERMINAL_DATA: 64;
|
|
47
|
+
/** viewer→plugin: raw keystroke bytes. */
|
|
48
|
+
readonly TERMINAL_INPUT: 65;
|
|
49
|
+
/** viewer→plugin: { cols, rows }. */
|
|
50
|
+
readonly TERMINAL_RESIZE: 66;
|
|
51
|
+
};
|
|
52
|
+
type RemoteFrameType = (typeof RemoteFrameType)[keyof typeof RemoteFrameType];
|
|
53
|
+
type RemoteSurface = "browser" | "terminal";
|
|
54
|
+
interface RemoteFrame {
|
|
55
|
+
type: RemoteFrameType;
|
|
56
|
+
sessionId: number;
|
|
57
|
+
payload: Buffer;
|
|
58
|
+
}
|
|
59
|
+
interface SessionOpenPayload {
|
|
60
|
+
surface: RemoteSurface;
|
|
61
|
+
/** Browser: initial viewport in device pixels. */
|
|
62
|
+
width?: number;
|
|
63
|
+
height?: number;
|
|
64
|
+
dpr?: number;
|
|
65
|
+
/** Terminal: initial PTY dimensions. */
|
|
66
|
+
cols?: number;
|
|
67
|
+
rows?: number;
|
|
68
|
+
}
|
|
69
|
+
interface SessionStatePayload {
|
|
70
|
+
surface: RemoteSurface;
|
|
71
|
+
url?: string;
|
|
72
|
+
title?: string;
|
|
73
|
+
loading?: boolean;
|
|
74
|
+
/** Who currently holds the write token (browser only). */
|
|
75
|
+
controller?: "agent" | "human" | "none";
|
|
76
|
+
}
|
|
77
|
+
interface ScreencastFrameMeta {
|
|
78
|
+
deviceWidth: number;
|
|
79
|
+
deviceHeight: number;
|
|
80
|
+
frameSeq: number;
|
|
81
|
+
offsetTop?: number;
|
|
82
|
+
pageScaleFactor?: number;
|
|
83
|
+
scrollOffsetX?: number;
|
|
84
|
+
scrollOffsetY?: number;
|
|
85
|
+
}
|
|
86
|
+
interface MouseInputPayload {
|
|
87
|
+
type: "mousemoved" | "mousepressed" | "mousereleased";
|
|
88
|
+
/** Normalized canvas coordinates in [0,1]. */
|
|
89
|
+
nx: number;
|
|
90
|
+
ny: number;
|
|
91
|
+
button?: "none" | "left" | "middle" | "right";
|
|
92
|
+
buttons?: number;
|
|
93
|
+
clickCount?: number;
|
|
94
|
+
modifiers?: number;
|
|
95
|
+
}
|
|
96
|
+
interface WheelInputPayload {
|
|
97
|
+
nx: number;
|
|
98
|
+
ny: number;
|
|
99
|
+
deltaX: number;
|
|
100
|
+
deltaY: number;
|
|
101
|
+
modifiers?: number;
|
|
102
|
+
}
|
|
103
|
+
interface KeyInputPayload {
|
|
104
|
+
type: "keydown" | "keyup" | "char";
|
|
105
|
+
key?: string;
|
|
106
|
+
code?: string;
|
|
107
|
+
text?: string;
|
|
108
|
+
modifiers?: number;
|
|
109
|
+
}
|
|
110
|
+
interface ResizePayload {
|
|
111
|
+
width: number;
|
|
112
|
+
height: number;
|
|
113
|
+
dpr?: number;
|
|
114
|
+
}
|
|
115
|
+
interface TerminalResizePayload {
|
|
116
|
+
cols: number;
|
|
117
|
+
rows: number;
|
|
118
|
+
}
|
|
119
|
+
declare function isRemoteFrame(data: Buffer): boolean;
|
|
120
|
+
declare function encodeFrame(type: RemoteFrameType, sessionId: number, payload?: Buffer): Buffer;
|
|
121
|
+
declare function decodeFrame(buf: Buffer): RemoteFrame;
|
|
122
|
+
declare function encodeJsonFrame(type: RemoteFrameType, sessionId: number, obj: unknown): Buffer;
|
|
123
|
+
/** Best-effort JSON decode — returns null on a malformed payload so a single
|
|
124
|
+
* corrupt frame can't crash the per-session handler. Callers assert the shape
|
|
125
|
+
* (e.g. `decodeJson(p) as SessionOpenPayload | null`). */
|
|
126
|
+
declare function decodeJson(payload: Buffer): unknown;
|
|
127
|
+
/** Encode a screencast frame: [metaLen:u16BE][meta JSON][raw JPEG]. The JPEG is
|
|
128
|
+
* forwarded raw (already base64-decoded from CDP) to avoid base64 bloat. */
|
|
129
|
+
declare function encodeScreencastFrame(sessionId: number, meta: ScreencastFrameMeta, jpeg: Buffer): Buffer;
|
|
130
|
+
declare function decodeScreencastFrame(payload: Buffer): {
|
|
131
|
+
meta: ScreencastFrameMeta;
|
|
132
|
+
jpeg: Buffer;
|
|
133
|
+
} | null;
|
|
134
|
+
/** Maximum WS message size (must align across daemon plugin and relay). */
|
|
135
|
+
declare const REMOTE_MAX_PAYLOAD: number;
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/surface.d.ts
|
|
138
|
+
interface SurfaceHandler {
|
|
139
|
+
/** Which surface this handler serves. */
|
|
140
|
+
readonly surface: RemoteSurface;
|
|
141
|
+
/** A viewer attached — open the surface for this session. */
|
|
142
|
+
openSession(sessionId: number, open: SessionOpenPayload): void | Promise<void>;
|
|
143
|
+
/** A subsequent frame for one of this handler's sessions (not SESSION_OPEN). */
|
|
144
|
+
handleFrame(frame: RemoteFrame): void;
|
|
145
|
+
/** The viewer detached — tear down this session's resources. */
|
|
146
|
+
closeSession(sessionId: number): void;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/types.d.ts
|
|
150
|
+
interface Logger {
|
|
151
|
+
info(msg: string, ...args: unknown[]): void;
|
|
152
|
+
warn(msg: string, ...args: unknown[]): void;
|
|
153
|
+
error(msg: string, ...args: unknown[]): void;
|
|
154
|
+
debug(msg: string, ...args: unknown[]): void;
|
|
155
|
+
}
|
|
156
|
+
interface RemoteServiceClientOptions {
|
|
157
|
+
/** Relay WebSocket URL (e.g. wss://remote.dev.alfe.ai/ws). */
|
|
158
|
+
wsUrl: string;
|
|
159
|
+
/** Agent API key for Bearer auth on the WS upgrade. */
|
|
160
|
+
apiKey: string;
|
|
161
|
+
/**
|
|
162
|
+
* Handler for every well-formed inbound frame from the relay. The plugin
|
|
163
|
+
* dispatches by `frame.sessionId` to the owning surface handler. Kept on the
|
|
164
|
+
* transport as a single callback so this package stays surface-agnostic.
|
|
165
|
+
*/
|
|
166
|
+
onFrame: (frame: RemoteFrame) => void;
|
|
167
|
+
/** Called when the WS connection state changes. */
|
|
168
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
169
|
+
/** Optional logger (defaults to console). */
|
|
170
|
+
logger?: Logger;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/client.d.ts
|
|
174
|
+
declare class RemoteServiceClient {
|
|
175
|
+
private readonly options;
|
|
176
|
+
private ws;
|
|
177
|
+
private stopped;
|
|
178
|
+
private connected;
|
|
179
|
+
private retryCount;
|
|
180
|
+
private retryTimer;
|
|
181
|
+
private heartbeatTimer;
|
|
182
|
+
private isAlive;
|
|
183
|
+
private readonly log;
|
|
184
|
+
constructor(options: RemoteServiceClientOptions);
|
|
185
|
+
get isConnected(): boolean;
|
|
186
|
+
/** Backpressure signal for the screencast pump — bytes queued but not flushed. */
|
|
187
|
+
get bufferedAmount(): number;
|
|
188
|
+
start(): void;
|
|
189
|
+
stop(): void;
|
|
190
|
+
/** Send a pre-encoded binary frame. No-op if the socket isn't open. */
|
|
191
|
+
sendFrame(buf: Buffer): void;
|
|
192
|
+
private doConnect;
|
|
193
|
+
private scheduleReconnect;
|
|
194
|
+
/**
|
|
195
|
+
* Detect a silently-dropped connection. Each tick: if no inbound liveness
|
|
196
|
+
* (pong/ping/message) was seen since the last tick, force-close so the
|
|
197
|
+
* `close` handler reconnects; otherwise send a fresh ping and arm the next.
|
|
198
|
+
*/
|
|
199
|
+
private startHeartbeat;
|
|
200
|
+
private clearHeartbeat;
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/turn-controller.d.ts
|
|
204
|
+
/**
|
|
205
|
+
* TurnController — the write-mutex between agent automation and a human during
|
|
206
|
+
* a browser co-browse handoff. The plugin owns the single CDP session, so this
|
|
207
|
+
* lives plugin-side; the relay only forwards the control frames.
|
|
208
|
+
*
|
|
209
|
+
* Only *writes* are gated — screencast reads always flow so the human sees the
|
|
210
|
+
* live page regardless of who holds the token. Agent automation ops call
|
|
211
|
+
* `acquireAgent()` and park while the human is in control; the human's input is
|
|
212
|
+
* injected only while `owner === 'human'`.
|
|
213
|
+
*/
|
|
214
|
+
type TurnOwner = "agent" | "human";
|
|
215
|
+
interface TurnControllerOptions {
|
|
216
|
+
/** Fired whenever ownership changes, so the plugin can emit SESSION_STATE. */
|
|
217
|
+
onOwnerChange?: (owner: TurnOwner) => void;
|
|
218
|
+
}
|
|
219
|
+
declare class TurnController {
|
|
220
|
+
private owner;
|
|
221
|
+
private waiters;
|
|
222
|
+
private readonly onOwnerChange?;
|
|
223
|
+
constructor(options?: TurnControllerOptions);
|
|
224
|
+
get currentOwner(): TurnOwner;
|
|
225
|
+
get humanInControl(): boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
228
|
+
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
229
|
+
* Every automation op awaits this before touching the page.
|
|
230
|
+
*/
|
|
231
|
+
acquireAgent(): Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
234
|
+
* controller). "Human request always wins over the agent" — the agent parks
|
|
235
|
+
* at its next `acquireAgent()`.
|
|
236
|
+
*/
|
|
237
|
+
grantHuman(): boolean;
|
|
238
|
+
/** Return control to the agent and drain any parked automation ops. */
|
|
239
|
+
releaseHuman(): void;
|
|
240
|
+
private setOwner;
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
export { KeyInputPayload, type Logger, MouseInputPayload, REMOTE_HEADER_SIZE, REMOTE_MAX_PAYLOAD, RemoteFrame, RemoteFrameType, RemoteServiceClient, type RemoteServiceClientOptions, RemoteSurface, ResizePayload, ScreencastFrameMeta, SessionOpenPayload, SessionStatePayload, type SurfaceHandler, TerminalResizePayload, TurnController, type TurnControllerOptions, type TurnOwner, WheelInputPayload, decodeFrame, decodeJson, decodeScreencastFrame, encodeFrame, encodeJsonFrame, encodeScreencastFrame, isRemoteFrame };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
//#region src/remote-protocol.ts
|
|
3
|
+
/**
|
|
4
|
+
* Remote-relay framing protocol — multiplexes interactive surfaces (browser
|
|
5
|
+
* screencast + input, terminal PTY) over the single outbound WS between the
|
|
6
|
+
* agent plugin and the Alfe remote relay service. The relay routes frames by
|
|
7
|
+
* `sessionId` (bytes 1-4) without parsing payloads.
|
|
8
|
+
*
|
|
9
|
+
* 5-byte header (WS preserves order, so no sequence numbers):
|
|
10
|
+
* Byte 0 Frame type (uint8)
|
|
11
|
+
* Bytes 1-4 Session ID (uint32, big-endian) — one viewer attachment
|
|
12
|
+
* Bytes 5+ Payload — JSON (utf-8) for control/input, raw bytes for media
|
|
13
|
+
*
|
|
14
|
+
* Direction is enforced at the relay:
|
|
15
|
+
* • plugin → viewer: SESSION_STATE, NAVIGATION, SCREENCAST_FRAME,
|
|
16
|
+
* TAKEOVER_GRANTED/DENIED, CONTROL_REVOKED, TERMINAL_DATA
|
|
17
|
+
* • viewer → plugin: SESSION_OPEN, SESSION_CLOSE, SCREENCAST_ACK, INPUT_*,
|
|
18
|
+
* RESIZE, TAKEOVER_REQUEST, RELEASE_CONTROL, TERMINAL_INPUT, TERMINAL_RESIZE
|
|
19
|
+
*
|
|
20
|
+
* Keep this file byte-identical with `services/remote/src/remote-protocol.ts`.
|
|
21
|
+
*/
|
|
22
|
+
const REMOTE_HEADER_SIZE = 5;
|
|
23
|
+
const RemoteFrameType = {
|
|
24
|
+
SESSION_OPEN: 1,
|
|
25
|
+
SESSION_CLOSE: 2,
|
|
26
|
+
SESSION_STATE: 3,
|
|
27
|
+
NAVIGATION: 4,
|
|
28
|
+
SCREENCAST_FRAME: 16,
|
|
29
|
+
SCREENCAST_ACK: 17,
|
|
30
|
+
INPUT_MOUSE: 32,
|
|
31
|
+
INPUT_WHEEL: 33,
|
|
32
|
+
INPUT_KEY: 34,
|
|
33
|
+
RESIZE: 35,
|
|
34
|
+
TAKEOVER_REQUEST: 48,
|
|
35
|
+
TAKEOVER_GRANTED: 49,
|
|
36
|
+
TAKEOVER_DENIED: 50,
|
|
37
|
+
RELEASE_CONTROL: 51,
|
|
38
|
+
CONTROL_REVOKED: 52,
|
|
39
|
+
TERMINAL_DATA: 64,
|
|
40
|
+
TERMINAL_INPUT: 65,
|
|
41
|
+
TERMINAL_RESIZE: 66
|
|
42
|
+
};
|
|
43
|
+
const MIN_FRAME_TYPE = RemoteFrameType.SESSION_OPEN;
|
|
44
|
+
const MAX_FRAME_TYPE = RemoteFrameType.TERMINAL_RESIZE;
|
|
45
|
+
function isRemoteFrame(data) {
|
|
46
|
+
if (data.length < 5) return false;
|
|
47
|
+
const type = data[0];
|
|
48
|
+
return type >= MIN_FRAME_TYPE && type <= MAX_FRAME_TYPE;
|
|
49
|
+
}
|
|
50
|
+
function encodeFrame(type, sessionId, payload) {
|
|
51
|
+
const body = payload ?? Buffer.alloc(0);
|
|
52
|
+
const buf = Buffer.allocUnsafe(5 + body.length);
|
|
53
|
+
buf.writeUInt8(type, 0);
|
|
54
|
+
buf.writeUInt32BE(sessionId >>> 0, 1);
|
|
55
|
+
if (body.length > 0) body.copy(buf, 5);
|
|
56
|
+
return buf;
|
|
57
|
+
}
|
|
58
|
+
function decodeFrame(buf) {
|
|
59
|
+
if (buf.length < 5) throw new Error(`Remote frame too short: ${String(buf.length)} bytes`);
|
|
60
|
+
return {
|
|
61
|
+
type: buf.readUInt8(0),
|
|
62
|
+
sessionId: buf.readUInt32BE(1),
|
|
63
|
+
payload: buf.subarray(5)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function encodeJsonFrame(type, sessionId, obj) {
|
|
67
|
+
return encodeFrame(type, sessionId, Buffer.from(JSON.stringify(obj), "utf-8"));
|
|
68
|
+
}
|
|
69
|
+
/** Best-effort JSON decode — returns null on a malformed payload so a single
|
|
70
|
+
* corrupt frame can't crash the per-session handler. Callers assert the shape
|
|
71
|
+
* (e.g. `decodeJson(p) as SessionOpenPayload | null`). */
|
|
72
|
+
function decodeJson(payload) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(payload.toString("utf-8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Encode a screencast frame: [metaLen:u16BE][meta JSON][raw JPEG]. The JPEG is
|
|
80
|
+
* forwarded raw (already base64-decoded from CDP) to avoid base64 bloat. */
|
|
81
|
+
function encodeScreencastFrame(sessionId, meta, jpeg) {
|
|
82
|
+
const metaJson = Buffer.from(JSON.stringify(meta), "utf-8");
|
|
83
|
+
const body = Buffer.allocUnsafe(2 + metaJson.length + jpeg.length);
|
|
84
|
+
body.writeUInt16BE(metaJson.length, 0);
|
|
85
|
+
metaJson.copy(body, 2);
|
|
86
|
+
jpeg.copy(body, 2 + metaJson.length);
|
|
87
|
+
return encodeFrame(RemoteFrameType.SCREENCAST_FRAME, sessionId, body);
|
|
88
|
+
}
|
|
89
|
+
function decodeScreencastFrame(payload) {
|
|
90
|
+
if (payload.length < 2) return null;
|
|
91
|
+
const metaLen = payload.readUInt16BE(0);
|
|
92
|
+
if (payload.length < 2 + metaLen) return null;
|
|
93
|
+
const meta = decodeJson(payload.subarray(2, 2 + metaLen));
|
|
94
|
+
if (!meta) return null;
|
|
95
|
+
return {
|
|
96
|
+
meta,
|
|
97
|
+
jpeg: payload.subarray(2 + metaLen)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Maximum WS message size (must align across daemon plugin and relay). */
|
|
101
|
+
const REMOTE_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/client.ts
|
|
104
|
+
/**
|
|
105
|
+
* RemoteServiceClient — runtime-agnostic outbound WS client to the Alfe remote
|
|
106
|
+
* relay service. Brings up a persistent WebSocket, decodes binary frames and
|
|
107
|
+
* hands them to a surface-agnostic `onFrame` callback, exposes `sendFrame` for
|
|
108
|
+
* outbound frames, and reconnects with backoff.
|
|
109
|
+
*
|
|
110
|
+
* One client per agent. Multiple viewer attachments (browser + terminal) are
|
|
111
|
+
* multiplexed by the relay over the single WS using `sessionId`.
|
|
112
|
+
*
|
|
113
|
+
* Reconnect + heartbeat logic mirrors `@alfe.ai/console-client`'s client.
|
|
114
|
+
*/
|
|
115
|
+
const RECONNECT_DELAYS = [
|
|
116
|
+
1e3,
|
|
117
|
+
2e3,
|
|
118
|
+
4e3,
|
|
119
|
+
8e3,
|
|
120
|
+
16e3,
|
|
121
|
+
3e4
|
|
122
|
+
];
|
|
123
|
+
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
124
|
+
const defaultLogger = {
|
|
125
|
+
info: (msg) => {
|
|
126
|
+
console.log(`[remote-client] ${msg}`);
|
|
127
|
+
},
|
|
128
|
+
warn: (msg) => {
|
|
129
|
+
console.warn(`[remote-client] ${msg}`);
|
|
130
|
+
},
|
|
131
|
+
error: (msg) => {
|
|
132
|
+
console.error(`[remote-client] ${msg}`);
|
|
133
|
+
},
|
|
134
|
+
debug: () => {}
|
|
135
|
+
};
|
|
136
|
+
function wsDataToBuffer(data) {
|
|
137
|
+
if (Buffer.isBuffer(data)) return data;
|
|
138
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
139
|
+
return Buffer.from(data);
|
|
140
|
+
}
|
|
141
|
+
var RemoteServiceClient = class {
|
|
142
|
+
ws = null;
|
|
143
|
+
stopped = false;
|
|
144
|
+
connected = false;
|
|
145
|
+
retryCount = 0;
|
|
146
|
+
retryTimer = null;
|
|
147
|
+
heartbeatTimer = null;
|
|
148
|
+
isAlive = false;
|
|
149
|
+
log;
|
|
150
|
+
constructor(options) {
|
|
151
|
+
this.options = options;
|
|
152
|
+
this.log = options.logger ?? defaultLogger;
|
|
153
|
+
}
|
|
154
|
+
get isConnected() {
|
|
155
|
+
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
156
|
+
}
|
|
157
|
+
/** Backpressure signal for the screencast pump — bytes queued but not flushed. */
|
|
158
|
+
get bufferedAmount() {
|
|
159
|
+
return this.ws?.bufferedAmount ?? 0;
|
|
160
|
+
}
|
|
161
|
+
start() {
|
|
162
|
+
this.log.debug("RemoteServiceClient starting...");
|
|
163
|
+
this.stopped = false;
|
|
164
|
+
this.doConnect();
|
|
165
|
+
}
|
|
166
|
+
stop() {
|
|
167
|
+
this.log.debug("RemoteServiceClient stopping...");
|
|
168
|
+
this.stopped = true;
|
|
169
|
+
if (this.retryTimer) {
|
|
170
|
+
clearTimeout(this.retryTimer);
|
|
171
|
+
this.retryTimer = null;
|
|
172
|
+
}
|
|
173
|
+
this.clearHeartbeat();
|
|
174
|
+
if (this.ws) {
|
|
175
|
+
try {
|
|
176
|
+
this.ws.close(1e3, "client shutdown");
|
|
177
|
+
} catch {}
|
|
178
|
+
this.ws = null;
|
|
179
|
+
}
|
|
180
|
+
this.connected = false;
|
|
181
|
+
}
|
|
182
|
+
/** Send a pre-encoded binary frame. No-op if the socket isn't open. */
|
|
183
|
+
sendFrame(buf) {
|
|
184
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(buf, { binary: true });
|
|
185
|
+
}
|
|
186
|
+
doConnect() {
|
|
187
|
+
if (this.stopped) return;
|
|
188
|
+
this.log.info(`Connecting to remote relay at ${this.options.wsUrl}...`);
|
|
189
|
+
this.ws = new WebSocket(this.options.wsUrl, {
|
|
190
|
+
headers: { authorization: `Bearer ${this.options.apiKey}` },
|
|
191
|
+
maxPayload: REMOTE_MAX_PAYLOAD,
|
|
192
|
+
handshakeTimeout: 1e4
|
|
193
|
+
});
|
|
194
|
+
this.ws.on("open", () => {
|
|
195
|
+
this.log.info("Connected to remote relay");
|
|
196
|
+
this.connected = true;
|
|
197
|
+
this.retryCount = 0;
|
|
198
|
+
this.startHeartbeat();
|
|
199
|
+
this.options.onConnectionChange?.(true);
|
|
200
|
+
});
|
|
201
|
+
this.ws.on("message", (data, isBinary) => {
|
|
202
|
+
this.isAlive = true;
|
|
203
|
+
if (!isBinary) return;
|
|
204
|
+
const buf = wsDataToBuffer(data);
|
|
205
|
+
if (!isRemoteFrame(buf)) return;
|
|
206
|
+
try {
|
|
207
|
+
this.options.onFrame(decodeFrame(buf));
|
|
208
|
+
} catch (err) {
|
|
209
|
+
this.log.warn(`Failed to handle remote frame: ${err.message}`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
this.ws.on("ping", () => {
|
|
213
|
+
this.isAlive = true;
|
|
214
|
+
this.ws?.pong();
|
|
215
|
+
});
|
|
216
|
+
this.ws.on("pong", () => {
|
|
217
|
+
this.isAlive = true;
|
|
218
|
+
});
|
|
219
|
+
this.ws.on("close", (code, reason) => {
|
|
220
|
+
this.clearHeartbeat();
|
|
221
|
+
this.log.warn(`Disconnected from remote relay (${String(code)}: ${reason.toString()})`);
|
|
222
|
+
this.connected = false;
|
|
223
|
+
this.options.onConnectionChange?.(false);
|
|
224
|
+
this.scheduleReconnect();
|
|
225
|
+
});
|
|
226
|
+
this.ws.on("error", (err) => {
|
|
227
|
+
this.log.error(`Remote relay WS error: ${err.message}`);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
scheduleReconnect() {
|
|
231
|
+
if (this.stopped) return;
|
|
232
|
+
const delay = RECONNECT_DELAYS[Math.min(this.retryCount, RECONNECT_DELAYS.length - 1)];
|
|
233
|
+
this.retryCount += 1;
|
|
234
|
+
this.log.info(`Reconnecting to remote relay in ${String(delay)}ms (attempt ${String(this.retryCount)})...`);
|
|
235
|
+
this.retryTimer = setTimeout(() => {
|
|
236
|
+
this.retryTimer = null;
|
|
237
|
+
this.doConnect();
|
|
238
|
+
}, delay);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Detect a silently-dropped connection. Each tick: if no inbound liveness
|
|
242
|
+
* (pong/ping/message) was seen since the last tick, force-close so the
|
|
243
|
+
* `close` handler reconnects; otherwise send a fresh ping and arm the next.
|
|
244
|
+
*/
|
|
245
|
+
startHeartbeat() {
|
|
246
|
+
this.clearHeartbeat();
|
|
247
|
+
this.isAlive = true;
|
|
248
|
+
const timer = setInterval(() => {
|
|
249
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
250
|
+
if (!this.isAlive) {
|
|
251
|
+
this.log.warn("Remote relay heartbeat timed out — terminating stale connection");
|
|
252
|
+
this.ws.terminate();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.isAlive = false;
|
|
256
|
+
try {
|
|
257
|
+
this.ws.ping();
|
|
258
|
+
} catch {}
|
|
259
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
260
|
+
timer.unref();
|
|
261
|
+
this.heartbeatTimer = timer;
|
|
262
|
+
}
|
|
263
|
+
clearHeartbeat() {
|
|
264
|
+
if (this.heartbeatTimer) {
|
|
265
|
+
clearInterval(this.heartbeatTimer);
|
|
266
|
+
this.heartbeatTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/turn-controller.ts
|
|
272
|
+
var TurnController = class {
|
|
273
|
+
owner = "agent";
|
|
274
|
+
waiters = [];
|
|
275
|
+
onOwnerChange;
|
|
276
|
+
constructor(options = {}) {
|
|
277
|
+
this.onOwnerChange = options.onOwnerChange;
|
|
278
|
+
}
|
|
279
|
+
get currentOwner() {
|
|
280
|
+
return this.owner;
|
|
281
|
+
}
|
|
282
|
+
get humanInControl() {
|
|
283
|
+
return this.owner === "human";
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
287
|
+
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
288
|
+
* Every automation op awaits this before touching the page.
|
|
289
|
+
*/
|
|
290
|
+
acquireAgent() {
|
|
291
|
+
if (this.owner === "agent") return Promise.resolve();
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
this.waiters.push(resolve);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
298
|
+
* controller). "Human request always wins over the agent" — the agent parks
|
|
299
|
+
* at its next `acquireAgent()`.
|
|
300
|
+
*/
|
|
301
|
+
grantHuman() {
|
|
302
|
+
if (this.owner === "human") return false;
|
|
303
|
+
this.setOwner("human");
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
/** Return control to the agent and drain any parked automation ops. */
|
|
307
|
+
releaseHuman() {
|
|
308
|
+
if (this.owner === "agent") return;
|
|
309
|
+
this.setOwner("agent");
|
|
310
|
+
const parked = this.waiters;
|
|
311
|
+
this.waiters = [];
|
|
312
|
+
for (const resolve of parked) resolve();
|
|
313
|
+
}
|
|
314
|
+
setOwner(owner) {
|
|
315
|
+
if (this.owner === owner) return;
|
|
316
|
+
this.owner = owner;
|
|
317
|
+
this.onOwnerChange?.(owner);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
//#endregion
|
|
321
|
+
export { REMOTE_HEADER_SIZE, REMOTE_MAX_PAYLOAD, RemoteFrameType, RemoteServiceClient, TurnController, decodeFrame, decodeJson, decodeScreencastFrame, encodeFrame, encodeJsonFrame, encodeScreencastFrame, isRemoteFrame };
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/remote",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Runtime-agnostic transport for the Alfe interactive remote-control relay — multiplexes browser screencast and terminal PTY surfaces over one outbound WebSocket",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"require": "./dist/index.cjs",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"ws": "^8.18.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "UNLICENSED",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsdown",
|
|
24
|
+
"dev": "tsdown --watch",
|
|
25
|
+
"test": "vitest run --passWithNoTests",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "eslint ."
|
|
28
|
+
}
|
|
29
|
+
}
|