@h-rig/pi-rig 0.0.6-alpha.9 → 0.0.6-alpha.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ import { RIG_PROTOCOL_VERSION } from "@rig/contracts";
2
+ export { RIG_PROTOCOL_VERSION };
3
+ export type RigExtensionContext = {
4
+ readonly active: boolean;
5
+ readonly runId?: string;
6
+ readonly taskId?: string;
7
+ readonly serverUrl?: string;
8
+ readonly projectRoot?: string;
9
+ readonly authToken?: string;
10
+ readonly steeringPollMs?: number;
11
+ readonly operatorSession?: boolean;
12
+ };
13
+ export type RigBridgeFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
14
+ /**
15
+ * Result of the client–server protocol handshake performed at extension init.
16
+ *
17
+ * - "compatible": the server reports the same RIG_PROTOCOL_VERSION.
18
+ * - "mismatch": the server reports a different (or no) protocol version; the
19
+ * bridge must disable itself instead of failing on a later request.
20
+ * - "indeterminate": the server could not be reached, so compatibility is
21
+ * unknown; the bridge stays enabled and normal error handling applies.
22
+ */
23
+ export type RigProtocolCheck = {
24
+ readonly status: "compatible" | "mismatch" | "indeterminate";
25
+ readonly serverProtocolVersion: number | null;
26
+ readonly message: string | null;
27
+ };
28
+ export declare function createRigContextFromEnv(env?: Record<string, string | undefined>): RigExtensionContext;
29
+ /** Upper bound on any single bridge request. Observed remote servers answer
30
+ * in 0.5–25s under load; without a bound, one wedged fetch freezes every
31
+ * gate-dependent surface (commands, tools, steering) for the session. */
32
+ export declare const BRIDGE_REQUEST_TIMEOUT_MS = 30000;
33
+ /** Tighter bound for the protocol handshake: a timeout maps to
34
+ * "indeterminate" (bridge stays enabled, handshake retries on the next
35
+ * bridge call), so failing fast here only shortens the blind window. */
36
+ export declare const PROTOCOL_CHECK_TIMEOUT_MS = 10000;
37
+ export declare class RigBridgeClient {
38
+ readonly context: RigExtensionContext;
39
+ readonly fetchImpl: RigBridgeFetch;
40
+ constructor(input: {
41
+ context: RigExtensionContext;
42
+ fetchImpl?: RigBridgeFetch;
43
+ });
44
+ request(pathname: string, init?: RequestInit, timeoutMs?: number): Promise<unknown>;
45
+ status(timeoutMs?: number): Promise<Record<string, unknown>>;
46
+ /**
47
+ * Validate that this pi-rig speaks the same protocol as the Rig server.
48
+ * Reads `protocolVersion` from the server bootstrap/status payload; servers
49
+ * that predate the handshake report none and count as v0 (mismatch).
50
+ */
51
+ checkProtocolCompatibility(): Promise<RigProtocolCheck>;
52
+ listTasks(): Promise<Array<Record<string, unknown>>>;
53
+ runTask(taskId?: string): Promise<{
54
+ runId: string;
55
+ }>;
56
+ attach(runId?: string | undefined): Promise<Record<string, unknown>>;
57
+ runLogs(runId?: string | undefined, limit?: number): Promise<Array<Record<string, unknown>>>;
58
+ runTimeline(runId?: string | undefined, limit?: number): Promise<Array<Record<string, unknown>>>;
59
+ steer(message: string, runId?: string | undefined): Promise<Record<string, unknown>>;
60
+ stop(runId?: string | undefined): Promise<Record<string, unknown>>;
61
+ pollSteering(runId?: string | undefined): Promise<Array<Record<string, unknown>>>;
62
+ consumeSteering(runId?: string | undefined): Promise<Array<Record<string, unknown>>>;
63
+ emitEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>;
64
+ readTaskMetadata(taskId?: string | undefined): Promise<Record<string, unknown>>;
65
+ private piProxy;
66
+ /** The worker session's slash commands (name + description). */
67
+ workerCommands(runId?: string | undefined): Promise<Array<Record<string, unknown>>>;
68
+ /** Run one of the worker session's slash commands. */
69
+ workerRunCommand(command: string, args: string, runId?: string | undefined): Promise<Record<string, unknown>>;
70
+ /** Execute a shell command inside the WORKER's workspace. */
71
+ workerShell(command: string, runId?: string | undefined): Promise<Record<string, unknown>>;
72
+ /** Abort the worker session's current turn. */
73
+ workerAbort(runId?: string | undefined): Promise<Record<string, unknown>>;
74
+ /** The worker session's real capabilities (tools, extensions, hooks, skills, model, cwd). */
75
+ workerCapabilities(runId?: string | undefined): Promise<Record<string, unknown>>;
76
+ /** Answer a worker extension-UI request (select/confirm/input) via the server proxy. */
77
+ workerRespondExtensionUi(requestId: string, response: Record<string, unknown>, runId?: string | undefined): Promise<Record<string, unknown>>;
78
+ /** The run's full Pi session transcript (jsonl) — the cockpit's source of truth. */
79
+ fetchRunSessionFile(runId?: string | undefined): Promise<{
80
+ fileName: string;
81
+ content: string;
82
+ } | null>;
83
+ }
84
+ export type RigWebSocketEvent = {
85
+ readonly data?: unknown;
86
+ };
87
+ /**
88
+ * Minimal WebSocket surface the bridge needs. Matches the global
89
+ * `WebSocket` available in Bun and Node >= 22; tests inject fakes.
90
+ */
91
+ export type RigWebSocketLike = {
92
+ addEventListener(type: "open" | "message" | "close" | "error", listener: (event: RigWebSocketEvent) => void): void;
93
+ send(data: string): void;
94
+ close(): void;
95
+ };
96
+ export type RigWebSocketFactory = (url: string) => RigWebSocketLike;
97
+ export type RigBridgeSocketHandlers = {
98
+ /** Steering message pushed for this context's run (already runId-filtered). */
99
+ readonly onSteeringMessage?: (message: Record<string, unknown>) => void;
100
+ /** Any engine event on the `rig.event` channel (NOT runId-filtered). */
101
+ readonly onRigEvent?: (event: Record<string, unknown>) => void;
102
+ readonly onSnapshotInvalidated?: () => void;
103
+ readonly onConnect?: () => void;
104
+ readonly onDisconnect?: () => void;
105
+ };
106
+ export declare function buildRigWebSocketUrl(serverUrl: string, authToken?: string): string;
107
+ /**
108
+ * Reconnecting WebSocket subscriber for Rig server push channels.
109
+ *
110
+ * - Connects to the same host as the HTTP base URL (`ws://`/`wss://`,
111
+ * token as query param).
112
+ * - Reconnects with exponential backoff (1s doubling to 30s); `connected`
113
+ * lets callers gate their HTTP-polling fallback on socket health.
114
+ * - Steering delivery is confirmed over the same socket via the
115
+ * `rig.ackRunSteering` RPC method.
116
+ */
117
+ export declare class RigBridgeSocket {
118
+ private readonly context;
119
+ private readonly handlers;
120
+ private readonly factory;
121
+ private readonly reconnectBaseMs;
122
+ private readonly reconnectMaxMs;
123
+ private socket;
124
+ private connectedFlag;
125
+ private closed;
126
+ private started;
127
+ private attempt;
128
+ private reconnectTimer;
129
+ private ackSequence;
130
+ constructor(input: {
131
+ readonly context: RigExtensionContext;
132
+ readonly handlers?: RigBridgeSocketHandlers;
133
+ readonly webSocketFactory?: RigWebSocketFactory;
134
+ readonly reconnectBaseMs?: number;
135
+ readonly reconnectMaxMs?: number;
136
+ });
137
+ get connected(): boolean;
138
+ /** Returns false when no server URL or WebSocket implementation exists. */
139
+ start(): boolean;
140
+ close(): void;
141
+ /** Confirm steering delivery over the socket. Fire-and-forget: on failure the
142
+ * polling fallback re-delivers and the caller's dedupe set absorbs it. */
143
+ ackSteering(runId: string, ids: readonly string[]): void;
144
+ private connect;
145
+ private scheduleReconnect;
146
+ private handleMessage;
147
+ }
148
+ /** WS URL for the server's worker-session event proxy
149
+ * (`/api/runs/:id/pi/events` → daemon events). Token rides the query param
150
+ * (the upgrade handler reads it); rigProjectRoot scopes multi-root servers. */
151
+ export declare function buildRunPiEventsWebSocketUrl(context: RigExtensionContext): string | null;
152
+ /**
153
+ * Reconnecting subscriber for the worker session's event stream (the same
154
+ * frames the worker daemon emits: `pi.event` envelopes, `status.update`,
155
+ * `activity.update`). This is the live-mirror transport: the operator
156
+ * console renders worker turns from these frames.
157
+ *
158
+ * The proxy returns 202 while the session is still starting and the run may
159
+ * outlive several daemon generations, so reconnection uses the same backoff
160
+ * discipline as RigBridgeSocket.
161
+ */
162
+ export declare class RigWorkerEventsSocket {
163
+ private readonly context;
164
+ private readonly handlers;
165
+ private readonly factory;
166
+ private readonly reconnectBaseMs;
167
+ private readonly reconnectMaxMs;
168
+ private socket;
169
+ private connectedFlag;
170
+ private closed;
171
+ private started;
172
+ private attempt;
173
+ private reconnectTimer;
174
+ constructor(input: {
175
+ readonly context: RigExtensionContext;
176
+ readonly handlers?: RigWorkerEventsSocket["handlers"];
177
+ readonly webSocketFactory?: RigWebSocketFactory;
178
+ readonly reconnectBaseMs?: number;
179
+ readonly reconnectMaxMs?: number;
180
+ });
181
+ get connected(): boolean;
182
+ start(): boolean;
183
+ close(): void;
184
+ private connect;
185
+ private scheduleReconnect;
186
+ }
@@ -3,6 +3,7 @@
3
3
  import { existsSync, readFileSync } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { dirname, resolve } from "path";
