tracegist-mcp-bridge 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to `tracegist-mcp-bridge`.
4
4
 
5
+ ## 0.4.0
6
+
7
+ **Remote Live Sessions** — the live shadowing loop now works across machines.
8
+ A tester anywhere starts a remote session in the TraceGist extension and
9
+ shares a code; this bridge joins through a relay and every live tool works
10
+ exactly as it does locally.
11
+
12
+ - New tool `join_live_session({ code })`: joins the tester's session via a
13
+ relay (`TRACEGIST_RELAY_URL` env var overrides the default). All live-shadow
14
+ traffic is end-to-end encrypted with AES-256-GCM using a key derived from
15
+ the session code — the relay only ever sees ciphertext, and the room ID is
16
+ a hash of the code, so the relay cannot derive the key.
17
+ - New tool `leave_live_session()`: disconnects from the remote session.
18
+ - `live-session://status` now reports the active transport (`local` / `relay`
19
+ / `none`) and remote connection state.
20
+ - The local localhost WebSocket transport is unchanged and remains the
21
+ default; remote mode is purely additive (semver minor).
22
+ - `TRACEGIST_DISABLE_LIVE` now also disables `join_live_session`.
23
+ - Relay reconnects use the same backoff strategy as the extension; a
24
+ `tester-connected` / `tester-disconnected` live event is pushed when the
25
+ tester's extension joins or drops.
26
+
27
+ The relay itself ships separately as the `tracegist-relay` package
28
+ (self-hostable; plain Node + ws).
29
+
5
30
  ## 0.3.0
6
31
 
