@h-rig/pi-rig 0.0.6-alpha.82 → 0.0.6-alpha.84

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.
@@ -45,6 +45,13 @@ function resolveGlobalConnectionsPath(env) {
45
45
  return resolve(stateDir, "connections.json");
46
46
  return resolve(homedir(), ".rig", "connections.json");
47
47
  }
48
+ function inferRemoteProjectRoot(repoConnection, authState) {
49
+ const repoSlug = cleanString(repoConnection?.project) ?? cleanString(authState?.selectedRepo);
50
+ const checkoutBaseDir = cleanString(authState?.checkoutBaseDir);
51
+ if (!repoSlug || !checkoutBaseDir || !repoSlug.includes("/"))
52
+ return null;
53
+ return resolve(checkoutBaseDir, repoSlug);
54
+ }
48
55
  function discoverRigContext(env) {
49
56
  const cwd = cleanString(env.PWD);
50
57
  if (!cwd)
@@ -60,11 +67,11 @@ function discoverRigContext(env) {
60
67
  const server = readJson(resolve(projectRoot, ".rig", "state", "rig-server.json"));
61
68
  const host = cleanString(server?.host);
62
69
  const port = typeof server?.port === "number" ? server.port : null;
63
- const authToken = cleanString(server?.authToken);
70
+ const authToken2 = cleanString(server?.authToken);
64
71
  return {
65
72
  projectRoot,
66
73
  ...host && port ? { serverUrl: `http://${host}:${port}` } : {},
67
- ...authToken ? { authToken } : {}
74
+ ...authToken2 ? { authToken: authToken2 } : {}
68
75
  };
69
76
  }
70
77
  const global = readJson(resolveGlobalConnectionsPath(env));
@@ -72,15 +79,22 @@ function discoverRigContext(env) {
72
79
  const selectedConnection = connections[selected];
73
80
  const record = selectedConnection && typeof selectedConnection === "object" && !Array.isArray(selectedConnection) ? selectedConnection : null;
74
81
  const baseUrl = record?.kind === "remote" ? cleanString(record.baseUrl) : null;
75
- return { projectRoot, ...baseUrl ? { serverUrl: baseUrl } : {} };
82
+ const authState = readJson(resolve(projectRoot, ".rig", "state", "github-auth.json"));
83
+ const authToken = cleanString(authState?.apiSessionToken) ?? cleanString(authState?.sessionToken);
84
+ const serverProjectRoot = cleanString(repoConnection?.serverProjectRoot) ?? inferRemoteProjectRoot(repoConnection, authState);
85
+ return {
86
+ projectRoot: serverProjectRoot ?? projectRoot,
87
+ ...baseUrl ? { serverUrl: baseUrl } : {},
88
+ ...authToken ? { authToken } : {}
89
+ };
76
90
  }
77
91
  function createRigContextFromEnv(env = process.env) {
78
92
  const runId = env.RIG_RUN_ID ?? env.RIG_SERVER_RUN_ID;
79
93
  const taskId = env.RIG_TASK_ID;
80
94
  const discovered = discoverRigContext(env);
81
- const serverUrl = env.RIG_SERVER_URL ?? env.RIG_SERVER_BASE_URL ?? discovered.serverUrl;
82
- const projectRoot = env.RIG_PROJECT_ROOT ?? env.PROJECT_RIG_ROOT ?? discovered.projectRoot;
83
- const authToken = env.RIG_AUTH_TOKEN ?? env.RIG_SERVER_AUTH_TOKEN ?? discovered.authToken;
95
+ const serverUrl = cleanString(env.RIG_SERVER_URL) ?? cleanString(env.RIG_SERVER_BASE_URL) ?? discovered.serverUrl;
96
+ const projectRoot = cleanString(env.RIG_PROJECT_ROOT) ?? cleanString(env.PROJECT_RIG_ROOT) ?? discovered.projectRoot;
97
+ const authToken = cleanString(env.RIG_AUTH_TOKEN) ?? cleanString(env.RIG_SERVER_AUTH_TOKEN) ?? discovered.authToken;
84
98
  const steeringPollMs = cleanNonNegativeInteger(env.RIG_STEERING_POLL_MS);
85
99
  const operatorSession = env.RIG_PI_OPERATOR_SESSION === "1" || env.RIG_PI_OPERATOR_SESSION === "true";
86
100
  const active = Boolean(runId || taskId || serverUrl || projectRoot);
@@ -121,6 +135,16 @@ function requireServerUrl(context) {
121
135
  }
122
136
  var BRIDGE_REQUEST_TIMEOUT_MS = 30000;
123
137
  var PROTOCOL_CHECK_TIMEOUT_MS = 1e4;
138
+ function mergeCookie(existing, name, value) {
139
+ const encoded = `${name}=${encodeURIComponent(value)}`;
140
+ if (!existing?.trim())
141
+ return encoded;
142
+ const parts = existing.split(";").map((part) => part.trim()).filter((part) => part && !part.startsWith(`${name}=`));
143
+ return [...parts, encoded].join("; ");
144
+ }
145
+ function queryAuthFallbackEnabled(env = process.env) {
146
+ return env.RIG_ENABLE_QUERY_AUTH_FALLBACK === "1" || env.RIG_QUERY_AUTH_FALLBACK === "1";
147
+ }
124
148
 
125
149
  class RigBridgeClient {
126
150
  context;
@@ -131,14 +155,22 @@ class RigBridgeClient {
131
155
  }
132
156
  async request(pathname, init, timeoutMs = BRIDGE_REQUEST_TIMEOUT_MS) {
133
157
  const headers = new Headers(init?.headers);
134
- if (this.context.authToken && !headers.has("authorization")) {
135
- headers.set("authorization", `Bearer ${this.context.authToken}`);
158
+ if (this.context.authToken) {
159
+ const bearer = `Bearer ${this.context.authToken}`;
160
+ if (!headers.has("authorization"))
161
+ headers.set("authorization", bearer);
162
+ if (!headers.has("x-auth"))
163
+ headers.set("x-auth", bearer);
164
+ headers.set("cookie", mergeCookie(headers.get("cookie"), "rig_auth", this.context.authToken));
136
165
  }
137
166
  if (this.context.projectRoot && !headers.has("x-rig-project-root")) {
138
167
  headers.set("x-rig-project-root", this.context.projectRoot);
139
168
  }
140
169
  const signal = init?.signal ?? (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined);
141
- const response = await this.fetchImpl(joinUrl(requireServerUrl(this.context), pathname), { ...init, headers, signal });
170
+ const requestUrl = new URL(joinUrl(requireServerUrl(this.context), pathname));
171
+ if (this.context.authToken && queryAuthFallbackEnabled())
172
+ requestUrl.searchParams.set("rt", this.context.authToken);
173
+ const response = await this.fetchImpl(requestUrl.toString(), { ...init, headers, signal });
142
174
  return readJsonResponse(response);
143
175
  }
144
176
  async status(timeoutMs) {
@@ -53,8 +53,17 @@ function createRigSlashCommands(input) {
53
53
  notify("Usage: /rig steer <message>", "error");
54
54
  return;
55
55
  }
56
- await input.client.steer(message);
57
- notify("Rig steering message queued.", "info");
56
+ const result = await input.client.steer(message);
57
+ const accepted = result.ok !== false && result.queued !== false;
58
+ if (!accepted) {
59
+ const reason = typeof result.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
60
+ notify(`Rig did not accept the steering message${reason}.`, "error");
61
+ return;
62
+ }
63
+ const entry = result.message && typeof result.message === "object" && !Array.isArray(result.message) ? result.message : null;
64
+ const runId = String(entry?.runId ?? second ?? input.context.runId ?? "run");
65
+ const preview = message.length > 80 ? `${message.slice(0, 79)}\u2026` : message;
66
+ notify(`Steering queued for ${runId} \u2192 "${preview}" \u2014 the worker applies it at its next checkpoint.`, "info");
58
67
  return;
59
68
  }
60
69
  if (first === "stop") {
@@ -46,13 +46,11 @@ export type PiRigBridgeGate = {
46
46
  readonly status: RigProtocolCheck["status"];
47
47
  };
48
48
  export type PiRigBridgeGateCheck = (ctx: unknown) => Promise<PiRigBridgeGate>;
49
- /** Live refresh control handed to the operator widget by the WS bridge:
50
- * while the socket is up, pushes (rig.event / snapshotInvalidated) drive the
51
- * widget instead of the 1s status poll. */
52
49
  export type OperatorLiveRefresh = {
53
50
  isConnected(): boolean;
54
51
  /** Returns true (and resets) when a push arrived since the last check. */
55
52
  consumePushTrigger(): boolean;
53
+ dispose(): void;
56
54
  };
57
55
  export default function createPiRigExtension(pi: MinimalPiApi, options?: {
58
56
  state?: PiRigExtensionState;
package/dist/src/index.js CHANGED
@@ -47,6 +47,13 @@ function resolveGlobalConnectionsPath(env) {
47
47
  return resolve(stateDir, "connections.json");
48
48
  return resolve(homedir(), ".rig", "connections.json");
49
49
  }
50
+ function inferRemoteProjectRoot(repoConnection, authState) {
51
+ const repoSlug = cleanString(repoConnection?.project) ?? cleanString(authState?.selectedRepo);
52
+ const checkoutBaseDir = cleanString(authState?.checkoutBaseDir);
53
+ if (!repoSlug || !checkoutBaseDir || !repoSlug.includes("/"))
54
+ return null;
55
+ return resolve(checkoutBaseDir, repoSlug);
56
+ }
50
57
  function discoverRigContext(env) {
51
58
  const cwd = cleanString(env.PWD);
52
59
  if (!cwd)
@@ -62,11 +69,11 @@ function discoverRigContext(env) {
62
69
  const server = readJson(resolve(projectRoot, ".rig", "state", "rig-server.json"));
63
70
  const host = cleanString(server?.host);
64
71
  const port = typeof server?.port === "number" ? server.port : null;
65
- const authToken = cleanString(server?.authToken);
72
+ const authToken2 = cleanString(server?.authToken);
66
73
  return {
67
74
  projectRoot,
68
75
  ...host && port ? { serverUrl: `http://${host}:${port}` } : {},
69
- ...authToken ? { authToken } : {}
76
+ ...authToken2 ? { authToken: authToken2 } : {}
70
77
  };
71
78
  }
72
79
  const global = readJson(resolveGlobalConnectionsPath(env));
@@ -74,15 +81,22 @@ function discoverRigContext(env) {
74
81
  const selectedConnection = connections[selected];
75
82
  const record = selectedConnection && typeof selectedConnection === "object" && !Array.isArray(selectedConnection) ? selectedConnection : null;
76
83
  const baseUrl = record?.kind === "remote" ? cleanString(record.baseUrl) : null;
77
- return { projectRoot, ...baseUrl ? { serverUrl: baseUrl } : {} };
84
+ const authState = readJson(resolve(projectRoot, ".rig", "state", "github-auth.json"));
85
+ const authToken = cleanString(authState?.apiSessionToken) ?? cleanString(authState?.sessionToken);
86
+ const serverProjectRoot = cleanString(repoConnection?.serverProjectRoot) ?? inferRemoteProjectRoot(repoConnection, authState);
87
+ return {
88
+ projectRoot: serverProjectRoot ?? projectRoot,
89
+ ...baseUrl ? { serverUrl: baseUrl } : {},
90
+ ...authToken ? { authToken } : {}
91
+ };
78
92
  }
79
93
  function createRigContextFromEnv(env = process.env) {
80
94
  const runId = env.RIG_RUN_ID ?? env.RIG_SERVER_RUN_ID;
81
95
  const taskId = env.RIG_TASK_ID;
82
96
  const discovered = discoverRigContext(env);
83
- const serverUrl = env.RIG_SERVER_URL ?? env.RIG_SERVER_BASE_URL ?? discovered.serverUrl;
84
- const projectRoot = env.RIG_PROJECT_ROOT ?? env.PROJECT_RIG_ROOT ?? discovered.projectRoot;
85
- const authToken = env.RIG_AUTH_TOKEN ?? env.RIG_SERVER_AUTH_TOKEN ?? discovered.authToken;
97
+ const serverUrl = cleanString(env.RIG_SERVER_URL) ?? cleanString(env.RIG_SERVER_BASE_URL) ?? discovered.serverUrl;
98
+ const projectRoot = cleanString(env.RIG_PROJECT_ROOT) ?? cleanString(env.PROJECT_RIG_ROOT) ?? discovered.projectRoot;
99
+ const authToken = cleanString(env.RIG_AUTH_TOKEN) ?? cleanString(env.RIG_SERVER_AUTH_TOKEN) ?? discovered.authToken;
86
100
  const steeringPollMs = cleanNonNegativeInteger(env.RIG_STEERING_POLL_MS);
87
101
  const operatorSession = env.RIG_PI_OPERATOR_SESSION === "1" || env.RIG_PI_OPERATOR_SESSION === "true";
88
102
  const active = Boolean(runId || taskId || serverUrl || projectRoot);
@@ -123,6 +137,16 @@ function requireServerUrl(context) {
123
137
  }
124
138
  var BRIDGE_REQUEST_TIMEOUT_MS = 30000;
125
139
  var PROTOCOL_CHECK_TIMEOUT_MS = 1e4;
140
+ function mergeCookie(existing, name, value) {
141
+ const encoded = `${name}=${encodeURIComponent(value)}`;
142
+ if (!existing?.trim())
143
+ return encoded;
144
+ const parts = existing.split(";").map((part) => part.trim()).filter((part) => part && !part.startsWith(`${name}=`));
145
+ return [...parts, encoded].join("; ");
146
+ }
147
+ function queryAuthFallbackEnabled(env = process.env) {
148
+ return env.RIG_ENABLE_QUERY_AUTH_FALLBACK === "1" || env.RIG_QUERY_AUTH_FALLBACK === "1";
149
+ }
126
150
 
127
151
  class RigBridgeClient {
128
152
  context;
@@ -133,14 +157,22 @@ class RigBridgeClient {
133
157
  }
134
158
  async request(pathname, init, timeoutMs = BRIDGE_REQUEST_TIMEOUT_MS) {
135
159
  const headers = new Headers(init?.headers);
136
- if (this.context.authToken && !headers.has("authorization")) {
137
- headers.set("authorization", `Bearer ${this.context.authToken}`);
160
+ if (this.context.authToken) {
161
+ const bearer = `Bearer ${this.context.authToken}`;
162
+ if (!headers.has("authorization"))
163
+ headers.set("authorization", bearer);
164
+ if (!headers.has("x-auth"))
165
+ headers.set("x-auth", bearer);
166
+ headers.set("cookie", mergeCookie(headers.get("cookie"), "rig_auth", this.context.authToken));
138
167
  }
139
168
  if (this.context.projectRoot && !headers.has("x-rig-project-root")) {
140
169
  headers.set("x-rig-project-root", this.context.projectRoot);
141
170
  }
142
171
  const signal = init?.signal ?? (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined);
143
- const response = await this.fetchImpl(joinUrl(requireServerUrl(this.context), pathname), { ...init, headers, signal });
172
+ const requestUrl = new URL(joinUrl(requireServerUrl(this.context), pathname));
173
+ if (this.context.authToken && queryAuthFallbackEnabled())
174
+ requestUrl.searchParams.set("rt", this.context.authToken);
175
+ const response = await this.fetchImpl(requestUrl.toString(), { ...init, headers, signal });
144
176
  return readJsonResponse(response);
145
177
  }
146
178
  async status(timeoutMs) {
@@ -667,8 +699,17 @@ function createRigSlashCommands(input) {
667
699
  notify("Usage: /rig steer <message>", "error");
668
700
  return;
669
701
  }
670
- await input.client.steer(message);
671
- notify("Rig steering message queued.", "info");
702
+ const result = await input.client.steer(message);
703
+ const accepted = result.ok !== false && result.queued !== false;
704
+ if (!accepted) {
705
+ const reason = typeof result.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
706
+ notify(`Rig did not accept the steering message${reason}.`, "error");
707
+ return;
708
+ }
709
+ const entry = result.message && typeof result.message === "object" && !Array.isArray(result.message) ? result.message : null;
710
+ const runId = String(entry?.runId ?? second ?? input.context.runId ?? "run");
711
+ const preview = message.length > 80 ? `${message.slice(0, 79)}\u2026` : message;
712
+ notify(`Steering queued for ${runId} \u2192 "${preview}" \u2014 the worker applies it at its next checkpoint.`, "info");
672
713
  return;
673
714
  }
674
715
  if (first === "stop") {
@@ -986,35 +1027,52 @@ function createPiRigExtensionState(input = {}) {
986
1027
  ...input.webSocketFactory ? { webSocketFactory: input.webSocketFactory } : {}
987
1028
  };
988
1029
  }
1030
+ function isStalePiContextError(error) {
1031
+ const message = error instanceof Error ? error.message : String(error);
1032
+ return /ctx is stale|stale after session replacement|session replacement or reload/i.test(message);
1033
+ }
1034
+ function safeUiCall(action) {
1035
+ try {
1036
+ action();
1037
+ } catch (error) {
1038
+ if (!isStalePiContextError(error)) {
1039
+ return;
1040
+ }
1041
+ }
1042
+ }
1043
+ function uiOf(ctx) {
1044
+ try {
1045
+ const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
1046
+ return ui && typeof ui === "object" ? ui : null;
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+ }
989
1051
  function notify(ctx, message, level = "info") {
990
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
991
- const notifyFn = ui && typeof ui === "object" ? ui.notify : null;
1052
+ const ui = uiOf(ctx);
1053
+ const notifyFn = ui?.notify;
992
1054
  if (typeof notifyFn === "function") {
993
- notifyFn.call(ui, message, level);
1055
+ safeUiCall(() => notifyFn.call(ui, message, level));
994
1056
  }
995
1057
  }
996
1058
  function canNotify(ctx) {
997
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
998
- return Boolean(ui && typeof ui === "object" && typeof ui.notify === "function");
1059
+ const ui = uiOf(ctx);
1060
+ return Boolean(ui && typeof ui.notify === "function");
999
1061
  }
1000
1062
  function setWidget(ctx, id, lines) {
1001
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
1002
- const setWidgetFn = ui && typeof ui === "object" ? ui.setWidget : null;
1063
+ const ui = uiOf(ctx);
1064
+ const setWidgetFn = ui?.setWidget;
1003
1065
  if (typeof setWidgetFn === "function") {
1004
- setWidgetFn.call(ui, id, lines);
1066
+ safeUiCall(() => setWidgetFn.call(ui, id, lines));
1005
1067
  }
1006
1068
  }
1007
1069
  function setStatus(ctx, id, text) {
1008
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
1009
- const setStatusFn = ui && typeof ui === "object" ? ui.setStatus : null;
1070
+ const ui = uiOf(ctx);
1071
+ const setStatusFn = ui?.setStatus;
1010
1072
  if (typeof setStatusFn === "function") {
1011
- setStatusFn.call(ui, id, text);
1073
+ safeUiCall(() => setStatusFn.call(ui, id, text));
1012
1074
  }
1013
1075
  }
1014
- function uiOf(ctx) {
1015
- const ui = ctx && typeof ctx === "object" ? ctx.ui : null;
1016
- return ui && typeof ui === "object" ? ui : null;
1017
- }
1018
1076
  function setTitle(ctx, title) {
1019
1077
  const ui = uiOf(ctx);
1020
1078
  const setTitleFn = ui?.setTitle;
@@ -1181,24 +1239,31 @@ function startOperatorRunStatusLine(state, ctx, live) {
1181
1239
  return;
1182
1240
  const shortId = state.runId.slice(0, 8);
1183
1241
  let inFlight = false;
1242
+ let disposed = false;
1184
1243
  let lastRefreshAt = 0;
1185
1244
  const refresh = async () => {
1186
- if (inFlight)
1245
+ if (disposed || inFlight)
1187
1246
  return;
1188
1247
  inFlight = true;
1189
1248
  lastRefreshAt = Date.now();
1190
1249
  try {
1191
1250
  const run = runPayload(await state.client.attach(state.runId));
1251
+ if (disposed)
1252
+ return;
1192
1253
  const status = String(run.status ?? "unknown");
1193
1254
  setStatus(ctx, "rig", `drone ${shortId} \xB7 ${status} \xB7 ${shortPath(runLocation(run))}`);
1194
1255
  } catch (error) {
1195
- setStatus(ctx, "rig", `drone ${shortId} \xB7 server unreachable: ${error instanceof Error ? error.message : String(error)}`);
1256
+ if (!disposed) {
1257
+ setStatus(ctx, "rig", `drone ${shortId} \xB7 server unreachable: ${error instanceof Error ? error.message : String(error)}`);
1258
+ }
1196
1259
  } finally {
1197
1260
  inFlight = false;
1198
1261
  }
1199
1262
  };
1200
1263
  refresh();
1201
1264
  const timer = setInterval(() => {
1265
+ if (disposed)
1266
+ return;
1202
1267
  const triggered = live?.consumePushTrigger() ?? false;
1203
1268
  if ((live?.isConnected() ?? false) && !triggered && Date.now() - lastRefreshAt < OPERATOR_WIDGET_WS_FALLBACK_MS) {
1204
1269
  return;
@@ -1206,6 +1271,10 @@ function startOperatorRunStatusLine(state, ctx, live) {
1206
1271
  refresh();
1207
1272
  }, 5000);
1208
1273
  unrefTimer(timer);
1274
+ return () => {
1275
+ disposed = true;
1276
+ clearInterval(timer);
1277
+ };
1209
1278
  }
1210
1279
  function operatorInboxNotification(event) {
1211
1280
  const type = typeof event.type === "string" ? event.type : null;
@@ -1258,7 +1327,8 @@ function startOperatorBridge(state, ctx) {
1258
1327
  const triggered = pushTrigger;
1259
1328
  pushTrigger = false;
1260
1329
  return triggered;
1261
- }
1330
+ },
1331
+ dispose: () => socket.close()
1262
1332
  };
1263
1333
  }
1264
1334
  function workerStatusLine(status) {
@@ -1380,24 +1450,30 @@ function registerOperatorConsoleCommands(pi, state, tryRegister) {
1380
1450
  function startWorkerSessionMirror(pi, state, ctx) {
1381
1451
  if (!state.operatorSession || !state.active || !state.runId)
1382
1452
  return;
1453
+ let disposed = false;
1383
1454
  let mirror = null;
1384
1455
  const pendingEvents = [];
1385
1456
  createLiveMirror({ pi }).then((created) => {
1457
+ if (disposed)
1458
+ return;
1386
1459
  mirror = created;
1387
1460
  const ui = uiOf(ctx);
1388
1461
  if (ui && typeof ui.setWidget === "function") {
1389
- ui.setWidget("rig-tui-capture", (tui) => created.captureTui(tui));
1462
+ safeUiCall(() => ui.setWidget("rig-tui-capture", (tui) => created.captureTui(tui)));
1390
1463
  }
1391
1464
  for (const event of pendingEvents.splice(0))
1392
1465
  created.handleWorkerEvent(event);
1393
1466
  }).catch((error) => {
1394
- notify(ctx, `Live drone transcript unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
1467
+ if (!disposed)
1468
+ notify(ctx, `Live drone transcript unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
1395
1469
  });
1396
1470
  const socket = new RigWorkerEventsSocket({
1397
1471
  context: state,
1398
1472
  webSocketFactory: state.webSocketFactory,
1399
1473
  handlers: {
1400
1474
  onFrame: (frame) => {
1475
+ if (disposed)
1476
+ return;
1401
1477
  if (frame.type === "status.update") {
1402
1478
  const status = frame.status && typeof frame.status === "object" && !Array.isArray(frame.status) ? frame.status : null;
1403
1479
  if (status)
@@ -1419,14 +1495,21 @@ function startWorkerSessionMirror(pi, state, ctx) {
1419
1495
  pendingEvents.push(event);
1420
1496
  },
1421
1497
  onConnect: () => {
1422
- setStatus(ctx, "rig-worker", "drone link live");
1498
+ if (!disposed)
1499
+ setStatus(ctx, "rig-worker", "drone link live");
1423
1500
  },
1424
1501
  onDisconnect: () => {
1425
- setStatus(ctx, "rig-worker", "drone link down (reconnecting\u2026)");
1502
+ if (!disposed)
1503
+ setStatus(ctx, "rig-worker", "drone link down (reconnecting\u2026)");
1426
1504
  }
1427
1505
  }
1428
1506
  });
1429
1507
  socket.start();
1508
+ return () => {
1509
+ disposed = true;
1510
+ pendingEvents.splice(0);
1511
+ socket.close();
1512
+ };
1430
1513
  }
1431
1514
  async function forwardWorkerUiRequest(state, ctx, event) {
1432
1515
  const request = event.request && typeof event.request === "object" && !Array.isArray(event.request) ? event.request : event;
@@ -1496,8 +1579,10 @@ function startWorkerCommandRegistration(pi, state, ctx) {
1496
1579
  const registeredNames = new Set;
1497
1580
  let attempts = 0;
1498
1581
  let inFlight = false;
1582
+ let disposed = false;
1583
+ let timer = null;
1499
1584
  const attempt = async () => {
1500
- if (inFlight)
1585
+ if (disposed || inFlight)
1501
1586
  return false;
1502
1587
  inFlight = true;
1503
1588
  attempts += 1;
@@ -1508,64 +1593,84 @@ function startWorkerCommandRegistration(pi, state, ctx) {
1508
1593
  }
1509
1594
  };
1510
1595
  attempt().then((ready) => {
1511
- if (ready)
1596
+ if (disposed || ready)
1512
1597
  return;
1513
- const timer = setInterval(() => {
1514
- if (attempts >= 60) {
1515
- clearInterval(timer);
1598
+ timer = setInterval(() => {
1599
+ if (disposed || attempts >= 60) {
1600
+ if (timer)
1601
+ clearInterval(timer);
1602
+ timer = null;
1516
1603
  return;
1517
1604
  }
1518
1605
  attempt().then((nextReady) => {
1519
- if (nextReady)
1606
+ if (nextReady && timer) {
1520
1607
  clearInterval(timer);
1608
+ timer = null;
1609
+ }
1521
1610
  });
1522
1611
  }, 2000);
1523
1612
  unrefTimer(timer);
1524
1613
  });
1614
+ return () => {
1615
+ disposed = true;
1616
+ if (timer)
1617
+ clearInterval(timer);
1618
+ timer = null;
1619
+ };
1525
1620
  }
1526
1621
  function startSteeringBridge(pi, state, ctx, gate, deliveredIds) {
1527
1622
  if (state.operatorSession || !state.active || !state.runId || typeof pi.sendUserMessage !== "function")
1528
1623
  return;
1529
1624
  const runId = state.runId;
1625
+ let disposed = false;
1530
1626
  const socket = new RigBridgeSocket({
1531
1627
  context: state,
1532
1628
  webSocketFactory: state.webSocketFactory,
1533
1629
  handlers: {
1534
1630
  onSteeringMessage: (message) => {
1631
+ if (disposed)
1632
+ return;
1535
1633
  (async () => {
1536
1634
  try {
1537
- if (!await deliverSteeringMessage(pi, deliveredIds, message))
1635
+ if (disposed || !await deliverSteeringMessage(pi, deliveredIds, message))
1538
1636
  return;
1539
1637
  const id = typeof message.id === "string" && message.id.trim() ? message.id : null;
1540
1638
  if (id)
1541
1639
  socket.ackSteering(runId, [id]);
1542
1640
  notify(ctx, "Delivered 1 Rig steering message.");
1543
1641
  } catch (error) {
1544
- notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1642
+ if (!disposed)
1643
+ notify(ctx, `Rig steering sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1545
1644
  }
1546
1645
  })();
1547
1646
  },
1548
1647
  onConnect: () => {
1648
+ if (disposed)
1649
+ return;
1549
1650
  consumeQueuedSteering(pi, state, ctx, gate, deliveredIds);
1550
1651
  }
1551
1652
  }
1552
1653
  });
1553
1654
  (async () => {
1554
1655
  const gateResult = await gate(ctx);
1555
- if (!gateResult.allowed)
1656
+ if (disposed || !gateResult.allowed)
1556
1657
  return;
1557
1658
  if (gateResult.status === "compatible") {
1558
1659
  socket.start();
1559
1660
  }
1560
1661
  })();
1561
1662
  const intervalMs = state.steeringPollMs ?? 1000;
1562
- if (intervalMs <= 0)
1563
- return;
1663
+ if (intervalMs <= 0) {
1664
+ return () => {
1665
+ disposed = true;
1666
+ socket.close();
1667
+ };
1668
+ }
1564
1669
  const WS_CONNECTED_SWEEP_MS = 1e4;
1565
1670
  let inFlight = false;
1566
1671
  let lastSweepAt = 0;
1567
1672
  const timer = setInterval(() => {
1568
- if (inFlight)
1673
+ if (disposed || inFlight)
1569
1674
  return;
1570
1675
  if (socket.connected && Date.now() - lastSweepAt < WS_CONNECTED_SWEEP_MS)
1571
1676
  return;
@@ -1576,11 +1681,35 @@ function startSteeringBridge(pi, state, ctx, gate, deliveredIds) {
1576
1681
  });
1577
1682
  }, intervalMs);
1578
1683
  unrefTimer(timer);
1684
+ return () => {
1685
+ disposed = true;
1686
+ clearInterval(timer);
1687
+ socket.close();
1688
+ };
1579
1689
  }
1580
1690
  function createPiRigExtension(pi, options = {}) {
1581
1691
  const state = options.state ?? createPiRigExtensionState();
1582
1692
  const gate = createBridgeGate(state);
1583
1693
  const deliveredSteeringIds = new Set;
1694
+ const sessionDisposables = new Set;
1695
+ const runtimeDisposables = new Set;
1696
+ const addDisposable = (target, disposable) => {
1697
+ if (disposable)
1698
+ target.add(disposable);
1699
+ };
1700
+ const disposeSet = (target) => {
1701
+ for (const dispose of target) {
1702
+ try {
1703
+ dispose();
1704
+ } catch {}
1705
+ }
1706
+ target.clear();
1707
+ };
1708
+ const disposeSessionResources = () => disposeSet(sessionDisposables);
1709
+ const disposeRuntimeResources = () => {
1710
+ disposeSet(sessionDisposables);
1711
+ disposeSet(runtimeDisposables);
1712
+ };
1584
1713
  const commands = createRigSlashCommands({
1585
1714
  context: state,
1586
1715
  client: state.client,
@@ -1630,10 +1759,14 @@ function createPiRigExtension(pi, options = {}) {
1630
1759
  }
1631
1760
  }));
1632
1761
  }
1633
- startSteeringBridge(pi, state, globalThis, gate, deliveredSteeringIds);
1762
+ addDisposable(runtimeDisposables, startSteeringBridge(pi, state, globalThis, gate, deliveredSteeringIds));
1634
1763
  }
1635
1764
  pi.on?.("input", async (event, ctx) => handleOperatorInput(event, state, ctx, gate));
1765
+ pi.on?.("session_shutdown", () => {
1766
+ disposeRuntimeResources();
1767
+ });
1636
1768
  pi.on?.("session_start", async (_event, ctx) => {
1769
+ disposeSessionResources();
1637
1770
  if (!state.active || !state.runId)
1638
1771
  return;
1639
1772
  const shortId = state.runId.slice(0, 8);
@@ -1649,11 +1782,12 @@ function createPiRigExtension(pi, options = {}) {
1649
1782
  return;
1650
1783
  }
1651
1784
  setStatus(ctx, "rig", `drone ${shortId} \xB7 connecting\u2026`);
1652
- const live = gateResult.status === "compatible" ? startOperatorBridge(state, ctx) : undefined;
1653
- startOperatorRunStatusLine(state, ctx, live);
1654
- if (state.operatorSession && gateResult.status === "compatible") {
1655
- startWorkerSessionMirror(pi, state, ctx);
1656
- startWorkerCommandRegistration(pi, state, ctx);
1785
+ const live = gateResult.allowed ? startOperatorBridge(state, ctx) : undefined;
1786
+ addDisposable(sessionDisposables, live?.dispose);
1787
+ addDisposable(sessionDisposables, startOperatorRunStatusLine(state, ctx, live));
1788
+ if (state.operatorSession && gateResult.allowed) {
1789
+ addDisposable(sessionDisposables, startWorkerSessionMirror(pi, state, ctx));
1790
+ addDisposable(sessionDisposables, startWorkerCommandRegistration(pi, state, ctx));
1657
1791
  }
1658
1792
  await consumeQueuedSteering(pi, state, ctx, gate, deliveredSteeringIds);
1659
1793
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@h-rig/pi-rig",
3
- "version": "0.0.6-alpha.82",
3
+ "version": "0.0.6-alpha.84",
4
4
  "type": "module",
5
5
  "description": "Rig package",
6
6
  "license": "UNLICENSED",
@@ -38,7 +38,7 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.82"
41
+ "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.84"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-coding-agent": ">=0.79.0",