6
+ import { RIG_PROTOCOL_VERSION, RIG_WS_CHANNELS, RIG_WS_METHODS } from "@rig/contracts";
6
7
  function cleanString(value) {
7
8
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
8
9
  }
@@ -79,8 +80,9 @@ function createRigContextFromEnv(env = process.env) {
79
80
  const discovered = discoverRigContext(env);
80
81
  const serverUrl = env.RIG_SERVER_URL ?? env.RIG_SERVER_BASE_URL ?? discovered.serverUrl;
81
82
  const projectRoot = env.RIG_PROJECT_ROOT ?? env.PROJECT_RIG_ROOT ?? discovered.projectRoot;
82
- const authToken = env.RIG_AUTH_TOKEN ?? env.RIG_GITHUB_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN ?? discovered.authToken;
83
+ const authToken = env.RIG_AUTH_TOKEN ?? env.RIG_SERVER_AUTH_TOKEN ?? discovered.authToken;
83
84
  const steeringPollMs = cleanNonNegativeInteger(env.RIG_STEERING_POLL_MS);
85
+ const operatorSession = env.RIG_PI_OPERATOR_SESSION === "1" || env.RIG_PI_OPERATOR_SESSION === "true";
84
86
  const active = Boolean(runId || taskId || serverUrl || projectRoot);
85
87
  if (!active)
86
88
  return { active: false };
@@ -91,7 +93,8 @@ function createRigContextFromEnv(env = process.env) {
91
93
  ...serverUrl ? { serverUrl } : {},
92
94
  ...projectRoot ? { projectRoot } : {},
93
95
  ...authToken ? { authToken } : {},
94
- ...steeringPollMs !== null ? { steeringPollMs } : {}
96
+ ...steeringPollMs !== null ? { steeringPollMs } : {},
97
+ ...operatorSession ? { operatorSession } : {}
95
98
  };
96
99
  }