7
32
  **Breaking changes:**
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Remote Live Sessions — session-code derivation and E2E frame crypto.
3
+ *
4
+ * Node-side mirror of the extension's lib/remote-session.ts. The two modules
5
+ * must stay byte-compatible: same code normalization, same room-ID and key
6
+ * derivation, same AES-256-GCM frame format (see docs/live-protocol.md in the
7
+ * TraceGist repo). This package is published standalone, so the primitives
8
+ * are duplicated here rather than imported from the extension codebase.
9
+ */
10
+
11
+ import { webcrypto } from "node:crypto";
12
+
13
+ const subtle = webcrypto.subtle;
14
+
15
+ /** Crockford base32 alphabet — no I, L, O, U to avoid ambiguity. */
16
+ const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
17
+ const CODE_GROUP_LENGTH = 5;
18
+ const CODE_GROUPS = 3;
19
+
20
+ const ROOM_ID_CONTEXT = "tracegist-room-v1:";
21
+ const HKDF_SALT = "tracegist-live-v1";
22
+ const HKDF_INFO = "aes-256-gcm";
23
+
24
+ export const FRAME_VERSION = 1;
25
+
26
+ /** AES-GCM IV length in bytes (protocol constant, see docs/live-protocol.md) */
27
+ const GCM_IV_BYTES = 12;
28
+
29
+ /**
30
+ * Normalize a user-entered session code: trim, uppercase.
31
+ * @param {string} raw
32
+ * @returns {string | null} normalized code, or null if malformed
33
+ */
34
+ export function normalizeSessionCode(raw) {
35
+ const code = String(raw).trim().toUpperCase();
36
+ const groupPattern = `[${CODE_ALPHABET}]{${CODE_GROUP_LENGTH}}`;
37
+ const pattern = new RegExp(`^TG(-${groupPattern}){${CODE_GROUPS}}$`);
38
+ return pattern.test(code) ? code : null;
39
+ }
40
+
41
+ /**
42
+ * Derive the relay room ID (hex SHA-256) from a normalized session code.
43
+ * @param {string} code
44
+ * @returns {Promise<string>}
45
+ */
46
+ export async function deriveRoomId(code) {
47
+ const data = new TextEncoder().encode(ROOM_ID_CONTEXT + code);
48
+ const digest = await subtle.digest("SHA-256", data);
49
+ return Buffer.from(digest).toString("hex");
50
+ }
51
+
52
+ /**
53
+ * Derive the AES-256-GCM session key from a normalized session code.
54
+ * @param {string} code
55
+ * @returns {Promise<CryptoKey>}
56
+ */
57
+ export async function deriveSessionKey(code) {
58
+ const encoder = new TextEncoder();
59
+ const ikm = await subtle.importKey("raw", encoder.encode(code), "HKDF", false, ["deriveKey"]);
60
+ return subtle.deriveKey(
61
+ {
62
+ name: "HKDF",
63
+ hash: "SHA-256",
64
+ salt: encoder.encode(HKDF_SALT),
65
+ info: encoder.encode(HKDF_INFO),
66
+ },
67
+ ikm,
68
+ { name: "AES-GCM", length: 256 },
69
+ false,
70
+ ["encrypt", "decrypt"],
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Encrypt a live-shadow message object into a frame payload.
76
+ * @param {CryptoKey} key
77
+ * @param {object} message
78
+ * @returns {Promise<{v: number, iv: string, ct: string}>}
79
+ */
80
+ export async function encryptFrame(key, message) {
81
+ const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
82
+ const plaintext = new TextEncoder().encode(JSON.stringify(message));
83
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
84
+ return {
85
+ v: FRAME_VERSION,
86
+ iv: Buffer.from(iv).toString("base64"),
87
+ ct: Buffer.from(ct).toString("base64"),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Decrypt a frame payload back into a message object.
93
+ * Returns null for unknown versions, malformed payloads, or failed
94
+ * authentication — a tampered or foreign frame must not crash the session.
95
+ * @param {CryptoKey} key
96
+ * @param {{v: number, iv: string, ct: string}} payload
97
+ * @returns {Promise<object | null>}
98
+ */
99
+ export async function decryptFrame(key, payload) {
100
+ if (!payload || payload.v !== FRAME_VERSION) return null;
101
+ try {
102
+ const iv = Buffer.from(payload.iv, "base64");
103
+ const ct = Buffer.from(payload.ct, "base64");
104
+ const plaintext = await subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
105
+ return JSON.parse(new TextDecoder().decode(plaintext));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Frame chunking — mirror of lib/remote-session.ts (see that file for the
113
+ // size rationale). Hosted relays cap WebSocket messages (Cloudflare: 1 MiB);
114
+ // oversized inner messages are split into `frame-chunk` parts BEFORE
115
+ // encryption so relays only ever see ordinary frames.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Inner messages whose UTF-8 size exceeds this are chunked. */
119
+ export const FRAME_CHUNK_THRESHOLD_BYTES = 700_000;
120
+ /** UTF-8 bytes per chunk. */
121
+ export const FRAME_CHUNK_BYTES = 500_000;
122
+
123
+ /**
124
+ * Split an oversized inner message into `frame-chunk` parts. Returns null
125
+ * when the message fits in a single frame. Operates on UTF-8 bytes so
126
+ * multi-byte characters and surrogate pairs can never be split.
127
+ * @param {object} message
128
+ * @returns {Array<{type: "frame-chunk", id: string, seq: number, total: number, data: string}> | null}
129
+ */
130
+ export function chunkInnerMessage(message) {
131
+ const bytes = new TextEncoder().encode(JSON.stringify(message));
132
+ if (bytes.length <= FRAME_CHUNK_THRESHOLD_BYTES) return null;
133
+ const total = Math.ceil(bytes.length / FRAME_CHUNK_BYTES);
134
+ const id = webcrypto.randomUUID();
135
+ const parts = [];
136
+ for (let seq = 0; seq < total; seq++) {
137
+ parts.push({
138
+ type: "frame-chunk",
139
+ id,
140
+ seq,
141
+ total,
142
+ data: Buffer.from(
143
+ bytes.subarray(seq * FRAME_CHUNK_BYTES, (seq + 1) * FRAME_CHUNK_BYTES),
144
+ ).toString("base64"),
145
+ });
146
+ }
147
+ return parts;
148
+ }
149
+
150
+ /**
151
+ * Reassembles decrypted `frame-chunk` parts back into the original message.
152
+ * Non-chunk messages pass through unchanged; chunk parts return null until
153
+ * the final part completes the message. A part for a new id discards any
154
+ * incomplete assembly (peer reconnect mid-message).
155
+ */
156
+ export class FrameChunkAssembler {
157
+ constructor() {
158
+ this.id = null;
159
+ this.parts = [];
160
+ this.received = 0;
161
+ }
162
+
163
+ /**
164
+ * @param {object} inner - a decrypted inner message
165
+ * @returns {object | null} pass-through message, completed reassembly, or null
166
+ */
167
+ push(inner) {
168
+ if (inner.type !== "frame-chunk") return inner;
169
+
170
+ const { id, seq, total, data } = inner;
171
+ if (
172
+ typeof id !== "string" ||
173
+ typeof data !== "string" ||
174
+ !Number.isInteger(seq) ||
175
+ !Number.isInteger(total) ||
176
+ total < 1 ||
177
+ seq < 0 ||
178
+ seq >= total
179
+ ) {
180
+ return null;
181
+ }
182
+
183
+ if (this.id !== id) {
184
+ this.id = id;
185
+ this.parts = new Array(total);
186
+ this.received = 0;
187
+ }
188
+
189
+ if (this.parts.length !== total || this.parts[seq] !== undefined) {
190
+ this.reset();
191
+ return null;
192
+ }
193
+
194
+ this.parts[seq] = data;
195
+ this.received++;
196
+ if (this.received < total) return null;
197
+
198
+ const assembled = this.parts;
199
+ this.reset();
200
+ try {
201
+ const bytes = Buffer.concat(assembled.map((p) => Buffer.from(p, "base64")));
202
+ return JSON.parse(new TextDecoder().decode(bytes));
203
+ } catch {
204
+ // Corrupt part payload — same posture as decryptFrame: drop, don't crash
205
+ return null;
206
+ }
207
+ }
208
+
209
+ reset() {
210
+ this.id = null;
211
+ this.parts = [];
212
+ this.received = 0;
213
+ }
214
+ }
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import crypto from "node:crypto";
7
7
  import { createServer } from "node:http";
8
- import { WebSocketServer } from "ws";
8
+ import { WebSocketServer, WebSocket } from "ws";
9
9
  import JSZip from "jszip";
10
10
  import { z } from "zod";
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -26,6 +26,15 @@ import {
26
26
  isAllowedLiveShadowOrigin,
27
27
  envFlagEnabled,
28
28
  } from "./lib.mjs";
29
+ import {
30
+ normalizeSessionCode,
31
+ deriveRoomId,
32
+ deriveSessionKey,
33
+ encryptFrame,
34
+ decryptFrame,
35
+ chunkInnerMessage,
36
+ FrameChunkAssembler,
37
+ } from "./live-crypto.mjs";
29
38
 
30
39
  const BRIDGE_NAME = "tracegist-mcp-bridge";
31
40
  const { version: BRIDGE_VERSION } = JSON.parse(
@@ -1073,9 +1082,16 @@ server.registerTool(
1073
1082
  // Live Shadowing — WebSocket server + state
1074
1083
  // ---------------------------------------------------------------------------
1075
1084
 
1085
+ const liveShadowDisabled = envFlagEnabled(process.env.TRACEGIST_DISABLE_LIVE);
1086
+
1076
1087
  const LIVE_SHADOW_PORT = 19384;
1077
1088
  const LIVE_SHADOW_PORT_RANGE = 5;
1078
1089
  const LIVE_SHADOW_EVENT_BUFFER_SIZE = 2000;
1090
+
1091
+ /** Default relay for Remote Live Sessions. Override via TRACEGIST_RELAY_URL. */
1092
+ const DEFAULT_RELAY_URL = "wss://relay.tracegist.com";
1093
+ const RELAY_RECONNECT_INTERVAL_MS = 3000;
1094
+ const RELAY_MAX_RECONNECT_ATTEMPTS = 10;
1079
1095
  const PENDING_QUESTIONS_MAX = 50;
1080
1096
  const WATCH_LIVE_DEFAULT_LIMIT = 100;
1081
1097
  const WATCH_LIVE_MAX_LIMIT = 500;
@@ -1084,6 +1100,8 @@ const WATCH_LIVE_MAX_LIMIT = 500;
1084
1100
  const liveEvents = [];
1085
1101
  let liveEventSeq = 0;
1086
1102
  let liveSessionActive = false;
1103
+ /** Which transport owns the current session: "local" | "relay" | null. */
1104
+ let liveSessionTransport = null;
1087
1105
  /** @type {{sessionId: string, url: string, title: string, startedAt: number} | null} */
1088
1106
  let liveSessionMeta = null;
1089
1107
  /** @type {import("ws").WebSocket | null} */
@@ -1119,12 +1137,14 @@ async function transcribeLiveVoice(voiceBlobDataUrl) {
1119
1137
  }
1120
1138
 
1121
1139
  /** Push a live event into the ring buffer with auto-incrementing seq.
1122
- * Seq is always higher than any existing event (including extension-originated ones). */
1123
- function pushLiveEvent(eventType, data) {
1140
+ * All events are re-sequenced with the bridge's own monotonic counter
1141
+ * extension-provided seq numbers restart per session and would collide with
1142
+ * bridge-originated events, breaking the watch_live_session since_seq cursor. */
1143
+ function pushLiveEvent(eventType, data, ts = Date.now()) {
1124
1144
  // Ensure our seq is always higher than the max seq in the buffer
1125
1145
  const maxExistingSeq = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1].seq : 0;
1126
1146
  liveEventSeq = Math.max(liveEventSeq, maxExistingSeq) + 1;
1127
- liveEvents.push({ seq: liveEventSeq, ts: Date.now(), eventType, data });
1147
+ liveEvents.push({ seq: liveEventSeq, ts, eventType, data });
1128
1148
  if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
1129
1149
  const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
1130
1150
  liveEvents.splice(0, evictCount);
@@ -1170,7 +1190,7 @@ function startLiveShadowServer() {
1170
1190
  ws.on("message", (raw) => {
1171
1191
  try {
1172
1192
  const msg = JSON.parse(String(raw));
1173
- handleExtensionMessage(msg);
1193
+ handleExtensionMessage(msg, "local");
1174
1194
  } catch (err) {
1175
1195
  console.error(`[${BRIDGE_NAME}] Live shadow: invalid message:`, err);
1176
1196
  }
@@ -1179,7 +1199,7 @@ function startLiveShadowServer() {
1179
1199
  ws.on("close", () => {
1180
1200
  if (activeConnection === ws) {
1181
1201
  activeConnection = null;
1182
- if (liveSessionActive) {
1202
+ if (liveSessionActive && liveSessionTransport === "local") {
1183
1203
  liveSessionActive = false;
1184
1204
  console.error(`[${BRIDGE_NAME}] Live shadow: session ended (extension disconnected)`);
1185
1205
  try {
@@ -1215,22 +1235,33 @@ function startLiveShadowServer() {
1215
1235
  tryPort(LIVE_SHADOW_PORT);
1216
1236
  }
1217
1237
 
1218
- function handleExtensionMessage(msg) {
1238
+ function handleExtensionMessage(msg, source = "local") {
1219
1239
  switch (msg.type) {
1220
- case "session-start":
1240
+ case "session-start": {
1241
+ // The extension re-announces the session whenever the relay peer
1242
+ // (re)connects. A re-announce of the SAME session must not wipe
1243
+ // buffered events or un-retrieved question responses.
1244
+ const isReannounce = liveSessionActive && liveSessionMeta?.sessionId === msg.sessionId;
1221
1245
  liveSessionActive = true;
1246
+ liveSessionTransport = source;
1222
1247
  liveSessionMeta = {
1223
1248
  sessionId: msg.sessionId,
1224
1249
  url: msg.url,
1225
1250
  title: msg.title,
1226
- startedAt: Date.now(),
1251
+ startedAt: isReannounce ? liveSessionMeta.startedAt : Date.now(),
1227
1252
  };
1228
- liveEvents.length = 0;
1229
- liveEventSeq = 0;
1230
- pendingQuestions.length = 0;
1231
- questionResponses.clear();
1232
- liveInteractions.length = 0;
1233
- console.error(`[${BRIDGE_NAME}] Live shadow: session started (${msg.sessionId})`);
1253
+ if (!isReannounce) {
1254
+ liveEvents.length = 0;
1255
+ // liveEventSeq intentionally NOT reset: the watch_live_session cursor
1256
+ // stays monotonic across sessions, so a stale since_seq from a previous
1257
+ // session can never silently filter out new events.
1258
+ pendingQuestions.length = 0;
1259
+ questionResponses.clear();
1260
+ liveInteractions.length = 0;
1261
+ }
1262
+ console.error(
1263
+ `[${BRIDGE_NAME}] Live shadow: session ${isReannounce ? "re-announced" : "started"} (${msg.sessionId}, ${source})`,
1264
+ );
1234
1265
  // Notify MCP clients that resources changed
1235
1266
  try {
1236
1267
  server.sendResourceListChanged();
@@ -1238,19 +1269,13 @@ function handleExtensionMessage(msg) {
1238
1269
  // Not all transports support notifications
1239
1270
  }
1240
1271
  break;
1272
+ }
1241
1273
 
1242
1274
  case "event":
1243
- liveEvents.push({
1244
- seq: msg.seq,
1245
- ts: msg.ts,
1246
- eventType: msg.eventType,
1247
- data: msg.data,
1248
- });
1249
- if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
1250
- // Batch-evict oldest 25% to amortize the O(n) splice cost
1251
- const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
1252
- liveEvents.splice(0, evictCount);
1253
- }
1275
+ // Re-sequence with the bridge's monotonic counter (see pushLiveEvent);
1276
+ // the extension's own seq restarts per session and would collide with
1277
+ // bridge-originated events.
1278
+ pushLiveEvent(msg.eventType, msg.data, msg.ts);
1254
1279
  break;
1255
1280
 
1256
1281
  case "question-response": {
@@ -1374,6 +1399,9 @@ function handleExtensionMessage(msg) {
1374
1399
  }
1375
1400
 
1376
1401
  case "session-end":
1402
+ // A session-end from the transport that doesn't own the session must
1403
+ // not tear down the owning transport's session.
1404
+ if (liveSessionTransport && source !== liveSessionTransport) break;
1377
1405
  liveSessionActive = false;
1378
1406
  console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
1379
1407
  try {
@@ -1385,6 +1413,265 @@ function handleExtensionMessage(msg) {
1385
1413
  }
1386
1414
  }
1387
1415
 
1416
+ // ---------------------------------------------------------------------------
1417
+ // Remote Live Sessions (relay transport)
1418
+ //
1419
+ // The extension and this bridge meet at a relay and exchange the same
1420
+ // live-shadow messages as the local transport, AES-256-GCM encrypted with a
1421
+ // key derived from the session code (see docs/live-protocol.md). The relay
1422
+ // only ever sees ciphertext.
1423
+ // ---------------------------------------------------------------------------
1424
+
1425
+ /** @type {WebSocket | null} */
1426
+ let relayWs = null;
1427
+ /** @type {CryptoKey | null} */
1428
+ let relaySessionKey = null;
1429
+ let relayRoomId = null;
1430
+ let relayUrl = null;
1431
+ let relayPeerPresent = false;
1432
+ let relayIntentionalClose = false;
1433
+ let relayReconnectAttempts = 0;
1434
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
1435
+ let relayReconnectTimer;
1436
+
1437
+ function isRelayConnected() {
1438
+ return relayWs !== null && relayWs.readyState === WebSocket.OPEN;
1439
+ }
1440
+
1441
+ /** True when the extension that owns the current session is reachable. */
1442
+ function isExtensionLinked() {
1443
+ // Route by session owner so a locally-connected extension can never hijack
1444
+ // a remote session (or vice versa).
1445
+ if (liveSessionTransport === "relay") return isRelayConnected() && relayPeerPresent;
1446
+ if (liveSessionTransport === "local") return activeConnection !== null;
1447
+ return activeConnection !== null || (isRelayConnected() && relayPeerPresent);
1448
+ }
1449
+
1450
+ /** Serializes relay sends so chunk ids can never interleave on the wire —
1451
+ * the receiver's single-slot reassembler relies on it (mirrors the
1452
+ * extension's sendChain in lib/live-shadow.ts). */
1453
+ let relaySendChain = Promise.resolve();
1454
+
1455
+ /**
1456
+ * Send a live-shadow message to the extension that owns the current session:
1457
+ * the local WebSocket (plaintext, localhost-only) or the relay (E2E
1458
+ * encrypted). Throws if the owning transport is unavailable.
1459
+ * @param {object} msg
1460
+ */
1461
+ async function sendToExtension(msg) {
1462
+ const canRelay = isRelayConnected() && relaySessionKey !== null;
1463
+ const useRelay = canRelay && (liveSessionTransport === "relay" || activeConnection === null);
1464
+ if (useRelay) {
1465
+ // Hosted relays cap WebSocket messages (Cloudflare: 1 MiB); oversized
1466
+ // messages travel as encrypted frame-chunk parts (see live-crypto.mjs).
1467
+ const send = relaySendChain.then(async () => {
1468
+ // Re-check at execution time: the socket may have closed while an
1469
+ // earlier message in the chain was encrypting.
1470
+ if (!isRelayConnected() || relaySessionKey === null) {
1471
+ throw new Error("Extension not connected (relay closed while sending)");
1472
+ }
1473
+ const parts = chunkInnerMessage(msg) ?? [msg];
1474
+ for (const part of parts) {
1475
+ const payload = await encryptFrame(relaySessionKey, part);
1476
+ relayWs.send(JSON.stringify({ type: "frame", payload }));
1477
+ }
1478
+ });
1479
+ // One failed send must not poison the chain for later messages.
1480
+ relaySendChain = send.catch(() => {});
1481
+ return send;
1482
+ }
1483
+ if (activeConnection && liveSessionTransport !== "relay") {
1484
+ activeConnection.send(JSON.stringify(msg));
1485
+ return;
1486
+ }
1487
+ throw new Error("Extension not connected (no local or relay transport)");
1488
+ }
1489
+
1490
+ function disconnectRelay() {
1491
+ relayIntentionalClose = true;
1492
+ if (relayReconnectTimer) {
1493
+ clearTimeout(relayReconnectTimer);
1494
+ relayReconnectTimer = undefined;
1495
+ }
1496
+ if (relayWs && relayWs.readyState <= WebSocket.OPEN) {
1497
+ try {
1498
+ relayWs.close();
1499
+ } catch {
1500
+ // ignore
1501
+ }
1502
+ }
1503
+ relayWs = null;
1504
+ relaySessionKey = null;
1505
+ relayRoomId = null;
1506
+ relayPeerPresent = false;
1507
+ relayReconnectAttempts = 0;
1508
+ }
1509
+
1510
+ /**
1511
+ * Connect to the relay and join the session's room as the agent.
1512
+ * Resolves once the join is acknowledged.
1513
+ * @returns {Promise<{peerPresent: boolean}>}
1514
+ */
1515
+ function connectRelay() {
1516
+ return new Promise((resolve, reject) => {
1517
+ let settled = false;
1518
+ // Hosted relays (Cloudflare Worker) route the upgrade to a per-room
1519
+ // Durable Object, so the room ID must ride on the URL; the npm relay
1520
+ // ignores the query string. The room ID is a hash — not the secret.
1521
+ let wsUrl;
1522
+ try {
1523
+ const parsed = new URL(relayUrl);
1524
+ parsed.searchParams.set("room", relayRoomId);
1525
+ wsUrl = parsed.toString();
1526
+ } catch {
1527
+ reject(new Error(`Invalid relay URL: ${relayUrl}`));
1528
+ return;
1529
+ }
1530
+ const ws = new WebSocket(wsUrl);
1531
+ relayWs = ws;
1532
+
1533
+ const settle = (fn, arg) => {
1534
+ if (!settled) {
1535
+ settled = true;
1536
+ fn(arg);
1537
+ }
1538
+ };
1539
+
1540
+ ws.on("open", () => {
1541
+ relayReconnectAttempts = 0;
1542
+ ws.send(JSON.stringify({ type: "join", room: relayRoomId, role: "agent" }));
1543
+ });
1544
+
1545
+ // Frames are decrypted asynchronously; chain them so slow decryption of
1546
+ // one frame can never reorder events behind a faster later frame (the
1547
+ // live event buffer assumes ascending seq).
1548
+ let frameChain = Promise.resolve();
1549
+ // Per-connection: a new socket can never continue a previous reassembly.
1550
+ const chunkAssembler = new FrameChunkAssembler();
1551
+
1552
+ ws.on("message", (raw) => {
1553
+ // A superseded socket must not mutate current-connection state.
1554
+ if (relayWs !== ws) return;
1555
+ let msg;
1556
+ try {
1557
+ msg = JSON.parse(String(raw));
1558
+ } catch {
1559
+ return;
1560
+ }
1561
+
1562
+ switch (msg.type) {
1563
+ case "joined":
1564
+ relayPeerPresent = !!msg.peerPresent;
1565
+ console.error(
1566
+ `[${BRIDGE_NAME}] Relay: joined room (tester ${relayPeerPresent ? "present" : "not connected yet"})`,
1567
+ );
1568
+ settle(resolve, { peerPresent: relayPeerPresent });
1569
+ break;
1570
+
1571
+ case "peer-joined":
1572
+ relayPeerPresent = true;
1573
+ pushLiveEvent("tester-connected", {
1574
+ hint: "The tester's extension joined the remote session.",
1575
+ });
1576
+ break;
1577
+
1578
+ case "peer-left":
1579
+ relayPeerPresent = false;
1580
+ if (liveSessionActive) {
1581
+ liveSessionActive = false;
1582
+ console.error(`[${BRIDGE_NAME}] Relay: session ended (tester disconnected)`);
1583
+ try {
1584
+ server.sendResourceListChanged();
1585
+ } catch {
1586
+ // Not all transports support notifications
1587
+ }
1588
+ }
1589
+ pushLiveEvent("tester-disconnected", {
1590
+ hint: "The tester's extension left the remote session.",
1591
+ });
1592
+ break;
1593
+
1594
+ case "frame": {
1595
+ const payload = msg.payload;
1596
+ frameChain = frameChain
1597
+ .then(async () => {
1598
+ const inner = await decryptFrame(relaySessionKey, payload);
1599
+ if (inner === null) {
1600
+ console.error(`[${BRIDGE_NAME}] Relay: dropped undecryptable frame`);
1601
+ return;
1602
+ }
1603
+ // Chunk parts return null until the final part completes.
1604
+ const complete = chunkAssembler.push(inner);
1605
+ if (complete !== null) {
1606
+ handleExtensionMessage(complete, "relay");
1607
+ }
1608
+ })
1609
+ .catch((err) => {
1610
+ // One bad frame must not poison the chain and freeze the session
1611
+ console.error(`[${BRIDGE_NAME}] Relay: frame handling failed:`, err);
1612
+ });
1613
+ break;
1614
+ }
1615
+
1616
+ case "error":
1617
+ console.error(`[${BRIDGE_NAME}] Relay error: ${msg.code} — ${msg.message}`);
1618
+ settle(reject, new Error(`Relay error: ${msg.code}`));
1619
+ break;
1620
+ }
1621
+ });
1622
+
1623
+ ws.on("close", () => {
1624
+ settle(reject, new Error("Relay connection closed before join completed"));
1625
+ // A superseded socket's late close event must not clobber the state of
1626
+ // the current connection or arm a spurious reconnect for the new room.
1627
+ if (relayWs !== ws) return;
1628
+ relayWs = null;
1629
+ relayPeerPresent = false;
1630
+ if (!relayIntentionalClose && relayRoomId) {
1631
+ scheduleRelayReconnect();
1632
+ }
1633
+ });
1634
+
1635
+ ws.on("error", (err) => {
1636
+ console.error(`[${BRIDGE_NAME}] Relay connection error: ${err.message}`);
1637
+ settle(reject, err);
1638
+ // close fires after error; reconnect handled there
1639
+ });
1640
+ });
1641
+ }
1642
+
1643
+ function scheduleRelayReconnect() {
1644
+ if (relayReconnectAttempts >= RELAY_MAX_RECONNECT_ATTEMPTS) {
1645
+ console.error(
1646
+ `[${BRIDGE_NAME}] Relay: giving up after ${RELAY_MAX_RECONNECT_ATTEMPTS} reconnect attempts`,
1647
+ );
1648
+ // The relay-owned session is unreachable for good — end it so polling
1649
+ // agents see sessionActive:false instead of a permanently dead session.
1650
+ if (liveSessionActive && liveSessionTransport === "relay") {
1651
+ liveSessionActive = false;
1652
+ pushLiveEvent("relay-connection-lost", {
1653
+ hint: "Relay connection lost and reconnection failed; the remote session has ended. Use join_live_session to reconnect.",
1654
+ });
1655
+ try {
1656
+ server.sendResourceListChanged();
1657
+ } catch {
1658
+ // Not all transports support notifications
1659
+ }
1660
+ }
1661
+ return;
1662
+ }
1663
+ relayReconnectAttempts++;
1664
+ const delay = RELAY_RECONNECT_INTERVAL_MS * Math.min(relayReconnectAttempts, 4);
1665
+ relayReconnectTimer = setTimeout(() => {
1666
+ relayReconnectTimer = undefined;
1667
+ if (!relayIntentionalClose && relayRoomId) {
1668
+ connectRelay().catch(() => {
1669
+ // scheduleRelayReconnect is re-armed by the close handler
1670
+ });
1671
+ }
1672
+ }, delay);
1673
+ }
1674
+
1388
1675
  // Register live shadowing MCP resource
1389
1676
  server.resource(
1390
1677
  "live-session-status",
@@ -1400,6 +1687,10 @@ server.resource(
1400
1687
  session: liveSessionMeta,
1401
1688
  eventCount: liveEvents.length,
1402
1689
  pendingQuestions: pendingQuestions.length,
1690
+ transport: activeConnection ? "local" : isRelayConnected() ? "relay" : "none",
1691
+ remote: relayRoomId
1692
+ ? { relayUrl, connected: isRelayConnected(), testerPresent: relayPeerPresent }
1693
+ : null,
1403
1694
  },
1404
1695
  null,
1405
1696
  2,
@@ -1455,6 +1746,31 @@ server.registerTool(
1455
1746
  },
1456
1747
  async ({ since_seq, limit = WATCH_LIVE_DEFAULT_LIMIT }) => {
1457
1748
  if (!liveSessionActive && liveEvents.length === 0) {
1749
+ // Joined a remote room but the tester hasn't started yet — this is the
1750
+ // documented flow after join_live_session, not an error.
1751
+ if (relayRoomId) {
1752
+ return {
1753
+ content: [
1754
+ {
1755
+ type: "text",
1756
+ text: JSON.stringify(
1757
+ {
1758
+ sessionActive: false,
1759
+ waitingForTester: !relayPeerPresent,
1760
+ events: [],
1761
+ nextSeq: since_seq ?? 0,
1762
+ hasMore: false,
1763
+ note: relayPeerPresent
1764
+ ? "Tester is connected but has not started recording. Keep polling — a 'session-start' will arrive when they do."
1765
+ : "Joined the remote session room; waiting for the tester to connect. Keep polling — a 'tester-connected' event will appear when they join.",
1766
+ },
1767
+ null,
1768
+ 2,
1769
+ ),
1770
+ },
1771
+ ],
1772
+ };
1773
+ }
1458
1774
  return toolError(
1459
1775
  "No active live shadowing session. Ensure the TraceGist extension has Live Shadowing enabled and is recording.",
1460
1776
  );
@@ -1592,19 +1908,17 @@ server.registerTool(
1592
1908
  }),
1593
1909
  },
1594
1910
  async ({ question }) => {
1595
- if (!liveSessionActive || !activeConnection) {
1911
+ if (!liveSessionActive || !isExtensionLinked()) {
1596
1912
  return toolError("No active live shadowing session or extension not connected.");
1597
1913
  }
1598
1914
 
1599
1915
  const questionId = crypto.randomUUID();
1600
1916
  try {
1601
- activeConnection.send(
1602
- JSON.stringify({
1603
- type: "agent-question",
1604
- questionId,
1605
- question,
1606
- }),
1607
- );
1917
+ await sendToExtension({
1918
+ type: "agent-question",
1919
+ questionId,
1920
+ question,
1921
+ });
1608
1922
  } catch (err) {
1609
1923
  return toolError(`Failed to send question to extension: ${err}`);
1610
1924
  }
@@ -1799,7 +2113,7 @@ server.registerTool(
1799
2113
  inputSchema: z.object({}),
1800
2114
  },
1801
2115
  async () => {
1802
- if (!liveSessionActive || !activeConnection) {
2116
+ if (!liveSessionActive || !isExtensionLinked()) {
1803
2117
  return toolError("No active live shadowing session or extension not connected.");
1804
2118
  }
1805
2119
 
@@ -1812,12 +2126,17 @@ server.registerTool(
1812
2126
  }, 5000);
1813
2127
  screenshotWaiters.push({ resolve, reject, timeout });
1814
2128
  });
2129
+ // If the send below fails, the abandoned promise's timeout would reject
2130
+ // with nobody awaiting — an unhandled rejection that kills the process.
2131
+ screenshotPromise.catch(() => {});
1815
2132
 
1816
2133
  try {
1817
- activeConnection.send(JSON.stringify({ type: "request-screenshot" }));
2134
+ await sendToExtension({ type: "request-screenshot" });
1818
2135
  } catch (err) {
1819
- // Clean up waiter
1820
- screenshotWaiters.splice(0);
2136
+ // Clean up waiters, including their timeout rejections
2137
+ for (const waiter of screenshotWaiters.splice(0)) {
2138
+ clearTimeout(waiter.timeout);
2139
+ }
1821
2140
  return toolError(`Failed to request screenshot: ${err}`);
1822
2141
  }
1823
2142
 
@@ -1847,6 +2166,118 @@ server.registerTool(
1847
2166
  },
1848
2167
  );
1849
2168
 
2169
+ // Tool: join_live_session
2170
+ server.registerTool(
2171
+ "join_live_session",
2172
+ {
2173
+ title: "Join Remote Live Session",
2174
+ description:
2175
+ "Join a Remote Live Session using the session code shown in the tester's TraceGist " +
2176
+ "extension (format: TG-XXXXX-XXXXX-XXXXX). Connects to the relay and links this bridge " +
2177
+ "to the tester's browser — all live tools (watch_live_session, ask_tester_question, " +
2178
+ "get_tester_response, get_live_screenshot) then work exactly as they do locally. " +
2179
+ "Traffic is end-to-end encrypted with a key derived from the code; the relay never " +
2180
+ "sees session data.",
2181
+ annotations: {
2182
+ readOnlyHint: false,
2183
+ destructiveHint: false,
2184
+ idempotentHint: false,
2185
+ openWorldHint: true,
2186
+ },
2187
+ inputSchema: z.object({
2188
+ code: z
2189
+ .string()
2190
+ .describe(
2191
+ "The session code from the tester, e.g. TG-7K3MP-9XWQA-2BCDE (case-insensitive).",
2192
+ ),
2193
+ }),
2194
+ },
2195
+ async ({ code }) => {
2196
+ if (liveShadowDisabled) {
2197
+ return toolError("Live shadowing is disabled via TRACEGIST_DISABLE_LIVE.");
2198
+ }
2199
+
2200
+ const normalized = normalizeSessionCode(code);
2201
+ if (!normalized) {
2202
+ return toolError(
2203
+ "Invalid session code. Expected format: TG-XXXXX-XXXXX-XXXXX (letters/digits, no I/L/O/U).",
2204
+ );
2205
+ }
2206
+
2207
+ // Replace any previous remote session
2208
+ if (relayWs || relayRoomId) {
2209
+ disconnectRelay();
2210
+ }
2211
+
2212
+ relayIntentionalClose = false;
2213
+ relayUrl = process.env.TRACEGIST_RELAY_URL || DEFAULT_RELAY_URL;
2214
+ relayRoomId = await deriveRoomId(normalized);
2215
+ relaySessionKey = await deriveSessionKey(normalized);
2216
+
2217
+ try {
2218
+ const { peerPresent } = await connectRelay();
2219
+ return {
2220
+ content: [
2221
+ {
2222
+ type: "text",
2223
+ text: [
2224
+ `Joined remote live session via ${relayUrl}.`,
2225
+ peerPresent
2226
+ ? "The tester is connected. Once they start recording you will see a session-start; poll watch_live_session for events."
2227
+ : "Waiting for the tester to connect — ask them to start their remote session in the TraceGist extension. Poll watch_live_session; a 'tester-connected' event will appear when they join.",
2228
+ "",
2229
+ "All live tools now work over this connection: watch_live_session, ask_tester_question, get_tester_response, get_live_screenshot, get_live_session_summary.",
2230
+ "Use leave_live_session to disconnect when done.",
2231
+ ].join("\n"),
2232
+ },
2233
+ ],
2234
+ };
2235
+ } catch (err) {
2236
+ disconnectRelay();
2237
+ return toolError(
2238
+ `Could not join the remote session: ${err instanceof Error ? err.message : err}. ` +
2239
+ `Check the relay URL (${relayUrl}) and that the code is current.`,
2240
+ );
2241
+ }
2242
+ },
2243
+ );
2244
+
2245
+ // Tool: leave_live_session
2246
+ server.registerTool(
2247
+ "leave_live_session",
2248
+ {
2249
+ title: "Leave Remote Live Session",
2250
+ description:
2251
+ "Disconnect from the current Remote Live Session (relay connection). " +
2252
+ "Local live shadowing on this machine is unaffected.",
2253
+ annotations: {
2254
+ readOnlyHint: false,
2255
+ destructiveHint: false,
2256
+ idempotentHint: true,
2257
+ openWorldHint: false,
2258
+ },
2259
+ inputSchema: z.object({}),
2260
+ },
2261
+ async () => {
2262
+ const wasConnected = relayRoomId !== null;
2263
+ disconnectRelay();
2264
+ if (liveSessionActive && liveSessionTransport === "relay") {
2265
+ liveSessionActive = false;
2266
+ liveSessionTransport = null;
2267
+ }
2268
+ return {
2269
+ content: [
2270
+ {
2271
+ type: "text",
2272
+ text: wasConnected
2273
+ ? "Left the remote live session."
2274
+ : "No remote live session was active.",
2275
+ },
2276
+ ],
2277
+ };
2278
+ },
2279
+ );
2280
+
1850
2281
  // Tool: get_live_session_summary
1851
2282
  server.registerTool(
1852
2283
  "get_live_session_summary",
@@ -1921,10 +2352,10 @@ console.error(
1921
2352
  // Inspector) and live shadowing is not needed for the current task.
1922
2353
  // Accepts the conventional boolean-ish values: 1/true/yes/on disable;
1923
2354
  // 0/false/no/off (and empty/unset) leave it enabled.
1924
- if (envFlagEnabled(process.env.TRACEGIST_DISABLE_LIVE)) {
2355
+ if (liveShadowDisabled) {
1925
2356
  console.error(
1926
2357
  `[${BRIDGE_NAME}] Live shadow disabled via TRACEGIST_DISABLE_LIVE — ` +
1927
- "watch_live_session / ask_tester_question / get_tester_response / get_live_screenshot will be unavailable.",
2358
+ "watch_live_session / ask_tester_question / get_tester_response / get_live_screenshot / join_live_session will be unavailable.",
1928
2359
  );
1929
2360
  } else {
1930
2361
  startLiveShadowServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracegist-mcp-bridge",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first MCP bridge for reading and transcribing TraceGist package zips.",
5
5
  "type": "module",
6
6
  "bin": {