@h-rig/pi-rig 0.0.6-alpha.63 → 0.0.6-alpha.65

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.
@@ -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
  }
@@ -138,6 +139,30 @@ class RigBridgeClient {
138
139
  const payload = await this.request("/api/server/status");
139
140
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
140
141
  }
142
+ async checkProtocolCompatibility() {
143
+ let payload;
144
+ try {
145
+ payload = await this.status();
146
+ } catch (error) {
147
+ return {
148
+ status: "indeterminate",
149
+ serverProtocolVersion: null,
150
+ message: `Rig server protocol check failed: ${error instanceof Error ? error.message : String(error)}`
151
+ };
152
+ }
153
+ const raw = payload.protocolVersion;
154
+ const serverProtocolVersion = typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : null;
155
+ if (serverProtocolVersion === RIG_PROTOCOL_VERSION) {
156
+ return { status: "compatible", serverProtocolVersion, message: null };
157
+ }
158
+ const serverLabel = serverProtocolVersion === null ? "v0 (no protocolVersion reported)" : `v${serverProtocolVersion}`;
159
+ 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)";
160
+ return {
161
+ status: "mismatch",
162
+ serverProtocolVersion,
163
+ message: `Rig server speaks protocol ${serverLabel}, this pi-rig speaks v${RIG_PROTOCOL_VERSION} \u2014 ${updateHint}. The Rig bridge is disabled for this session.`
164
+ };
165
+ }
141
166
  async listTasks() {
142
167
  const payload = await this.request("/api/workspace/tasks");
143
168
  return Array.isArray(payload) ? payload.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) : [];
@@ -225,7 +250,179 @@ class RigBridgeClient {
225
250
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
226
251
  }
227
252
  }