97
100
  function joinUrl(baseUrl, pathname) {
@@ -116,6 +119,8 @@ function requireServerUrl(context) {
116
119
  }
117
120
  return context.serverUrl;
118
121
  }
122
+ var BRIDGE_REQUEST_TIMEOUT_MS = 30000;
123
+ var PROTOCOL_CHECK_TIMEOUT_MS = 1e4;
119
124
 
120
125
  class RigBridgeClient {
121
126
  context;
@@ -124,18 +129,46 @@ class RigBridgeClient {
124
129
  this.context = input.context;
125
130
  this.fetchImpl = input.fetchImpl ?? fetch;
126
131
  }
127
- async request(pathname, init) {
132
+ async request(pathname, init, timeoutMs = BRIDGE_REQUEST_TIMEOUT_MS) {
128
133
  const headers = new Headers(init?.headers);
129
134
  if (this.context.authToken && !headers.has("authorization")) {
130
135
  headers.set("authorization", `Bearer ${this.context.authToken}`);
131
136
  }
132
- const response = await this.fetchImpl(joinUrl(requireServerUrl(this.context), pathname), { ...init, headers });
137
+ if (this.context.projectRoot && !headers.has("x-rig-project-root")) {
138
+ headers.set("x-rig-project-root", this.context.projectRoot);
139
+ }
140
+ const signal = init?.signal ?? (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined);
141
+ const response = await this.fetchImpl(joinUrl(requireServerUrl(this.context), pathname), { ...init, headers, signal });
133
142
  return readJsonResponse(response);
134
143
  }
135
- async status() {
136
- const payload = await this.request("/api/server/status");
144
+ async status(timeoutMs) {
145
+ const payload = await this.request("/api/server/status", undefined, timeoutMs);
137
146
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
138
147
  }
148
+ async checkProtocolCompatibility() {
149
+ let payload;
150
+ try {
151
+ payload = await this.status(PROTOCOL_CHECK_TIMEOUT_MS);
152
+ } catch (error) {
153
+ return {
154
+ status: "indeterminate",
155
+ serverProtocolVersion: null,
156
+ message: `Rig server protocol check failed: ${error instanceof Error ? error.message : String(error)}`
157
+ };
158
+ }
159
+ const raw = payload.protocolVersion;
160
+ const serverProtocolVersion = typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : null;
161
+ if (serverProtocolVersion === RIG_PROTOCOL_VERSION) {
162
+ return { status: "compatible", serverProtocolVersion, message: null };
163
+ }
164
+ const serverLabel = serverProtocolVersion === null ? "v0 (no protocolVersion reported)" : `v${serverProtocolVersion}`;
165
+ const updateHint = (serverProtocolVersion ?? 0) < RIG_PROTOCOL_VERSION ? "update the Rig server (upgrade @h-rig/cli / @h-rig/server and restart it)" : "update pi-rig (upgrade @h-rig/pi-rig, or reinstall the extension from this server)";
166
+ return {
167
+ status: "mismatch",
168
+ serverProtocolVersion,
169
+ message: `Rig server speaks protocol ${serverLabel}, this pi-rig speaks v${RIG_PROTOCOL_VERSION} \u2014 ${updateHint}. The Rig bridge is disabled for this session.`
170
+ };
171
+ }
139
172
  async listTasks() {
140
173
  const payload = await this.request("/api/workspace/tasks");
141
174
  return Array.isArray(payload) ? payload.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) : [];
@@ -157,6 +190,20 @@ class RigBridgeClient {
157
190
  const payload = await this.request(`/api/runs/${encodeURIComponent(runId)}`);
158
191
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { runId };
159
192
  }
193
+ async runLogs(runId = this.context.runId, limit = 20) {
194
+ if (!runId)
195
+ throw new Error("runId is required");
196
+ const payload = await this.request(`/api/runs/${encodeURIComponent(runId)}/logs?limit=${encodeURIComponent(String(limit))}`);
197
+ const entries = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.entries : null;
198
+ return Array.isArray(entries) ? entries.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) : [];
199
+ }
200
+ async runTimeline(runId = this.context.runId, limit = 20) {
201
+ if (!runId)
202
+ throw new Error("runId is required");
203
+ const payload = await this.request(`/api/runs/${encodeURIComponent(runId)}/timeline?limit=${encodeURIComponent(String(limit))}`);
204
+ const entries = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.entries : null;
205
+ return Array.isArray(entries) ? entries.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) : [];
206
+ }
160
207
  async steer(message, runId = this.context.runId) {
161
208
  if (!runId)
162
209
  throw new Error("runId is required");
@@ -167,6 +214,16 @@ class RigBridgeClient {
167
214
  });
168
215
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true };
169
216
  }
