@h-rig/pi-rig 0.0.6-alpha.7 → 0.0.6-alpha.70

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
  }
@@ -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 [];
@@ -199,7 +256,181 @@ class RigBridgeClient {
199
256
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
200
257
  }
201
258
  }
259
+ function buildRigWebSocketUrl(serverUrl, authToken) {
260
+ const url = new URL(serverUrl);
261
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
262
+ if (authToken) {
263
+ url.searchParams.set("token", authToken);
264
+ }
265
+ return url.toString();
266
+ }
267
+ function defaultWebSocketFactory() {
268
+ const ctor = globalThis.WebSocket;
269
+ if (typeof ctor !== "function")
270
+ return null;
271
+ return (url) => new ctor(url);
272
+ }
273
+ function webSocketEventText(data) {
274
+ if (typeof data === "string")
275
+ return data;
276
+ if (data instanceof Uint8Array)
277
+ return new TextDecoder().decode(data);
278
+ return null;
279
+ }
280
+
281
+ class RigBridgeSocket {
282
+ context;
283
+ handlers;
284
+ factory;
285
+ reconnectBaseMs;
286
+ reconnectMaxMs;
287
+ socket = null;
288
+ connectedFlag = false;
289
+ closed = false;
290
+ started = false;
291
+ attempt = 0;
292
+ reconnectTimer = null;
293
+ ackSequence = 0;
294
+ constructor(input) {
295
+ this.context = input.context;
296
+ this.handlers = input.handlers ?? {};
297
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
298
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
299
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
300
+ }
301
+ get connected() {
302
+ return this.connectedFlag;
303
+ }
304
+ start() {
305
+ if (this.closed)
306
+ return false;
307
+ if (this.started)
308
+ return true;
309
+ if (!this.context.serverUrl || !this.factory)
310
+ return false;
311
+ this.started = true;
312
+ this.connect();
313
+ return true;
314
+ }
315
+ close() {
316
+ this.closed = true;
317
+ this.connectedFlag = false;
318
+ if (this.reconnectTimer) {
319
+ clearTimeout(this.reconnectTimer);
320
+ this.reconnectTimer = null;
321
+ }
322
+ try {
323
+ this.socket?.close();
324
+ } catch {}
325
+ this.socket = null;
326
+ }
327
+ ackSteering(runId, ids) {
328
+ if (!this.connectedFlag || !this.socket || ids.length === 0)
329
+ return;
330
+ try {
331
+ this.socket.send(JSON.stringify({
332
+ id: `pi-rig-steer-ack-${++this.ackSequence}`,
333
+ body: { _tag: RIG_WS_METHODS.ackRunSteering, runId, ids }
334
+ }));
335
+ } catch {}
336
+ }
337
+ connect() {
338
+ if (this.closed || !this.context.serverUrl || !this.factory)
339
+ return;
340
+ let socket;
341
+ try {
342
+ socket = this.factory(buildRigWebSocketUrl(this.context.serverUrl, this.context.authToken));
343
+ } catch {
344
+ this.scheduleReconnect();
345
+ return;
346
+ }
347
+ this.socket = socket;
348
+ let gone = false;
349
+ socket.addEventListener("open", () => {
350
+ if (this.closed || gone)
351
+ return;
352
+ this.attempt = 0;
353
+ this.connectedFlag = true;
354
+ this.handlers.onConnect?.();
355
+ });
356
+ const onGone = () => {
357
+ if (gone)
358
+ return;
359
+ gone = true;
360
+ const wasConnected = this.connectedFlag;
361
+ this.connectedFlag = false;
362
+ try {
363
+ socket.close();
364
+ } catch {}
365
+ if (this.socket === socket)
366
+ this.socket = null;
367
+ if (this.closed)
368
+ return;
369
+ if (wasConnected)
370
+ this.handlers.onDisconnect?.();
371
+ this.scheduleReconnect();
372
+ };
373
+ socket.addEventListener("close", onGone);
374
+ socket.addEventListener("error", onGone);
375
+ socket.addEventListener("message", (event) => {
376
+ if (!this.closed)
377
+ this.handleMessage(event);
378
+ });
379
+ }
380
+ scheduleReconnect() {
381
+ if (this.closed || this.reconnectTimer)
382
+ return;
383
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
384
+ this.attempt += 1;
385
+ const timer = setTimeout(() => {
386
+ this.reconnectTimer = null;
387
+ this.connect();
388
+ }, delay);
389
+ this.reconnectTimer = timer;
390
+ if (typeof timer.unref === "function") {
391
+ timer.unref();
392
+ }
393
+ }
394
+ handleMessage(event) {
395
+ const text = webSocketEventText(event.data);
396
+ if (!text)
397
+ return;
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(text);
401
+ } catch {
402
+ return;
403
+ }
404
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
405
+ return;
406
+ const record = parsed;
407
+ if (record.type !== "push" || typeof record.channel !== "string")
408
+ return;
409
+ const data = record.data && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
410
+ if (record.channel === RIG_WS_CHANNELS.runSteering) {
411
+ if (!data || !this.context.runId || data.runId !== this.context.runId)
412
+ return;
413
+ const message = data.message && typeof data.message === "object" && !Array.isArray(data.message) ? data.message : null;
414
+ if (message)
415
+ this.handlers.onSteeringMessage?.(message);
416
+ return;
417
+ }
418
+ if (record.channel === RIG_WS_CHANNELS.event) {
419
+ if (data)
420
+ this.handlers.onRigEvent?.(data);
421
+ return;
422
+ }
423
+ if (record.channel === RIG_WS_CHANNELS.snapshotInvalidated) {
424
+ this.handlers.onSnapshotInvalidated?.();
425
+ }
426
+ }
427
+ }
202
428
  export {
203
429
  createRigContextFromEnv,
204
- RigBridgeClient
430
+ buildRigWebSocketUrl,
431
+ RigBridgeSocket,
432
+ RigBridgeClient,
433
+ RIG_PROTOCOL_VERSION,
434
+ PROTOCOL_CHECK_TIMEOUT_MS,
435
+ BRIDGE_REQUEST_TIMEOUT_MS
205
436
  };