253
+ function buildRigWebSocketUrl(serverUrl, authToken) {
254
+ const url = new URL(serverUrl);
255
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
256
+ if (authToken) {
257
+ url.searchParams.set("token", authToken);
258
+ }
259
+ return url.toString();
260
+ }
261
+ function defaultWebSocketFactory() {
262
+ const ctor = globalThis.WebSocket;
263
+ if (typeof ctor !== "function")
264
+ return null;
265
+ return (url) => new ctor(url);
266
+ }
267
+ function webSocketEventText(data) {
268
+ if (typeof data === "string")
269
+ return data;
270
+ if (data instanceof Uint8Array)
271
+ return new TextDecoder().decode(data);
272
+ return null;
273
+ }
274
+
275
+ class RigBridgeSocket {
276
+ context;
277
+ handlers;
278
+ factory;
279
+ reconnectBaseMs;
280
+ reconnectMaxMs;
281
+ socket = null;
282
+ connectedFlag = false;
283
+ closed = false;
284
+ started = false;
285
+ attempt = 0;
286
+ reconnectTimer = null;
287
+ ackSequence = 0;
288
+ constructor(input) {
289
+ this.context = input.context;
290
+ this.handlers = input.handlers ?? {};
291
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
292
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
293
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
294
+ }
295
+ get connected() {
296
+ return this.connectedFlag;
297
+ }
298
+ start() {
299
+ if (this.closed)
300
+ return false;
301
+ if (this.started)
302
+ return true;
303
+ if (!this.context.serverUrl || !this.factory)
304
+ return false;
305
+ this.started = true;
306
+ this.connect();
307
+ return true;
308
+ }
309
+ close() {
310
+ this.closed = true;
311
+ this.connectedFlag = false;
312
+ if (this.reconnectTimer) {
313
+ clearTimeout(this.reconnectTimer);
314
+ this.reconnectTimer = null;
315
+ }
316
+ try {
317
+ this.socket?.close();
318
+ } catch {}
319
+ this.socket = null;
320
+ }
321
+ ackSteering(runId, ids) {
322
+ if (!this.connectedFlag || !this.socket || ids.length === 0)
323
+ return;
324
+ try {
325
+ this.socket.send(JSON.stringify({
326
+ id: `pi-rig-steer-ack-${++this.ackSequence}`,
327
+ body: { _tag: RIG_WS_METHODS.ackRunSteering, runId, ids }
328
+ }));
329
+ } catch {}
330
+ }
331
+ connect() {
332
+ if (this.closed || !this.context.serverUrl || !this.factory)
333
+ return;
334
+ let socket;
335
+ try {
336
+ socket = this.factory(buildRigWebSocketUrl(this.context.serverUrl, this.context.authToken));
337
+ } catch {
338
+ this.scheduleReconnect();
339
+ return;
340
+ }
341
+ this.socket = socket;
342
+ let gone = false;
343
+ socket.addEventListener("open", () => {
344
+ if (this.closed || gone)
345
+ return;
346
+ this.attempt = 0;
347
+ this.connectedFlag = true;
348
+ this.handlers.onConnect?.();
349
+ });
350
+ const onGone = () => {
351
+ if (gone)
352
+ return;
353
+ gone = true;
354
+ const wasConnected = this.connectedFlag;
355
+ this.connectedFlag = false;
356
+ try {
357
+ socket.close();
358
+ } catch {}
359
+ if (this.socket === socket)
360
+ this.socket = null;
361
+ if (this.closed)
362
+ return;
363
+ if (wasConnected)
364
+ this.handlers.onDisconnect?.();
365
+ this.scheduleReconnect();
366
+ };
367
+ socket.addEventListener("close", onGone);
368
+ socket.addEventListener("error", onGone);
369
+ socket.addEventListener("message", (event) => {
370
+ if (!this.closed)
371
+ this.handleMessage(event);
372
+ });
373
+ }
374
+ scheduleReconnect() {
375
+ if (this.closed || this.reconnectTimer)
376
+ return;
377
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
378
+ this.attempt += 1;
379
+ const timer = setTimeout(() => {
380
+ this.reconnectTimer = null;
381
+ this.connect();
382
+ }, delay);
383
+ this.reconnectTimer = timer;
384
+ if (typeof timer.unref === "function") {
385
+ timer.unref();
386
+ }
387
+ }
388
+ handleMessage(event) {
389
+ const text = webSocketEventText(event.data);
390
+ if (!text)
391
+ return;
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(text);
395
+ } catch {
396
+ return;
397
+ }
398
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
399
+ return;
400
+ const record = parsed;
401
+ if (record.type !== "push" || typeof record.channel !== "string")
402
+ return;
403
+ const data = record.data && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
404
+ if (record.channel === RIG_WS_CHANNELS.runSteering) {
405
+ if (!data || !this.context.runId || data.runId !== this.context.runId)
406
+ return;
407
+ const message = data.message && typeof data.message === "object" && !Array.isArray(data.message) ? data.message : null;
408
+ if (message)
409
+ this.handlers.onSteeringMessage?.(message);
410
+ return;
411
+ }
412
+ if (record.channel === RIG_WS_CHANNELS.event) {
413
+ if (data)
414
+ this.handlers.onRigEvent?.(data);
415
+ return;
416
+ }
417
+ if (record.channel === RIG_WS_CHANNELS.snapshotInvalidated) {
418
+ this.handlers.onSnapshotInvalidated?.();
419
+ }
420
+ }
421
+ }
228
422
  export {
229
423
  createRigContextFromEnv,
230
- RigBridgeClient
424
+ buildRigWebSocketUrl,
425
+ RigBridgeSocket,
426
+ RigBridgeClient,
427
+ RIG_PROTOCOL_VERSION
231
428
  };
package/dist/src/index.js CHANGED
@@ -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
  }
@@ -138,6 +139,30 @@ class RigBridgeClient {
138
139
  const payload = await this.request("/api/server/status");
139
140
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
140
141
  }
142
+ async checkProtocolCompatibility() {
143
+ let payload;
144
+ try {
145
+ payload = await this.status();
146
+ } catch (error) {
147
+ return {
148
+ status: "indeterminate",
149
+ serverProtocolVersion: null,
150
+ message: `Rig server protocol check failed: ${error instanceof Error ? error.message : String(error)}`
151
+ };
152
+ }
153
+ const raw = payload.protocolVersion;
154
+ const serverProtocolVersion = typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : null;
155
+ if (serverProtocolVersion === RIG_PROTOCOL_VERSION) {
156
+ return { status: "compatible", serverProtocolVersion, message: null };
157
+ }
158
+ const serverLabel = serverProtocolVersion === null ? "v0 (no protocolVersion reported)" : `v${serverProtocolVersion}`;
159
+ 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)";
160
+ return {
161
+ status: "mismatch",
162
+ serverProtocolVersion,
163
+ message: `Rig server speaks protocol ${serverLabel}, this pi-rig speaks v${RIG_PROTOCOL_VERSION} \u2014 ${updateHint}. The Rig bridge is disabled for this session.`
164
+ };
165
+ }
141
166
  async listTasks() {
142
167
  const payload = await this.request("/api/workspace/tasks");
143
168
  return Array.isArray(payload) ? payload.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) : [];
