@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 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;
@@ -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 };
@@ -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
+ }