@@ -1,5 +1,13 @@
1
1
  // @bun
2
2
  // packages/pi-rig/src/commands.ts
3
+ function runRecordFromPayload(payload) {
4
+ return payload.run && typeof payload.run === "object" && !Array.isArray(payload.run) ? payload.run : payload;
5
+ }
6
+ function formatEntry(entry) {
7
+ const type = String(entry.type ?? entry.title ?? "event");
8
+ const text = typeof entry.text === "string" ? entry.text : typeof entry.detail === "string" ? entry.detail : typeof entry.message === "string" ? entry.message : JSON.stringify(entry);
9
+ return `${type}: ${text}`;
10
+ }
3
11
  function createRigSlashCommands(input) {
4
12
  const notify = input.notify ?? (() => {});
5
13
  async function handleRig(args) {
@@ -28,18 +36,40 @@ function createRigSlashCommands(input) {
28
36
  }
29
37
  if (first === "attach") {
30
38
  const run = await input.client.attach(second);
31
- const runRecord = run.run && typeof run.run === "object" ? run.run : run;
39
+ const runRecord = runRecordFromPayload(run);
32
40
  notify(`Attached to ${String(runRecord.runId ?? second ?? input.context.runId ?? "run")}: ${String(runRecord.status ?? "unknown")}`, "info");
33
41
  return;
34
42
  }
35
- notify("Usage: /rig status | /rig task list | /rig task run [id] | /rig attach [run-id]", "error");
43
+ if (first === "timeline" || first === "logs") {
44
+ const runId = second || input.context.runId;
45
+ const entries = first === "timeline" ? await input.client.runTimeline(runId, 20) : await input.client.runLogs(runId, 20);
46
+ notify(entries.length > 0 ? entries.slice(-10).map(formatEntry).join(`
47
+ `) : `No ${first} entries for ${runId ?? "run"}.`, "info");
48
+ return;
49
+ }
50
+ if (first === "steer") {
51
+ const message = args.trim().slice("steer".length).trim();
52
+ if (!message) {
53
+ notify("Usage: /rig steer <message>", "error");
54
+ return;
55
+ }
56
+ await input.client.steer(message);
57
+ notify("Rig steering message queued.", "info");
58
+ return;
59
+ }
60
+ if (first === "stop") {
61
+ await input.client.stop(second || input.context.runId);
62
+ notify("Rig stop requested.", "info");
63
+ return;
64
+ }
65
+ notify("Usage: /rig status | /rig task list | /rig task run [id] | /rig attach [run-id] | /rig timeline [run-id] | /rig logs [run-id] | /rig steer <message> | /rig stop [run-id]", "error");
36
66
  } catch (error) {
37
67
  notify(error instanceof Error ? error.message : String(error), "error");
38
68
  }
39
69
  }
40
70
  return {
41
71
  rig: {
42
- description: "Rig control-plane commands: status, task list, task run, attach",
72
+ description: "Rig control-plane commands: status, task list, task run, attach, timeline, logs, steer, stop",
43
73
  handler: handleRig
44
74
  }
45
75
  };
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
  }
@@ -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 [];
@@ -199,8 +256,185 @@ class RigBridgeClient {
199
256
  return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
200
257
  }
201
258
  }
