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 +25 -0
- package/bin/live-crypto.mjs +214 -0
- package/bin/tracegist-mcp-bridge.mjs +471 -40
- package/package.json +1 -1
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
|
-
*
|
|
1123
|
-
|
|
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
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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 || !
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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 || !
|
|
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
|
-
|
|
2134
|
+
await sendToExtension({ type: "request-screenshot" });
|
|
1818
2135
|
} catch (err) {
|
|
1819
|
-
// Clean up
|
|
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 (
|
|
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();
|