@@ -225,6 +250,175 @@ class RigBridgeClient {
225
250
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
226
251
  }
227
252
  }
253
+ function buildRigWebSocketUrl(serverUrl, authToken) {
254
+ const url = new URL(serverUrl);
255
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
256
+ if (authToken) {
257
+ url.searchParams.set("token", authToken);
258
+ }
259
+ return url.toString();
260
+ }
261
+ function defaultWebSocketFactory() {
262
+ const ctor = globalThis.WebSocket;
263
+ if (typeof ctor !== "function")
264
+ return null;
265
+ return (url) => new ctor(url);
266
+ }
267
+ function webSocketEventText(data) {
268
+ if (typeof data === "string")
269
+ return data;
270
+ if (data instanceof Uint8Array)
271
+ return new TextDecoder().decode(data);
272
+ return null;
273
+ }
274
+
275
+ class RigBridgeSocket {
276
+ context;
277
+ handlers;
278
+ factory;
279
+ reconnectBaseMs;
280
+ reconnectMaxMs;
281
+ socket = null;
282
+ connectedFlag = false;
283
+ closed = false;
284
+ started = false;
285
+ attempt = 0;
286
+ reconnectTimer = null;
287
+ ackSequence = 0;
288
+ constructor(input) {
289
+ this.context = input.context;
290
+ this.handlers = input.handlers ?? {};
291
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
292
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
293
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
294
+ }
295
+ get connected() {
296
+ return this.connectedFlag;
297
+ }
298
+ start() {
299
+ if (this.closed)
300
+ return false;
301
+ if (this.started)
302
+ return true;
303
+ if (!this.context.serverUrl || !this.factory)
304
+ return false;
305
+ this.started = true;
306
+ this.connect();
307
+ return true;
308
+ }
309
+ close() {
310
+ this.closed = true;
311
+ this.connectedFlag = false;
312
+ if (this.reconnectTimer) {
313
+ clearTimeout(this.reconnectTimer);
314
+ this.reconnectTimer = null;
315
+ }
316
+ try {
317
+ this.socket?.close();
318
+ } catch {}
319
+ this.socket = null;
320
+ }
321
+ ackSteering(runId, ids) {
322
+ if (!this.connectedFlag || !this.socket || ids.length === 0)
323
+ return;
324
+ try {
325
+ this.socket.send(JSON.stringify({
326
+ id: `pi-rig-steer-ack-${++this.ackSequence}`,
327
+ body: { _tag: RIG_WS_METHODS.ackRunSteering, runId, ids }
328
+ }));
329
+ } catch {}
330
+ }
331
+ connect() {
332
+ if (this.closed || !this.context.serverUrl || !this.factory)
333
+ return;
334
+ let socket;
335
+ try {
336
+ socket = this.factory(buildRigWebSocketUrl(this.context.serverUrl, this.context.authToken));
337
+ } catch {
338
+ this.scheduleReconnect();
339
+ return;
340
+ }
341
+ this.socket = socket;
342
+ let gone = false;
343
+ socket.addEventListener("open", () => {
344
+ if (this.closed || gone)
345
+ return;
346
+ this.attempt = 0;
347
+ this.connectedFlag = true;
348
+ this.handlers.onConnect?.();
349
+ });
350
+ const onGone = () => {
351
+ if (gone)
352
+ return;
353
+ gone = true;
354
+ const wasConnected = this.connectedFlag;
355
+ this.connectedFlag = false;
356
+ try {
357
+ socket.close();
358
+ } catch {}
359
+ if (this.socket === socket)
360
+ this.socket = null;
361
+ if (this.closed)
362
+ return;
363
+ if (wasConnected)
364
+ this.handlers.onDisconnect?.();
365
+ this.scheduleReconnect();
366
+ };
367
+ socket.addEventListener("close", onGone);
368
+ socket.addEventListener("error", onGone);
369
+ socket.addEventListener("message", (event) => {
370
+ if (!this.closed)
371
+ this.handleMessage(event);
372
+ });
373
+ }
374
+ scheduleReconnect() {
375
+ if (this.closed || this.reconnectTimer)
376
+ return;
377
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
378
+ this.attempt += 1;
379
+ const timer = setTimeout(() => {
380
+ this.reconnectTimer = null;
381
+ this.connect();
382
+ }, delay);
383
+ this.reconnectTimer = timer;
384
+ if (typeof timer.unref === "function") {
385
+ timer.unref();
386
+ }
387
+ }
388
+ handleMessage(event) {
389
+ const text = webSocketEventText(event.data);
390
+ if (!text)
391
+ return;
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(text);
395
+ } catch {
396
+ return;
397
+ }
398
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
399
+ return;
400
+ const record = parsed;
401
+ if (record.type !== "push" || typeof record.channel !== "string")
402
+ return;
403
+ const data = record.data && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
404
+ if (record.channel === RIG_WS_CHANNELS.runSteering) {
405
+ if (!data || !this.context.runId || data.runId !== this.context.runId)
406
+ return;
407
+ const message = data.message && typeof data.message === "object" && !Array.isArray(data.message) ? data.message : null;
408
+ if (message)
409
+ this.handlers.onSteeringMessage?.(message);
410
+ return;
411
+ }
412
+ if (record.channel === RIG_WS_CHANNELS.event) {
413
+ if (data)
414
+ this.handlers.onRigEvent?.(data);
415
+ return;
416
+ }
417
+ if (record.channel === RIG_WS_CHANNELS.snapshotInvalidated) {
418
+ this.handlers.onSnapshotInvalidated?.();
419
+ }
420
+ }
421
+ }
228
422
 