259
+ function buildRigWebSocketUrl(serverUrl, authToken) {
260
+ const url = new URL(serverUrl);
261
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
262
+ if (authToken) {
263
+ url.searchParams.set("token", authToken);
264
+ }
265
+ return url.toString();
266
+ }
267
+ function defaultWebSocketFactory() {
268
+ const ctor = globalThis.WebSocket;
269
+ if (typeof ctor !== "function")
270
+ return null;
271
+ return (url) => new ctor(url);
272
+ }
273
+ function webSocketEventText(data) {
274
+ if (typeof data === "string")
275
+ return data;
276
+ if (data instanceof Uint8Array)
277
+ return new TextDecoder().decode(data);
278
+ return null;
279
+ }
280
+
281
+ class RigBridgeSocket {
282
+ context;
283
+ handlers;
284
+ factory;
285
+ reconnectBaseMs;
286
+ reconnectMaxMs;
287
+ socket = null;
288
+ connectedFlag = false;
289
+ closed = false;
290
+ started = false;
291
+ attempt = 0;
292
+ reconnectTimer = null;
293
+ ackSequence = 0;
294
+ constructor(input) {
295
+ this.context = input.context;
296
+ this.handlers = input.handlers ?? {};
297
+ this.factory = input.webSocketFactory ?? defaultWebSocketFactory();
298
+ this.reconnectBaseMs = input.reconnectBaseMs ?? 1000;
299
+ this.reconnectMaxMs = input.reconnectMaxMs ?? 30000;
300
+ }
301
+ get connected() {
302
+ return this.connectedFlag;
303
+ }
304
+ start() {
305
+ if (this.closed)
306
+ return false;
307
+ if (this.started)
308
+ return true;
309
+ if (!this.context.serverUrl || !this.factory)
310
+ return false;
311
+ this.started = true;
312
+ this.connect();
313
+ return true;
314
+ }
315
+ close() {
316
+ this.closed = true;
317
+ this.connectedFlag = false;
318
+ if (this.reconnectTimer) {
319
+ clearTimeout(this.reconnectTimer);
320
+ this.reconnectTimer = null;
321
+ }
322
+ try {
323
+ this.socket?.close();
324
+ } catch {}
325
+ this.socket = null;
326
+ }
327
+ ackSteering(runId, ids) {
328
+ if (!this.connectedFlag || !this.socket || ids.length === 0)
329
+ return;
330
+ try {
331
+ this.socket.send(JSON.stringify({
332
+ id: `pi-rig-steer-ack-${++this.ackSequence}`,
333
+ body: { _tag: RIG_WS_METHODS.ackRunSteering, runId, ids }
334
+ }));
335
+ } catch {}
336
+ }
337
+ connect() {
338
+ if (this.closed || !this.context.serverUrl || !this.factory)
339
+ return;
340
+ let socket;
341
+ try {
342
+ socket = this.factory(buildRigWebSocketUrl(this.context.serverUrl, this.context.authToken));
343
+ } catch {
344
+ this.scheduleReconnect();
345
+ return;
346
+ }
347
+ this.socket = socket;
348
+ let gone = false;
349
+ socket.addEventListener("open", () => {
350
+ if (this.closed || gone)
351
+ return;
352
+ this.attempt = 0;
353
+ this.connectedFlag = true;
354
+ this.handlers.onConnect?.();
355
+ });
356
+ const onGone = () => {
357
+ if (gone)
358
+ return;
359
+ gone = true;
360
+ const wasConnected = this.connectedFlag;
361
+ this.connectedFlag = false;
362
+ try {
363
+ socket.close();
364
+ } catch {}
365
+ if (this.socket === socket)
366
+ this.socket = null;
367
+ if (this.closed)
368
+ return;
369
+ if (wasConnected)
370
+ this.handlers.onDisconnect?.();
371
+ this.scheduleReconnect();
372
+ };
373
+ socket.addEventListener("close", onGone);
374
+ socket.addEventListener("error", onGone);
375
+ socket.addEventListener("message", (event) => {
376
+ if (!this.closed)
377
+ this.handleMessage(event);
378
+ });
379
+ }
380
+ scheduleReconnect() {
381
+ if (this.closed || this.reconnectTimer)
382
+ return;
383
+ const delay = Math.min(this.reconnectBaseMs * 2 ** this.attempt, this.reconnectMaxMs);
384
+ this.attempt += 1;
385
+ const timer = setTimeout(() => {
386
+ this.reconnectTimer = null;
387
+ this.connect();
388
+ }, delay);
389
+ this.reconnectTimer = timer;
390
+ if (typeof timer.unref === "function") {
391
+ timer.unref();
392
+ }
393
+ }
394
+ handleMessage(event) {
395
+ const text = webSocketEventText(event.data);
396
+ if (!text)
397
+ return;
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(text);
401
+ } catch {
402
+ return;
403
+ }
404
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
405
+ return;
406
+ const record = parsed;
407
+ if (record.type !== "push" || typeof record.channel !== "string")
408
+ return;
409
+ const data = record.data && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
410
+ if (record.channel === RIG_WS_CHANNELS.runSteering) {
411
+ if (!data || !this.context.runId || data.runId !== this.context.runId)
412
+ return;
413
+ const message = data.message && typeof data.message === "object" && !Array.isArray(data.message) ? data.message : null;
414
+ if (message)
415
+ this.handlers.onSteeringMessage?.(message);
416
+ return;
417
+ }
418
+ if (record.channel === RIG_WS_CHANNELS.event) {
419
+ if (data)
420
+ this.handlers.onRigEvent?.(data);
421
+ return;
422
+ }
423
+ if (record.channel === RIG_WS_CHANNELS.snapshotInvalidated) {
424
+ this.handlers.onSnapshotInvalidated?.();
425
+ }
426
+ }
427
+ }
202
428
 