217
+ async stop(runId = this.context.runId) {
218
+ if (!runId)
219
+ throw new Error("runId is required");
220
+ const payload = await this.request("/api/runs/stop", {
221
+ method: "POST",
222
+ headers: { "content-type": "application/json" },
223
+ body: JSON.stringify({ runId })
224
+ });
225
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true, runId };
226
+ }
170
227
  async pollSteering(runId = this.context.runId) {
171
228
  if (!runId)
172
229
  return [];
@@ -198,8 +255,369 @@ class RigBridgeClient {
198
255
  const payload = await this.request(`/api/workspace/tasks/${encodeURIComponent(taskId)}`);
199
256
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
200
257
  }
258
+ async piProxy(action, init, runId = this.context.runId) {
259
+ if (!runId)
260
+ throw new Error("runId is required");
261
+ return this.request(`/api/runs/${encodeURIComponent(runId)}/pi/${action}`, init);
262
+ }
263
+ async workerCommands(runId = this.context.runId) {
264
+ const payload = await this.piProxy("commands", undefined, runId);
265
+ const commands = Array.isArray(payload) ? payload : payload && typeof payload === "object" && !Array.isArray(payload) && Array.isArray(payload.commands) ? payload.commands : [];
266
+ return commands.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
267
+ }
268
+ async workerRunCommand(command, args, runId = this.context.runId) {
269
+ const text = `/${command}${args.trim() ? ` ${args.trim()}` : ""}`;
270
+ const payload = await this.piProxy("commands/run", {
271
+ method: "POST",
272
+ headers: { "content-type": "application/json" },
273
+ body: JSON.stringify({ text })
274
+ }, runId);
275
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true };
276
+ }
277
+ async workerShell(command, runId = this.context.runId) {
278
+ const payload = await this.piProxy("shell", {
279
+ method: "POST",
280
+ headers: { "content-type": "application/json" },
281
+ body: JSON.stringify({ text: command })
282
+ }, runId);
283
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true };
284
+ }
285
+ async workerAbort(runId = this.context.runId) {
286
+ const payload = await this.piProxy("abort", { method: "POST" }, runId);
287
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true };
288
+ }
289
+ async workerCapabilities(runId = this.context.runId) {
290
+ const payload = await this.piProxy("capabilities", undefined, runId);
291
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
292
+ }
293
+ async workerRespondExtensionUi(requestId, response, runId = this.context.runId) {
294
+ const payload = await this.piProxy("extension-ui/respond", {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify({ requestId, ...response })
298
+ }, runId);
299
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { ok: true };
300
+ }
301
+ async fetchRunSessionFile(runId = this.context.runId) {
302
+ if (!runId)
303
+ return null;
304
+ const payload = await this.request(`/api/runs/${encodeURIComponent(runId)}/session-file`);
305
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
306
+ return null;
307
+ const record = payload;
308
+ if (record.ok !== true || typeof record.content !== "string" || !record.content.trim())
309
+ return null;
310
+ return {
311
+ fileName: typeof record.fileName === "string" && record.fileName.trim() ? record.fileName : `rig-run-${runId}.jsonl`,
312
+ content: record.content
313
+ };
314
+ }
315
+ }
316
+ function buildRigWebSocketUrl(serverUrl, authToken) {
317
+ const url = new URL(serverUrl);
318
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
319
+ if (authToken) {
320
+ url.searchParams.set("token", authToken);
321
+ }
322
+ return url.toString();
323
+ }
324
+ function defaultWebSocketFactory() {
325
+ const ctor = globalThis.WebSocket;
326
+ if (typeof ctor !== "function")
327
+ return null;
328
+ return (url) => new ctor(url);
329
+ }
330
+ function webSocketEventText(data) {
331
+ if (typeof data === "string")
332
+ return data;
333
+ if (data instanceof Uint8Array)
334
+ return new TextDecoder().decode(data);
335
+ return null;
336
+ }
337
+
338
+ class RigBridgeSocket {
339
+ context;
340
+ handlers;
341
+ factory;
342
+ reconnectBaseMs;
343
+ reconnectMaxMs;
344
+ socket = null;
345
+ connectedFlag = false;
346
+ closed = false;
347
+ started = false;
348
+ attempt = 0;
349
+ reconnectTimer = null;
350
+ ackSequence = 0;
351
+ constructor(input) {
352
+ this.context = input.context;
353
+ this.handlers = input.handlers ?? {};
354
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
355
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
356
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
357
+ }
358
+ get connected() {
359
+ return this.connectedFlag;
360
+ }
361
+ start() {
362
+ if (this.closed)
363
+ return false;
364
+ if (this.started)
365
+ return true;
366
+ if (!this.context.serverUrl || !this.factory)
367
+ return false;
368
+ this.started = true;
369
+ this.connect();
370
+ return true;
371
+ }
372
+ close() {
373
+ this.closed = true;
374
+ this.connectedFlag = false;
375
+ if (this.reconnectTimer) {
376
+ clearTimeout(this.reconnectTimer);
377
+ this.reconnectTimer = null;
378
+ }
379
+ try {
380
+ this.socket?.close();
381
+ } catch {}
382
+ this.socket = null;
383
+ }
384
+ ackSteering(runId, ids) {
385
+ if (!this.connectedFlag || !this.socket || ids.length === 0)
386
+ return;
387
+ try {
388
+ this.socket.send(JSON.stringify({
389
+ id: `pi-rig-steer-ack-${++this.ackSequence}`,
390
+ body: { _tag: RIG_WS_METHODS.ackRunSteering, runId, ids }
391
+ }));
392
+ } catch {}
393
+ }
394
+ connect() {
395
+ if (this.closed || !this.context.serverUrl || !this.factory)
396
+ return;
397
+ let socket;
398
+ try {
399
+ socket = this.factory(buildRigWebSocketUrl(this.context.serverUrl, this.context.authToken));
400
+ } catch {
401
+ this.scheduleReconnect();
402
+ return;
403
+ }
404
+ this.socket = socket;
405
+ let gone = false;
406
+ socket.addEventListener("open", () => {
407
+ if (this.closed || gone)
408
+ return;
409
+ this.attempt = 0;
410
+ this.connectedFlag = true;
411
+ this.handlers.onConnect?.();
412
+ });
413
+ const onGone = () => {
414
+ if (gone)
415
+ return;
416
+ gone = true;
417
+ const wasConnected = this.connectedFlag;
418
+ this.connectedFlag = false;
419
+ try {
420
+ socket.close();
421
+ } catch {}
422
+ if (this.socket === socket)
423
+ this.socket = null;
424
+ if (this.closed)
425
+ return;
426
+ if (wasConnected)
427
+ this.handlers.onDisconnect?.();
428
+ this.scheduleReconnect();
429
+ };
430
+ socket.addEventListener("close", onGone);
431
+ socket.addEventListener("error", onGone);
432
+ socket.addEventListener("message", (event) => {
433
+ if (!this.closed)
434
+ this.handleMessage(event);
435
+ });
436
+ }
437
+ scheduleReconnect() {
438
+ if (this.closed || this.reconnectTimer)
439
+ return;
440
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
441
+ this.attempt += 1;
442
+ const timer = setTimeout(() => {
443
+ this.reconnectTimer = null;
444
+ this.connect();
445
+ }, delay);
446
+ this.reconnectTimer = timer;
447
+ if (typeof timer.unref === "function") {
448
+ timer.unref();
449
+ }
450
+ }
451
+ handleMessage(event) {
452
+ const text = webSocketEventText(event.data);
453
+ if (!text)
454
+ return;
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(text);
458
+ } catch {
459
+ return;
460
+ }
461
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
462
+ return;
463
+ const record = parsed;
464
+ if (record.type !== "push" || typeof record.channel !== "string")
465
+ return;
466
+ const data = record.data && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
467
+ if (record.channel === RIG_WS_CHANNELS.runSteering) {
468
+ if (!data || !this.context.runId || data.runId !== this.context.runId)
469
+ return;
470
+ const message = data.message && typeof data.message === "object" && !Array.isArray(data.message) ? data.message : null;
471
+ if (message)
472
+ this.handlers.onSteeringMessage?.(message);
473
+ return;
474
+ }
475
+ if (record.channel === RIG_WS_CHANNELS.event) {
476
+ if (data)
477
+ this.handlers.onRigEvent?.(data);
478
+ return;
479
+ }
480
+ if (record.channel === RIG_WS_CHANNELS.snapshotInvalidated) {
481
+ this.handlers.onSnapshotInvalidated?.();
482
+ }
483
+ }
484
+ }
485
+ function buildRunPiEventsWebSocketUrl(context) {
486
+ if (!context.serverUrl || !context.runId)
487
+ return null;
488
+ const url = new URL(`${context.serverUrl.replace(/\/+$/, "")}/api/runs/${encodeURIComponent(context.runId)}/pi/events`);
489
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
490
+ if (context.authToken)
491
+ url.searchParams.set("token", context.authToken);
492
+ if (context.projectRoot)
493
+ url.searchParams.set("rigProjectRoot", context.projectRoot);
494
+ return url.toString();
495
+ }
496
+
497
+ class RigWorkerEventsSocket {
498
+ context;
499
+ handlers;
500
+ factory;
501
+ reconnectBaseMs;
502
+ reconnectMaxMs;
503
+ socket = null;
504
+ connectedFlag = false;
505
+ closed = false;
506
+ started = false;
507
+ attempt = 0;
508
+ reconnectTimer = null;
509
+ constructor(input) {
510
+ this.context = input.context;
511
+ this.handlers = input.handlers ?? {};
512
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
513
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
514
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
515
+ }
516
+ get connected() {
517
+ return this.connectedFlag;
518
+ }
519
+ start() {
520
+ if (this.closed)
521
+ return false;
522
+ if (this.started)
523
+ return true;
524
+ if (!buildRunPiEventsWebSocketUrl(this.context) || !this.factory)
525
+ return false;
526
+ this.started = true;
527
+ this.connect();
528
+ return true;
529
+ }
530
+ close() {
531
+ this.closed = true;
532
+ this.connectedFlag = false;
533
+ if (this.reconnectTimer) {
534
+ clearTimeout(this.reconnectTimer);
535
+ this.reconnectTimer = null;
536
+ }
537
+ try {
538
+ this.socket?.close();
539
+ } catch {}
540
+ this.socket = null;
541
+ }
542
+ connect() {
543
+ const target = buildRunPiEventsWebSocketUrl(this.context);
544
+ if (this.closed || !target || !this.factory)
545
+ return;
546
+ let socket;
547
+ try {
548
+ socket = this.factory(target);
549
+ } catch {
550
+ this.scheduleReconnect();
551
+ return;
552
+ }
553
+ this.socket = socket;
554
+ let gone = false;
555
+ socket.addEventListener("open", () => {
556
+ if (this.closed || gone)
557
+ return;
558
+ this.attempt = 0;
559
+ this.connectedFlag = true;
560
+ this.handlers.onConnect?.();
561
+ });
562
+ const onGone = () => {
563
+ if (gone)
564
+ return;
565
+ gone = true;
566
+ const wasConnected = this.connectedFlag;
567
+ this.connectedFlag = false;
568
+ try {
569
+ socket.close();
570
+ } catch {}
571
+ if (this.socket === socket)
572
+ this.socket = null;
573
+ if (this.closed)
574
+ return;
575
+ if (wasConnected)
576
+ this.handlers.onDisconnect?.();
577
+ this.scheduleReconnect();
578
+ };
579
+ socket.addEventListener("close", onGone);
580
+ socket.addEventListener("error", onGone);
581
+ socket.addEventListener("message", (event) => {
582
+ if (this.closed)
583
+ return;
584
+ const text = webSocketEventText(event.data);
585
+ if (!text)
586
+ return;
587
+ let parsed;
588
+ try {
589
+ parsed = JSON.parse(text);
590
+ } catch {
591
+ return;
592
+ }
593
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
594
+ this.handlers.onFrame?.(parsed);
595
+ }
596
+ });
597
+ }
598
+ scheduleReconnect() {
599
+ if (this.closed || this.reconnectTimer)
600
+ return;
601
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
602
+ this.attempt += 1;
603
+ const timer = setTimeout(() => {
604
+ this.reconnectTimer = null;
605
+ this.connect();
606
+ }, delay);
607
+ this.reconnectTimer = timer;
608
+ if (typeof timer.unref === "function") {
609
+ timer.unref();
610
+ }
611
+ }
201
612
  }
202
613
  export {
203
614
  createRigContextFromEnv,
204
- RigBridgeClient
615
+ buildRunPiEventsWebSocketUrl,
616
+ buildRigWebSocketUrl,
617
+ RigWorkerEventsSocket,
618
+ RigBridgeSocket,
619
+ RigBridgeClient,
620
+ RIG_PROTOCOL_VERSION,
621
+ PROTOCOL_CHECK_TIMEOUT_MS,
622
+ BRIDGE_REQUEST_TIMEOUT_MS
205
623
  };
@@ -0,0 +1,10 @@
1
+ import type { RigExtensionContext, RigBridgeClient } from "./client";
2
+ export type RigCommandDefinition = {
3
+ readonly description: string;
4
+ readonly handler: (args: string, ctx: unknown) => Promise<void>;
5
+ };
6
+ export declare function createRigSlashCommands(input: {
7
+ readonly context: RigExtensionContext;
8
+ readonly client: RigBridgeClient;
9
+ readonly notify?: (message: string, level?: "info" | "error") => void;
10
+ }): Record<string, RigCommandDefinition>;