229
423
  // packages/pi-rig/src/commands.ts
230
424
  function runRecordFromPayload(payload) {
@@ -364,7 +558,8 @@ function createPiRigExtensionState(input = {}) {
364
558
  const context = createRigContextFromEnv(input.env ?? process.env);
365
559
  return {
366
560
  ...context,
367
- client: new RigBridgeClient({ context, fetchImpl: input.fetchImpl })
561
+ client: new RigBridgeClient({ context, fetchImpl: input.fetchImpl }),
562
+ ...input.webSocketFactory ? { webSocketFactory: input.webSocketFactory } : {}
368
563
  };
369
564
  }
370
565
  function notify(ctx, message, level = "info") {
@@ -374,6 +569,10 @@ function notify(ctx, message, level = "info") {
374
569
  notifyFn.call(ui, message, level);
375
570
  }
376
571
  }
572
+ function canNotify(ctx) {
573
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
574
+ return Boolean(ui && typeof ui === "object" && typeof ui.notify === "function");
575
+ }
377
576
  function setWidget(ctx, id, lines) {
378
577
  const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
379
578
  const setWidgetFn = ui && typeof ui === "object" ? ui.setWidget : null;
@@ -403,6 +602,25 @@ function setFooter(ctx, line) {
403
602
  invalidate() {}
404
603
  }));
405
604
  }