203
429
  // packages/pi-rig/src/commands.ts
430
+ function runRecordFromPayload(payload) {
431
+ return payload.run && typeof payload.run === "object" && !Array.isArray(payload.run) ? payload.run : payload;
432
+ }
433
+ function formatEntry(entry) {
434
+ const type = String(entry.type ?? entry.title ?? "event");
435
+ const text = typeof entry.text === "string" ? entry.text : typeof entry.detail === "string" ? entry.detail : typeof entry.message === "string" ? entry.message : JSON.stringify(entry);
436
+ return `${type}: ${text}`;
437
+ }
204
438
  function createRigSlashCommands(input) {
205
439
  const notify = input.notify ?? (() => {});
206
440
  async function handleRig(args) {
@@ -229,18 +463,40 @@ function createRigSlashCommands(input) {
229
463
  }
230
464
  if (first === "attach") {
231
465
  const run = await input.client.attach(second);
232
- const runRecord = run.run && typeof run.run === "object" ? run.run : run;
466
+ const runRecord = runRecordFromPayload(run);
233
467
  notify(`Attached to ${String(runRecord.runId ?? second ?? input.context.runId ?? "run")}: ${String(runRecord.status ?? "unknown")}`, "info");
234
468
  return;
235
469
  }
236
- notify("Usage: /rig status | /rig task list | /rig task run [id] | /rig attach [run-id]", "error");
470
+ if (first === "timeline" || first === "logs") {
471
+ const runId = second || input.context.runId;
472
+ const entries = first === "timeline" ? await input.client.runTimeline(runId, 20) : await input.client.runLogs(runId, 20);
473
+ notify(entries.length > 0 ? entries.slice(-10).map(formatEntry).join(`
474
+ `) : `No ${first} entries for ${runId ?? "run"}.`, "info");
475
+ return;
476
+ }
477
+ if (first === "steer") {
478
+ const message = args.trim().slice("steer".length).trim();
479
+ if (!message) {
480
+ notify("Usage: /rig steer <message>", "error");
481
+ return;
482
+ }
483
+ await input.client.steer(message);
484
+ notify("Rig steering message queued.", "info");
485
+ return;
486
+ }
487
+ if (first === "stop") {
488
+ await input.client.stop(second || input.context.runId);
489
+ notify("Rig stop requested.", "info");
490
+ return;
491
+ }
492
+ notify("Usage: /rig status | /rig task list | /rig task run [id] | /rig attach [run-id] | /rig timeline [run-id] | /rig logs [run-id] | /rig steer <message> | /rig stop [run-id]", "error");
237
493
  } catch (error) {
238
494
  notify(error instanceof Error ? error.message : String(error), "error");
239
495
  }
240
496
  }
241
497
  return {
242
498
  rig: {
243
- description: "Rig control-plane commands: status, task list, task run, attach",
499
+ description: "Rig control-plane commands: status, task list, task run, attach, timeline, logs, steer, stop",
244
500
  handler: handleRig
245
501
  }
246
502
  };
@@ -308,7 +564,8 @@ function createPiRigExtensionState(input = {}) {
308
564
  const context = createRigContextFromEnv(input.env ?? process.env);
309
565
  return {
310
566
  ...context,
311
- client: new RigBridgeClient({ context, fetchImpl: input.fetchImpl })
567
+ client: new RigBridgeClient({ context, fetchImpl: input.fetchImpl }),
568
+ ...input.webSocketFactory ? { webSocketFactory: input.webSocketFactory } : {}
312
569
  };
313
570
  }
314
571
  function notify(ctx, message, level = "info") {
@@ -318,6 +575,62 @@ function notify(ctx, message, level = "info") {
318
575
  notifyFn.call(ui, message, level);
319
576
  }
320
577
  }
578
+ function canNotify(ctx) {
579
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
580
+ return Boolean(ui && typeof ui === "object" && typeof ui.notify === "function");
581
+ }
582
+ function setWidget(ctx, id, lines) {
583
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
584
+ const setWidgetFn = ui && typeof ui === "object" ? ui.setWidget : null;
585
+ if (typeof setWidgetFn === "function") {
586
+ setWidgetFn.call(ui, id, lines);
587
+ }
588
+ }
589
+ function setStatus(ctx, id, text) {
590
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
591
+ const setStatusFn = ui && typeof ui === "object" ? ui.setStatus : null;
592
+ if (typeof setStatusFn === "function") {
593
+ setStatusFn.call(ui, id, text);
594
+ }
595
+ }
596
+ function setFooter(ctx, line) {
597
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
598
+ const setFooterFn = ui && typeof ui === "object" ? ui.setFooter : null;
599
+ if (typeof setFooterFn !== "function")
600
+ return;
601
+ setFooterFn.call(ui, () => ({
602
+ render(width) {
603
+ const max = Math.max(0, Math.trunc(width));
604
+ if (max === 0)
605
+ return [""];
606
+ return [line.length > max ? `${line.slice(0, Math.max(0, max - 1))}\u2026` : line];
607
+ },
608
+ invalidate() {}
609
+ }));
610
+ }
611
+ function createBridgeGate(state) {
612
+ let pending = null;
613
+ let warned = false;
614
+ return async (ctx) => {
615
+ if (!state.active || !state.serverUrl)
616
+ return { allowed: true, message: null, status: "indeterminate" };
617
+ pending ??= state.client.checkProtocolCompatibility().then((check2) => {
618
+ if (check2.status === "indeterminate")
619
+ pending = null;
620
+ return check2;
621
+ });
622
+ const check = await pending;
623
+ if (check.status !== "mismatch")
624
+ return { allowed: true, message: null, status: check.status };
625
+ const message = check.message ?? "Rig protocol mismatch \u2014 the Rig bridge is disabled.";
626
+ if (!warned && canNotify(ctx)) {
627
+ warned = true;
628
+ notify(ctx, message, "error");
629
+ setStatus(ctx, "rig", "Rig bridge disabled (protocol mismatch)");
630
+ }
631
+ return { allowed: false, message, status: "mismatch" };
632
+ };
633
+ }
321
634
  function steeringText(message) {
322
635
  const text = typeof message.message === "string" ? message.message.trim() : "";
323
636
  if (!text)
@@ -326,54 +639,276 @@ function steeringText(message) {
326
639
  return `[Rig steering from ${actor}]
327
640
  ${text}`;
328
641
  }
329
- async function consumeQueuedSteering(pi, state, ctx) {
330
- if (!state.active || !state.runId || typeof pi.sendUserMessage !== "function")
642
+ function unrefTimer(timer) {
643
+ if (typeof timer.unref === "function") {
644
+ timer.unref();
645
+ }
646
+ }
647
+ var STEERING_DEDUPE_LIMIT = 500;
648
+ function rememberDeliveredSteeringId(deliveredIds, id) {
649
+ deliveredIds.add(id);
650
+ if (deliveredIds.size > STEERING_DEDUPE_LIMIT) {
651
+ const oldest = deliveredIds.values().next().value;
652
+ if (typeof oldest === "string")
653
+ deliveredIds.delete(oldest);
654
+ }
655
+ }
656
+ async function deliverSteeringMessage(pi, deliveredIds, message) {
657
+ if (typeof pi.sendUserMessage !== "function")
658
+ return false;
659
+ const id = typeof message.id === "string" && message.id.trim() ? message.id : null;
660
+ if (id && deliveredIds.has(id))
661
+ return false;
662
+ const text = steeringText(message);
663
+ if (!text)
664
+ return false;
665
+ if (id)
666
+ rememberDeliveredSteeringId(deliveredIds, id);
667
+ await pi.sendUserMessage(text, { deliverAs: "steer", triggerTurn: true });
668
+ return true;
669
+ }
670
+ async function consumeQueuedSteering(pi, state, ctx, gate, deliveredIds) {
671
+ if (state.operatorSession || !state.active || !state.runId || typeof pi.sendUserMessage !== "function")
672
+ return;
673
+ if (!(await gate(ctx)).allowed)
331
674
  return;
332
675
  try {
333
676
  const messages = await state.client.consumeSteering(state.runId);
677
+ let deliveredCount = 0;
334
678
  for (const message of messages) {
335
- const text = steeringText(message);
336
- if (!text)
337
- continue;
338
- await pi.sendUserMessage(text, { deliverAs: "steer", triggerTurn: true });
679
+ if (await deliverSteeringMessage(pi, deliveredIds, message))
680
+ deliveredCount += 1;
339
681
  }
340
- if (messages.length > 0) {
341
- notify(ctx, `Delivered ${messages.length} Rig steering message${messages.length === 1 ? "" : "s"}.`);
682
+ if (deliveredCount > 0) {
683
+ notify(ctx, `Delivered ${deliveredCount} Rig steering message${deliveredCount === 1 ? "" : "s"}.`);
342
684
  }
343
685
  } catch (error) {
344
686
  notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
345
687
  }
346
688
  }
347
- function startLiveSteeringPoll(pi, state, ctx) {
348
- if (!state.active || !state.runId || typeof pi.sendUserMessage !== "function")
689
+ function inputText(event) {
690
+ if (!event || typeof event !== "object" || Array.isArray(event))
691
+ return null;
692
+ const text = event.text;
693
+ return typeof text === "string" && text.trim() ? text.trim() : null;
694
+ }
695
+ async function handleOperatorInput(event, state, ctx, gate) {
696
+ if (!state.operatorSession || !state.active || !state.runId)
697
+ return;
698
+ const text = inputText(event);
699
+ if (!text || text.startsWith("/"))
700
+ return;
701
+ if (!(await gate(ctx)).allowed)
702
+ return;
703
+ try {
704
+ await state.client.steer(text, state.runId);
705
+ notify(ctx, "Rig steering message queued.");
706
+ } catch (error) {
707
+ notify(ctx, `Rig steering failed: ${error instanceof Error ? error.message : String(error)}`, "error");
708
+ }
709
+ return { action: "handled" };
710
+ }
711
+ function shortEntry(entry) {
712
+ const type = String(entry.type ?? entry.title ?? "event");
713
+ const text = typeof entry.text === "string" ? entry.text : typeof entry.detail === "string" ? entry.detail : typeof entry.message === "string" ? entry.message : "";
714
+ return `${type}: ${text}`.slice(0, 160);
715
+ }
716
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
717
+ function runLocation(run) {
718
+ const worktree = typeof run.worktreePath === "string" && run.worktreePath.trim() ? run.worktreePath.trim() : null;
719
+ const projectRoot = typeof run.projectRoot === "string" && run.projectRoot.trim() ? run.projectRoot.trim() : null;
720
+ return worktree ?? projectRoot ?? "remote/local worker workspace";
721
+ }
722
+ function runPayload(payload) {
723
+ return payload.run && typeof payload.run === "object" && !Array.isArray(payload.run) ? payload.run : payload;
724
+ }
725
+ var OPERATOR_WIDGET_WS_FALLBACK_MS = 1e4;
726
+ function startOperatorRunWidget(state, ctx, live) {
727
+ if (!state.operatorSession || !state.active || !state.runId)
728
+ return;
729
+ let inFlight = false;
730
+ let frame = 0;
731
+ let lastRefreshAt = 0;
732
+ const refresh = async () => {
733
+ if (inFlight)
734
+ return;
735
+ inFlight = true;
736
+ lastRefreshAt = Date.now();
737
+ const spinner = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "\u2022";
738
+ try {
739
+ const [runPayloadRecord, timeline] = await Promise.all([
740
+ state.client.attach(state.runId),
741
+ state.client.runTimeline(state.runId, 8).catch(() => [])
742
+ ]);
743
+ const run = runPayload(runPayloadRecord);
744
+ const status = String(run.status ?? "unknown");
745
+ const header = `${spinner} Rig ${String(run.runId ?? state.runId)} \xB7 ${status}`;
746
+ const location = runLocation(run);
747
+ const detail = typeof run.statusDetail === "string" && run.statusDetail.trim() ? run.statusDetail.trim() : String(run.title ?? run.taskId ?? "");
748
+ const lines = [
749
+ header,
750
+ `worker: ${location}`.slice(0, 200),
751
+ ...detail ? [detail.slice(0, 160)] : [],
752
+ ...timeline.slice(-5).map(shortEntry)
753
+ ];
754
+ setStatus(ctx, "rig", `${spinner} Rig ${status}`);
755
+ setFooter(ctx, `${spinner} Rig ${String(run.runId ?? state.runId)} \xB7 ${status} \xB7 worker ${location}`);
756
+ setWidget(ctx, "rig-run", lines);
757
+ } catch (error) {
758
+ const message = error instanceof Error ? error.message : String(error);
759
+ setStatus(ctx, "rig", `${spinner} Rig unavailable`);
760
+ setFooter(ctx, `${spinner} Rig ${state.runId} \xB7 unavailable \xB7 ${message}`);
761
+ setWidget(ctx, "rig-run", [`${spinner} Rig ${state.runId} \xB7 unavailable`, message]);
762
+ } finally {
763
+ inFlight = false;
764
+ }
765
+ };
766
+ refresh();
767
+ const timer = setInterval(() => {
768
+ const triggered = live?.consumePushTrigger() ?? false;
769
+ if ((live?.isConnected() ?? false) && !triggered && Date.now() - lastRefreshAt < OPERATOR_WIDGET_WS_FALLBACK_MS) {
770
+ return;
771
+ }
772
+ refresh();
773
+ }, 1000);
774
+ unrefTimer(timer);
775
+ }
776
+ function operatorInboxNotification(event) {
777
+ const type = typeof event.type === "string" ? event.type : null;
778
+ if (type !== "rig.approval.requested" && type !== "rig.user-input.requested")
779
+ return null;
780
+ const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload) ? event.payload : {};
781
+ const runId = typeof payload.runId === "string" && payload.runId.trim() ? payload.runId : typeof event.aggregateId === "string" && event.aggregateId.trim() ? event.aggregateId : "unknown";
782
+ const requestId = typeof payload.requestId === "string" && payload.requestId.trim() ? payload.requestId : `${type}:${runId}`;
783
+ const waitingOn = type === "rig.approval.requested" ? "an approval" : "user input";
784
+ return {
785
+ key: requestId,
786
+ text: `Run ${runId} is waiting on ${waitingOn} \u2014 resolve with /rig inbox or \`rig inbox\`.`
787
+ };
788
+ }
789
+ function startOperatorBridge(state, ctx) {
790
+ if (!state.operatorSession || !state.active)
349
791
  return;
792
+ const notifiedRequests = new Set;
793
+ let pushTrigger = false;
794
+ const socket = new RigBridgeSocket({
795
+ context: state,
796
+ webSocketFactory: state.webSocketFactory,
797
+ handlers: {
798
+ onRigEvent: (event) => {
799
+ pushTrigger = true;
800
+ const notification = operatorInboxNotification(event);
801
+ if (!notification || notifiedRequests.has(notification.key))
802
+ return;
803
+ notifiedRequests.add(notification.key);
804
+ if (notifiedRequests.size > 1000) {
805
+ const oldest = notifiedRequests.values().next().value;
806
+ if (typeof oldest === "string")
807
+ notifiedRequests.delete(oldest);
808
+ }
809
+ notify(ctx, notification.text);
810
+ },
811
+ onSnapshotInvalidated: () => {
812
+ pushTrigger = true;
813
+ },
814
+ onDisconnect: () => {
815
+ pushTrigger = true;
816
+ }
817
+ }
818
+ });
819
+ if (!socket.start())
820
+ return;
821
+ return {
822
+ isConnected: () => socket.connected,
823
+ consumePushTrigger: () => {
824
+ const triggered = pushTrigger;
825
+ pushTrigger = false;
826
+ return triggered;
827
+ }
828
+ };
829
+ }
830
+ function startSteeringBridge(pi, state, ctx, gate, deliveredIds) {
831
+ if (state.operatorSession || !state.active || !state.runId || typeof pi.sendUserMessage !== "function")
832
+ return;
833
+ const runId = state.runId;
834
+ const socket = new RigBridgeSocket({
835
+ context: state,
836
+ webSocketFactory: state.webSocketFactory,
837
+ handlers: {
838
+ onSteeringMessage: (message) => {
839
+ (async () => {
840
+ try {
841
+ if (!await deliverSteeringMessage(pi, deliveredIds, message))
842
+ return;
843
+ const id = typeof message.id === "string" && message.id.trim() ? message.id : null;
844
+ if (id)
845
+ socket.ackSteering(runId, [id]);
846
+ notify(ctx, "Delivered 1 Rig steering message.");
847
+ } catch (error) {
848
+ notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
849
+ }
850
+ })();
851
+ },
852
+ onConnect: () => {
853
+ consumeQueuedSteering(pi, state, ctx, gate, deliveredIds);
854
+ }
855
+ }
856
+ });
857
+ (async () => {
858
+ const gateResult = await gate(ctx);
859
+ if (!gateResult.allowed)
860
+ return;
861
+ if (gateResult.status === "compatible") {
862
+ socket.start();
863
+ }
864
+ })();
350
865
  const intervalMs = state.steeringPollMs ?? 1000;
351
866
  if (intervalMs <= 0)
352
867
  return;
868
+ const WS_CONNECTED_SWEEP_MS = 1e4;
353
869
  let inFlight = false;
870
+ let lastSweepAt = 0;
354
871
  const timer = setInterval(() => {
355
872
  if (inFlight)
356
873
  return;
874
+ if (socket.connected && Date.now() - lastSweepAt < WS_CONNECTED_SWEEP_MS)
875
+ return;
357
876
  inFlight = true;
358
- consumeQueuedSteering(pi, state, ctx).finally(() => {
877
+ lastSweepAt = Date.now();
878
+ consumeQueuedSteering(pi, state, ctx, gate, deliveredIds).finally(() => {
359
879
  inFlight = false;
360
880
  });
361
881
  }, intervalMs);
362
- if (typeof timer.unref === "function") {
363
- timer.unref();
364
- }
882
+ unrefTimer(timer);
365
883
  }
366
884
  function createPiRigExtension(pi, options = {}) {
367
885
  const state = options.state ?? createPiRigExtensionState();
886
+ const gate = createBridgeGate(state);
887
+ const deliveredSteeringIds = new Set;
368
888
  const commands = createRigSlashCommands({
369
889
  context: state,
370
890
  client: state.client,
371
891
  notify: (message, level) => notify(globalThis, message, level)
372
892
  });
893
+ const tryRegister = (label, register) => {
894
+ try {
895
+ register();
896
+ } catch (error) {
897
+ const message = error instanceof Error ? error.message : String(error);
898
+ if (/conflict|already|duplicate/i.test(message))
899
+ return;
900
+ throw error;
901
+ }
902
+ };
373
903
  for (const [name, command] of Object.entries(commands)) {
374
- pi.registerCommand?.(name, {
904
+ tryRegister(`command:${name}`, () => pi.registerCommand?.(name, {
375
905
  description: command.description,
376
906
  handler: async (args, ctx) => {
907
+ const gateResult = await gate(ctx);
908
+ if (!gateResult.allowed) {
909
+ notify(ctx, gateResult.message ?? "Rig bridge disabled (protocol mismatch).", "error");
910
+ return;
911
+ }
377
912
  const nextCommands = createRigSlashCommands({
378
913
  context: state,
379
914
  client: state.client,
@@ -381,26 +916,48 @@ function createPiRigExtension(pi, options = {}) {
381
916
  });
382
917
  await nextCommands.rig.handler(args, ctx);
383
918
  }
384
- });
919
+ }));
385
920
  }
386
921
  if (state.active && state.runId) {
387
922
  for (const tool of createRigTools({ context: state, client: state.client })) {
388
- pi.registerTool?.(tool);
923
+ tryRegister(`tool:${String(tool.name ?? "rig-tool")}`, () => pi.registerTool?.({
924
+ ...tool,
925
+ execute: async (toolCallId, params) => {
926
+ const gateResult = await gate(globalThis);
927
+ if (!gateResult.allowed) {
928
+ return { content: [{ type: "text", text: gateResult.message ?? "Rig bridge disabled (protocol mismatch)." }], isError: true };
929
+ }
930
+ return tool.execute(toolCallId, params);
931
+ }
932
+ }));
389
933
  }
390
- startLiveSteeringPoll(pi, state, globalThis);
934
+ startSteeringBridge(pi, state, globalThis, gate, deliveredSteeringIds);
391
935
  }
936
+ pi.on?.("input", async (event, ctx) => handleOperatorInput(event, state, ctx, gate));
392
937
  pi.on?.("session_start", async (_event, ctx) => {
393
938
  if (!state.active || !state.runId)
394
939
  return;
395
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
396
- const setStatus = ui && typeof ui === "object" ? ui.setStatus : null;
397
- if (typeof setStatus === "function") {
398
- setStatus.call(ui, "rig", `Rig ${state.runId}`);
940
+ setStatus(ctx, "rig", `Rig ${state.runId} \xB7 connecting\u2026`);
941
+ if (state.operatorSession) {
942
+ setFooter(ctx, `Rig ${state.runId} \xB7 connecting to ${state.serverUrl ?? "Rig server"}\u2026`);
943
+ setWidget(ctx, "rig-run", [`Rig ${state.runId} \xB7 connecting\u2026`]);
944
+ }
945
+ const gateResult = await gate(ctx);
946
+ if (!gateResult.allowed) {
947
+ if (state.operatorSession) {
948
+ const message = gateResult.message ?? "Rig bridge disabled (protocol mismatch).";
949
+ setFooter(ctx, `Rig ${state.runId} \xB7 bridge disabled (protocol mismatch)`);
950
+ setWidget(ctx, "rig-run", [message]);
951
+ }
952
+ return;
399
953
  }
400
- await consumeQueuedSteering(pi, state, ctx);
954
+ setStatus(ctx, "rig", `Rig ${state.runId}`);
955
+ const live = gateResult.status === "compatible" ? startOperatorBridge(state, ctx) : undefined;
956
+ startOperatorRunWidget(state, ctx, live);
957
+ await consumeQueuedSteering(pi, state, ctx, gate, deliveredSteeringIds);
401
958
  });
402
959
  pi.on?.("turn_end", async (_event, ctx) => {
403
- await consumeQueuedSteering(pi, state, ctx);
960
+ await consumeQueuedSteering(pi, state, ctx, gate, deliveredSteeringIds);
404
961
  });
405
962
  }
406
963
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@h-rig/pi-rig",
3
- "version": "0.0.6-alpha.7",
3
+ "version": "0.0.6-alpha.70",
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.70"
37
+ },
35
38
  "peerDependencies": {
36
- "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-coding-agent": ">=0.79.0",
37
40
  "typebox": "*"
38
41
  }
39
42
  }