605
+ function createBridgeGate(state) {
606
+ let pending = null;
607
+ let warned = false;
608
+ return async (ctx) => {
609
+ if (!state.active || !state.serverUrl)
610
+ return { allowed: true, message: null, status: "indeterminate" };
611
+ pending ??= state.client.checkProtocolCompatibility();
612
+ const check = await pending;
613
+ if (check.status !== "mismatch")
614
+ return { allowed: true, message: null, status: check.status };
615
+ const message = check.message ?? "Rig protocol mismatch \u2014 the Rig bridge is disabled.";
616
+ if (!warned && canNotify(ctx)) {
617
+ warned = true;
618
+ notify(ctx, message, "error");
619
+ setStatus(ctx, "rig", "Rig bridge disabled (protocol mismatch)");
620
+ }
621
+ return { allowed: false, message, status: "mismatch" };
622
+ };
623
+ }
406
624
  function steeringText(message) {
407
625
  const text = typeof message.message === "string" ? message.message.trim() : "";
408
626
  if (!text)
@@ -411,19 +629,48 @@ function steeringText(message) {
411
629
  return `[Rig steering from ${actor}]
412
630
  ${text}`;
413
631
  }
414
- async function consumeQueuedSteering(pi, state, ctx) {
632
+ function unrefTimer(timer) {
633
+ if (typeof timer.unref === "function") {
634
+ timer.unref();
635
+ }
636
+ }
637
+ var STEERING_DEDUPE_LIMIT = 500;
638
+ function rememberDeliveredSteeringId(deliveredIds, id) {
639
+ deliveredIds.add(id);
640
+ if (deliveredIds.size > STEERING_DEDUPE_LIMIT) {
641
+ const oldest = deliveredIds.values().next().value;
642
+ if (typeof oldest === "string")
643
+ deliveredIds.delete(oldest);
644
+ }
645
+ }
646
+ async function deliverSteeringMessage(pi, deliveredIds, message) {
647
+ if (typeof pi.sendUserMessage !== "function")
648
+ return false;
649
+ const id = typeof message.id === "string" && message.id.trim() ? message.id : null;
650
+ if (id && deliveredIds.has(id))
651
+ return false;
652
+ const text = steeringText(message);
653
+ if (!text)
654
+ return false;
655
+ if (id)
656
+ rememberDeliveredSteeringId(deliveredIds, id);
657
+ await pi.sendUserMessage(text, { deliverAs: "steer", triggerTurn: true });
658
+ return true;
659
+ }
660
+ async function consumeQueuedSteering(pi, state, ctx, gate, deliveredIds) {
415
661
  if (state.operatorSession || !state.active || !state.runId || typeof pi.sendUserMessage !== "function")
416
662
  return;
663
+ if (!(await gate(ctx)).allowed)
664
+ return;
417
665
  try {
418
666
  const messages = await state.client.consumeSteering(state.runId);
667
+ let deliveredCount = 0;
419
668
  for (const message of messages) {
420
- const text = steeringText(message);
421
- if (!text)
422
- continue;
423
- await pi.sendUserMessage(text, { deliverAs: "steer", triggerTurn: true });
669
+ if (await deliverSteeringMessage(pi, deliveredIds, message))
670
+ deliveredCount += 1;
424
671
  }
425
- if (messages.length > 0) {
426
- notify(ctx, `Delivered ${messages.length} Rig steering message${messages.length === 1 ? "" : "s"}.`);
672
+ if (deliveredCount > 0) {
673
+ notify(ctx, `Delivered ${deliveredCount} Rig steering message${deliveredCount === 1 ? "" : "s"}.`);
427
674
  }
428
675
  } catch (error) {
429
676
  notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -435,12 +682,14 @@ function inputText(event) {
435
682
  const text = event.text;
436
683
  return typeof text === "string" && text.trim() ? text.trim() : null;
437
684
  }
438
- async function handleOperatorInput(event, state, ctx) {
685
+ async function handleOperatorInput(event, state, ctx, gate) {
439
686
  if (!state.operatorSession || !state.active || !state.runId)
440
687
  return;
441
688
  const text = inputText(event);
442
689
  if (!text || text.startsWith("/"))
443
690
  return;
691
+ if (!(await gate(ctx)).allowed)
692
+ return;
444
693
  try {
445
694
  await state.client.steer(text, state.runId);
446
695
  notify(ctx, "Rig steering message queued.");
@@ -463,15 +712,18 @@ function runLocation(run) {
463
712
  function runPayload(payload) {
464
713
  return payload.run && typeof payload.run === "object" && !Array.isArray(payload.run) ? payload.run : payload;
465
714
  }
466
- function startOperatorRunWidget(state, ctx) {
715
+ var OPERATOR_WIDGET_WS_FALLBACK_MS = 1e4;
716
+ function startOperatorRunWidget(state, ctx, live) {
467
717
  if (!state.operatorSession || !state.active || !state.runId)
468
718
  return;
469
719
  let inFlight = false;
470
720
  let frame = 0;
721
+ let lastRefreshAt = 0;
471
722
  const refresh = async () => {
472
723
  if (inFlight)
473
724
  return;
474
725
  inFlight = true;
726
+ lastRefreshAt = Date.now();
475
727
  const spinner = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "\u2022";
476
728
  try {
477
729
  const [runPayloadRecord, timeline] = await Promise.all([
@@ -502,32 +754,127 @@ function startOperatorRunWidget(state, ctx) {
502
754
  }
503
755
  };
504
756
  refresh();
505
- const timer = setInterval(() => void refresh(), 1000);
506
- if (typeof timer.unref === "function") {
507
- timer.unref();
508
- }
757
+ const timer = setInterval(() => {
758
+ const triggered = live?.consumePushTrigger() ?? false;
759
+ if ((live?.isConnected() ?? false) && !triggered && Date.now() - lastRefreshAt < OPERATOR_WIDGET_WS_FALLBACK_MS) {
760
+ return;
761
+ }
762
+ refresh();
763
+ }, 1000);
764
+ unrefTimer(timer);
765
+ }
766
+ function operatorInboxNotification(event) {
767
+ const type = typeof event.type === "string" ? event.type : null;
768
+ if (type !== "rig.approval.requested" && type !== "rig.user-input.requested")
769
+ return null;
770
+ const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload) ? event.payload : {};
771
+ const runId = typeof payload.runId === "string" && payload.runId.trim() ? payload.runId : typeof event.aggregateId === "string" && event.aggregateId.trim() ? event.aggregateId : "unknown";
772
+ const requestId = typeof payload.requestId === "string" && payload.requestId.trim() ? payload.requestId : `${type}:${runId}`;
773
+ const waitingOn = type === "rig.approval.requested" ? "an approval" : "user input";
774
+ return {
775
+ key: requestId,
776
+ text: `Run ${runId} is waiting on ${waitingOn} \u2014 resolve with /rig inbox or \`rig inbox\`.`
777
+ };
509
778
  }
510
- function startLiveSteeringPoll(pi, state, ctx) {
779
+ function startOperatorBridge(state, ctx) {
780
+ if (!state.operatorSession || !state.active)
781
+ return;
782
+ const notifiedRequests = new Set;
783
+ let pushTrigger = false;
784
+ const socket = new RigBridgeSocket({
785
+ context: state,
786
+ webSocketFactory: state.webSocketFactory,
787
+ handlers: {
788
+ onRigEvent: (event) => {
789
+ pushTrigger = true;
790
+ const notification = operatorInboxNotification(event);
791
+ if (!notification || notifiedRequests.has(notification.key))
792
+ return;
793
+ notifiedRequests.add(notification.key);
794
+ if (notifiedRequests.size > 1000) {
795
+ const oldest = notifiedRequests.values().next().value;
796
+ if (typeof oldest === "string")
797
+ notifiedRequests.delete(oldest);
798
+ }
799
+ notify(ctx, notification.text);
800
+ },
801
+ onSnapshotInvalidated: () => {
802
+ pushTrigger = true;
803
+ },
804
+ onDisconnect: () => {
805
+ pushTrigger = true;
806
+ }
807
+ }
808
+ });
809
+ if (!socket.start())
810
+ return;
811
+ return {
812
+ isConnected: () => socket.connected,
813
+ consumePushTrigger: () => {
814
+ const triggered = pushTrigger;
815
+ pushTrigger = false;
816
+ return triggered;
817
+ }
818
+ };
819
+ }
820
+ function startSteeringBridge(pi, state, ctx, gate, deliveredIds) {
511
821
  if (state.operatorSession || !state.active || !state.runId || typeof pi.sendUserMessage !== "function")
512
822
  return;
823
+ const runId = state.runId;
824
+ const socket = new RigBridgeSocket({
825
+ context: state,
826
+ webSocketFactory: state.webSocketFactory,
827
+ handlers: {
828
+ onSteeringMessage: (message) => {
829
+ (async () => {
830
+ try {
831
+ if (!await deliverSteeringMessage(pi, deliveredIds, message))
832
+ return;
833
+ const id = typeof message.id === "string" && message.id.trim() ? message.id : null;
834
+ if (id)
835
+ socket.ackSteering(runId, [id]);
836
+ notify(ctx, "Delivered 1 Rig steering message.");
837
+ } catch (error) {
838
+ notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
839
+ }
840
+ })();
841
+ },
842
+ onConnect: () => {
843
+ consumeQueuedSteering(pi, state, ctx, gate, deliveredIds);
844
+ }
845
+ }
846
+ });
847
+ (async () => {
848
+ const gateResult = await gate(ctx);
849
+ if (!gateResult.allowed)
850
+ return;
851
+ if (gateResult.status === "compatible") {
852
+ socket.start();
853
+ }
854
+ })();
513
855
  const intervalMs = state.steeringPollMs ?? 1000;
514
856
  if (intervalMs <= 0)
515
857
  return;
858
+ const WS_CONNECTED_SWEEP_MS = 1e4;
516
859
  let inFlight = false;
860
+ let lastSweepAt = 0;
517
861
  const timer = setInterval(() => {
518
862
  if (inFlight)
519
863
  return;
864
+ if (socket.connected && Date.now() - lastSweepAt < WS_CONNECTED_SWEEP_MS)
865
+ return;
520
866
  inFlight = true;
521
- consumeQueuedSteering(pi, state, ctx).finally(() => {
867
+ lastSweepAt = Date.now();
868
+ consumeQueuedSteering(pi, state, ctx, gate, deliveredIds).finally(() => {
522
869
  inFlight = false;
523
870
  });
524
871
  }, intervalMs);
525
- if (typeof timer.unref === "function") {
526
- timer.unref();
527
- }
872
+ unrefTimer(timer);
528
873
  }
529
874
  function createPiRigExtension(pi, options = {}) {
530
875
  const state = options.state ?? createPiRigExtensionState();
876
+ const gate = createBridgeGate(state);
877
+ const deliveredSteeringIds = new Set;
531
878
  const commands = createRigSlashCommands({
532
879
  context: state,
533
880
  client: state.client,
@@ -537,6 +884,11 @@ function createPiRigExtension(pi, options = {}) {
537
884
  pi.registerCommand?.(name, {
538
885
  description: command.description,
539
886
  handler: async (args, ctx) => {
887
+ const gateResult = await gate(ctx);
888
+ if (!gateResult.allowed) {
889
+ notify(ctx, gateResult.message ?? "Rig bridge disabled (protocol mismatch).", "error");
890
+ return;
891
+ }
540
892
  const nextCommands = createRigSlashCommands({
541
893
  context: state,
542
894
  client: state.client,
@@ -548,20 +900,33 @@ function createPiRigExtension(pi, options = {}) {
548
900
  }
549
901
  if (state.active && state.runId) {
550
902
  for (const tool of createRigTools({ context: state, client: state.client })) {
551
- pi.registerTool?.(tool);
903
+ pi.registerTool?.({
904
+ ...tool,
905
+ execute: async (toolCallId, params) => {
906
+ const gateResult = await gate(globalThis);
907
+ if (!gateResult.allowed) {
908
+ return { content: [{ type: "text", text: gateResult.message ?? "Rig bridge disabled (protocol mismatch)." }], isError: true };
909
+ }
910
+ return tool.execute(toolCallId, params);
911
+ }
912
+ });
552
913
  }
553
- startLiveSteeringPoll(pi, state, globalThis);
914
+ startSteeringBridge(pi, state, globalThis, gate, deliveredSteeringIds);
554
915
  }
555
- pi.on?.("input", async (event, ctx) => handleOperatorInput(event, state, ctx));
916
+ pi.on?.("input", async (event, ctx) => handleOperatorInput(event, state, ctx, gate));
556
917
  pi.on?.("session_start", async (_event, ctx) => {
557
918
  if (!state.active || !state.runId)
558
919
  return;
920
+ const gateResult = await gate(ctx);
921
+ if (!gateResult.allowed)
922
+ return;
559
923
  setStatus(ctx, "rig", `Rig ${state.runId}`);
560
- startOperatorRunWidget(state, ctx);
561
- await consumeQueuedSteering(pi, state, ctx);
924
+ const live = gateResult.status === "compatible" ? startOperatorBridge(state, ctx) : undefined;
925
+ startOperatorRunWidget(state, ctx, live);
926
+ await consumeQueuedSteering(pi, state, ctx, gate, deliveredSteeringIds);
562
927
  });
563
928
  pi.on?.("turn_end", async (_event, ctx) => {
564
- await consumeQueuedSteering(pi, state, ctx);
929
+ await consumeQueuedSteering(pi, state, ctx, gate, deliveredSteeringIds);
565
930
  });
566
931
  }
567
932
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@h-rig/pi-rig",
3
- "version": "0.0.6-alpha.63",
3
+ "version": "0.0.6-alpha.65",
4
4
  "type": "module",
5
5
  "description": "Rig package",
6
6
  "license": "UNLICENSED",
@@ -32,8 +32,11 @@
32
32
  "./dist/src/index.js"
33
33
  ]
34
34
  },
35
+ "dependencies": {
36
+ "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.65"
37
+ },
35
38
  "peerDependencies": {
36
- "@earendil-works/pi-coding-agent": "npm:@h-rig/pi-coding-agent@0.0.6-alpha.63",
39
+ "@earendil-works/pi-coding-agent": ">=0.79.0",
37
40
  "typebox": "*"
38
41
  }
39
42
  }