tracegist-mcp-bridge 0.4.0 → 1.0.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,69 @@
2
2
 
3
3
  All notable changes to `tracegist-mcp-bridge`.
4
4
 
5
+ ## 1.0.0
6
+
7
+ > ⚠️ **Breaking Remote Live Session protocol change** (`FRAME_VERSION` → 3):
8
+ > a 1.0.0 bridge will NOT interoperate with an older extension, and vice
9
+ > versa. It ships **in lockstep** with the TraceGist extension v1.0.0 release —
10
+ > update both together. Includes everything from the (unpublished) 0.5.0 below.
11
+
12
+ Hardens the E2E encryption per the security audit (findings F1–F3) plus a
13
+ per-connection ephemeral-key handshake:
14
+
15
+ - **Per-connection handshake (fixes the CRITICAL reconnect/replay flaw).** Data
16
+ frames are now encrypted under a _connection key_, not the code key. On every
17
+ (re)connection the tester sends a fresh nonce (tagged with a monotonic
18
+ wall-clock epoch); the agent replies with its own nonce; both HKDF a connection
19
+ key from the code secret + both nonces + epoch and reset their counter. Because
20
+ each connection derives a distinct key: (a) a routine single-sided reconnect
21
+ re-handshakes and resynchronizes both sides (no more "post-reconnect frames
22
+ rejected as replays" brick), and (b) a malicious relay replaying a previous
23
+ connection's frames fails to decrypt under the new key. The agent is idempotent
24
+ per epoch and the tester retransmits until it gets a reply (with a bounded
25
+ timeout that ends the session), so a dropped handshake nonce recovers.
26
+ - **Anti-replay / anti-reflection (F1).** Every data frame authenticates a
27
+ per-direction monotonic counter + sender role via AES-GCM AAD; a `ReplayGuard`
28
+ rejects non-increasing counters (in-connection replay/reorder), and the
29
+ per-connection key rejects cross-connection replay.
30
+ - **Slow KDF (F2).** The code secret is PBKDF2-SHA256 (210k iterations); the
31
+ handshake and connection keys are HKDF'd from it. The relay-visible room ID
32
+ stays a fast, domain-separated hash.
33
+ - **Chunk-count cap (F3).** `FrameChunkAssembler` rejects a part count above
34
+ `MAX_CHUNK_TOTAL` (1024).
35
+
36
+ Not included (deferred): **forward secrecy (F5)** — the nonce-mixed key does NOT
37
+ provide it (a compromised code + logged nonces still derives the key); true FS
38
+ needs ephemeral ECDH. Also deferred: raising session-code entropy above 75 bits.
39
+
40
+ ## 0.5.0
41
+
42
+ _Never published to npm — these changes first shipped as part of 1.0.0._
43
+
44
+ Remote Live Session agent-experience improvements (all additive — no breaking
45
+ tool-signature changes), from real agent-session feedback.
46
+
47
+ - `watch_live_session` gains an optional `wait_seconds` long-poll parameter (max
48
+ 30). The call now blocks until the next event arrives (or the session ends),
49
+ so agents react instantly instead of sleeping-and-re-polling every few seconds.
50
+ - **Tester reconnect grace window.** A transient tester disconnect (service-worker
51
+ suspend, network blip) no longer ends the session immediately; the bridge waits
52
+ `TRACEGIST_PEER_GRACE_MS` (default 15s) for the extension to reconnect and
53
+ re-announce, preserving buffered events and in-flight replies. An explicit
54
+ `session-end` (recording stopped / consent revoked) still ends at once.
55
+ - **Actionable, differentiated errors** for `ask_tester_question` /
56
+ `get_live_screenshot`: distinguishes "tester hasn't joined / left", "connected
57
+ but not recording yet", and "momentarily disconnected, retry" instead of one
58
+ opaque message.
59
+ - **Silent-recording detection.** A voice answer that transcribes to whisper's
60
+ `[BLANK_AUDIO]` (or empty) is now surfaced as an explicit "no speech detected —
61
+ consider re-asking" signal rather than a bogus transcript.
62
+ - Every live event now carries `tsEpoch` (bridge wall-clock at ingestion) — a
63
+ single uniformly-comparable absolute timestamp, since `ts` is source-relative
64
+ for tester events but epoch for bridge control events.
65
+ - `ask_tester_question` message limit raised 200 → 500 chars and reframed to allow
66
+ statements/replies, not only questions.
67
+
5
68
  ## 0.4.0
6
69
 
7
70
  **Remote Live Sessions** — the live shadowing loop now works across machines.
package/bin/lib.mjs CHANGED
@@ -212,6 +212,44 @@ export function audioFormatFromMime(mimeType, fallback = "webm") {
212
212
  return m ? m[1] : fallback;
213
213
  }
214
214
 
215
+ /**
216
+ * Interpret a raw transcription string from Whisper/OpenRouter. whisper.cpp
217
+ * emits the literal "[BLANK_AUDIO]" marker (and models can return near-empty
218
+ * text) when a recording has no speech — a silent mic. Strips those markers and
219
+ * reports silence explicitly so the caller can tell the agent to re-ask instead
220
+ * of forwarding a bogus "answer".
221
+ * @param {string | null | undefined} rawText
222
+ * @returns {{ transcription: string } | { silent: true }}
223
+ */
224
+ export function interpretTranscription(rawText) {
225
+ const cleaned = String(rawText || "")
226
+ .replace(/\[BLANK_?AUDIO\]/gi, "")
227
+ .trim();
228
+ if (!cleaned) return { silent: true };
229
+ return { transcription: cleaned };
230
+ }
231
+
232
+ /**
233
+ * Validate a Whisper language code before it is handed to nodejs-whisper.
234
+ *
235
+ * nodejs-whisper interpolates this value UNescaped into a shell command string
236
+ * (`-l <language>`) executed via shelljs.exec, so an unvalidated value is a
237
+ * command-injection sink. Whisper language codes are ISO 639-1/639-2 (2-3
238
+ * ASCII letters) or the literal "auto". Returns the trimmed code, or `undefined`
239
+ * for an empty/absent value. Throws on anything else.
240
+ */
241
+ export function sanitizeWhisperLanguage(language) {
242
+ const trimmed = typeof language === "string" ? language.trim() : "";
243
+ if (!trimmed) return undefined;
244
+ if (!/^(auto|[a-z]{2,3})$/i.test(trimmed)) {
245
+ throw new Error(
246
+ `Invalid language code: ${JSON.stringify(language)}. ` +
247
+ `Expected an ISO 639 code (e.g. "en", "de", "ja") or "auto".`,
248
+ );
249
+ }
250
+ return trimmed;
251
+ }
252
+
215
253
  /**
216
254
  * Parse a boolean-ish env-var value. Treats "0", "false", "no", "off"
217
255
  * (case-insensitive) and empty/undefined as falsy; everything else as truthy.
@@ -18,14 +18,57 @@ const CODE_GROUP_LENGTH = 5;
18
18
  const CODE_GROUPS = 3;
19
19
 
20
20
  const ROOM_ID_CONTEXT = "tracegist-room-v1:";
21
- const HKDF_SALT = "tracegist-live-v1";
22
- const HKDF_INFO = "aes-256-gcm";
23
21
 
24
- export const FRAME_VERSION = 1;
22
+ /**
23
+ * Key derivation: PBKDF2-SHA256, high iteration count, into an HKDF base key.
24
+ * v3 adds the per-connection ephemeral-key handshake (see HandshakeManager).
25
+ * MUST match lib/remote-session.ts.
26
+ */
27
+ const PBKDF2_SALT = "tracegist-live-v2";
28
+ const PBKDF2_ITERATIONS = 210_000;
29
+
30
+ export const FRAME_VERSION = 3;
31
+
32
+ const HKDF_HANDSHAKE_INFO = "tracegist-hs-key-v3";
33
+ const HKDF_CONNECTION_INFO = "tracegist-conn-v3";
34
+ const HKDF_SALT_CONST = "tracegist-live-v3";
35
+ const HANDSHAKE_NONCE_BYTES = 16;
25
36
 
26
37
  /** AES-GCM IV length in bytes (protocol constant, see docs/live-protocol.md) */
27
38
  const GCM_IV_BYTES = 12;
28
39
 
40
+ /**
41
+ * AAD bound into every DATA frame's AES-GCM tag: version + sender role +
42
+ * per-direction counter. Blocks reflection and replay/reorder. MUST match
43
+ * lib/remote-session.ts byte-for-byte.
44
+ * @param {"tester" | "agent"} senderRole
45
+ * @param {number} counter
46
+ */
47
+ function frameAad(senderRole, counter) {
48
+ return new TextEncoder().encode(`tracegist-frame-v3|${senderRole}|${counter}`);
49
+ }
50
+
51
+ /** AAD for a handshake frame: binds protocol tag + sender role. */
52
+ function handshakeAad(senderRole) {
53
+ return new TextEncoder().encode(`tracegist-hs-v3|${senderRole}`);
54
+ }
55
+
56
+ /**
57
+ * Rejects frames whose per-direction counter is not strictly increasing (anti
58
+ * replay/reorder). One instance per receive direction; reset per connection.
59
+ */
60
+ export class ReplayGuard {
61
+ constructor() {
62
+ this.last = -1;
63
+ }
64
+ /** @param {number} counter */
65
+ accept(counter) {
66
+ if (!Number.isInteger(counter) || counter <= this.last) return false;
67
+ this.last = counter;
68
+ return true;
69
+ }
70
+ }
71
+
29
72
  /**
30
73
  * Normalize a user-entered session code: trim, uppercase.
31
74
  * @param {string} raw
@@ -50,21 +93,44 @@ export async function deriveRoomId(code) {
50
93
  }
51
94
 
52
95
  /**
53
- * Derive the AES-256-GCM session key from a normalized session code.
96
+ * Derive the long-term "code secret" (HKDF base key) from the session code via
97
+ * PBKDF2. Everything else is HKDF'd from this. MUST match lib/remote-session.ts.
54
98
  * @param {string} code
55
99
  * @returns {Promise<CryptoKey>}
56
100
  */
57
- export async function deriveSessionKey(code) {
101
+ export async function deriveCodeSecret(code) {
102
+ const encoder = new TextEncoder();
103
+ const pbkdf2Key = await subtle.importKey("raw", encoder.encode(code), "PBKDF2", false, [
104
+ "deriveBits",
105
+ ]);
106
+ const bits = await subtle.deriveBits(
107
+ {
108
+ name: "PBKDF2",
109
+ hash: "SHA-256",
110
+ salt: encoder.encode(PBKDF2_SALT),
111
+ iterations: PBKDF2_ITERATIONS,
112
+ },
113
+ pbkdf2Key,
114
+ 256,
115
+ );
116
+ return subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
117
+ }
118
+
119
+ /**
120
+ * Derive the handshake key (encrypts the nonce-exchange frames).
121
+ * @param {CryptoKey} codeSecret
122
+ * @returns {Promise<CryptoKey>}
123
+ */
124
+ export async function deriveHandshakeKey(codeSecret) {
58
125
  const encoder = new TextEncoder();
59
- const ikm = await subtle.importKey("raw", encoder.encode(code), "HKDF", false, ["deriveKey"]);
60
126
  return subtle.deriveKey(
61
127
  {
62
128
  name: "HKDF",
63
129
  hash: "SHA-256",
64
- salt: encoder.encode(HKDF_SALT),
65
- info: encoder.encode(HKDF_INFO),
130
+ salt: encoder.encode(HKDF_SALT_CONST),
131
+ info: encoder.encode(HKDF_HANDSHAKE_INFO),
66
132
  },
67
- ikm,
133
+ codeSecret,
68
134
  { name: "AES-GCM", length: 256 },
69
135
  false,
70
136
  ["encrypt", "decrypt"],
@@ -72,42 +138,294 @@ export async function deriveSessionKey(code) {
72
138
  }
73
139
 
74
140
  /**
75
- * Encrypt a live-shadow message object into a frame payload.
141
+ * Derive the per-connection DATA key from the code secret + both handshake
142
+ * nonces + epoch (nonces concatenated tester-then-agent). MUST match
143
+ * lib/remote-session.ts.
144
+ * @param {CryptoKey} codeSecret
145
+ * @param {Uint8Array} testerNonce
146
+ * @param {Uint8Array} agentNonce
147
+ * @param {number} epoch
148
+ * @returns {Promise<CryptoKey>}
149
+ */
150
+ export async function deriveConnectionKey(codeSecret, testerNonce, agentNonce, epoch) {
151
+ const salt = new Uint8Array(testerNonce.length + agentNonce.length);
152
+ salt.set(testerNonce, 0);
153
+ salt.set(agentNonce, testerNonce.length);
154
+ return subtle.deriveKey(
155
+ {
156
+ name: "HKDF",
157
+ hash: "SHA-256",
158
+ salt,
159
+ info: new TextEncoder().encode(`${HKDF_CONNECTION_INFO}|${epoch}`),
160
+ },
161
+ codeSecret,
162
+ { name: "AES-GCM", length: 256 },
163
+ false,
164
+ ["encrypt", "decrypt"],
165
+ );
166
+ }
167
+
168
+ /** Generate a fresh handshake nonce (raw bytes). */
169
+ export function generateHandshakeNonce() {
170
+ return webcrypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES));
171
+ }
172
+
173
+ /**
174
+ * Encrypt a live-shadow message into a frame payload. `senderRole` is this
175
+ * endpoint's own role, `counter` its per-direction monotonic sequence; both are
176
+ * authenticated via AAD.
76
177
  * @param {CryptoKey} key
77
178
  * @param {object} message
78
- * @returns {Promise<{v: number, iv: string, ct: string}>}
179
+ * @param {"tester" | "agent"} senderRole
180
+ * @param {number} counter
181
+ * @returns {Promise<{v: number, iv: string, ct: string, c: number}>}
79
182
  */
80
- export async function encryptFrame(key, message) {
183
+ export async function encryptFrame(key, message, senderRole, counter) {
184
+ // Counter must be a non-negative safe integer (see lib/remote-session.ts).
185
+ if (!Number.isSafeInteger(counter) || counter < 0) {
186
+ throw new Error("Frame counter exhausted — a new session/key is required");
187
+ }
81
188
  const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
82
189
  const plaintext = new TextEncoder().encode(JSON.stringify(message));
83
- const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
190
+ const additionalData = frameAad(senderRole, counter);
191
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, plaintext);
84
192
  return {
85
193
  v: FRAME_VERSION,
86
194
  iv: Buffer.from(iv).toString("base64"),
87
195
  ct: Buffer.from(ct).toString("base64"),
196
+ c: counter,
88
197
  };
89
198
  }
90
199
 
91
200
  /**
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.
201
+ * Decrypt a frame payload back into a message object. `senderRole` is the role
202
+ * the receiver EXPECTS (the peer's role); the AAD is rebuilt from it and the
203
+ * frame counter, so a reflected or counter-tampered frame fails to authenticate.
204
+ * Returns null for unknown versions, malformed payloads, or failed auth.
205
+ * Anti-replay is enforced separately by ReplayGuard.
95
206
  * @param {CryptoKey} key
96
- * @param {{v: number, iv: string, ct: string}} payload
207
+ * @param {{v: number, iv: string, ct: string, c: number}} payload
208
+ * @param {"tester" | "agent"} senderRole
97
209
  * @returns {Promise<object | null>}
98
210
  */
99
- export async function decryptFrame(key, payload) {
100
- if (!payload || payload.v !== FRAME_VERSION) return null;
211
+ export async function decryptFrame(key, payload, senderRole) {
212
+ // Counters are non-negative safe integers by construction; reject anything
213
+ // else before it reaches AAD building or the ReplayGuard.
214
+ if (!payload || payload.v !== FRAME_VERSION || !Number.isSafeInteger(payload.c) || payload.c < 0)
215
+ return null;
101
216
  try {
102
217
  const iv = Buffer.from(payload.iv, "base64");
103
218
  const ct = Buffer.from(payload.ct, "base64");
104
- const plaintext = await subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
219
+ const additionalData = frameAad(senderRole, payload.c);
220
+ const plaintext = await subtle.decrypt({ name: "AES-GCM", iv, additionalData }, key, ct);
105
221
  return JSON.parse(new TextDecoder().decode(plaintext));
106
222
  } catch {
107
223
  return null;
108
224
  }
109
225
  }
110
226
 
227
+ /**
228
+ * Encrypt a handshake message under the handshake key (authenticated by role).
229
+ * @param {CryptoKey} handshakeKey
230
+ * @param {"tester" | "agent"} senderRole
231
+ * @param {{epoch: number, nonce: string, role: string}} message
232
+ * @returns {Promise<{hs: 1, iv: string, ct: string}>}
233
+ */
234
+ export async function encryptHandshake(handshakeKey, senderRole, message) {
235
+ const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
236
+ const plaintext = new TextEncoder().encode(JSON.stringify(message));
237
+ const additionalData = handshakeAad(senderRole);
238
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, handshakeKey, plaintext);
239
+ return {
240
+ hs: 1,
241
+ iv: Buffer.from(iv).toString("base64"),
242
+ ct: Buffer.from(ct).toString("base64"),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Decrypt a handshake frame. `senderRole` is the expected sender (peer) role.
248
+ * @param {CryptoKey} handshakeKey
249
+ * @param {"tester" | "agent"} senderRole
250
+ * @param {{hs: 1, iv: string, ct: string}} payload
251
+ * @returns {Promise<{epoch: number, nonce: string, role: string} | null>}
252
+ */
253
+ export async function decryptHandshake(handshakeKey, senderRole, payload) {
254
+ if (!payload || payload.hs !== 1) return null;
255
+ try {
256
+ const iv = Buffer.from(payload.iv, "base64");
257
+ const ct = Buffer.from(payload.ct, "base64");
258
+ const additionalData = handshakeAad(senderRole);
259
+ const plaintext = await subtle.decrypt(
260
+ { name: "AES-GCM", iv, additionalData },
261
+ handshakeKey,
262
+ ct,
263
+ );
264
+ const msg = JSON.parse(new TextDecoder().decode(plaintext));
265
+ if (
266
+ !Number.isSafeInteger(msg.epoch) ||
267
+ msg.epoch < 0 ||
268
+ typeof msg.nonce !== "string" ||
269
+ (msg.role !== "tester" && msg.role !== "agent")
270
+ ) {
271
+ return null;
272
+ }
273
+ return msg;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /** True if a relay payload is a handshake frame (vs a data frame). */
280
+ export function isHandshakeFrame(payload) {
281
+ return typeof payload === "object" && payload !== null && payload.hs === 1;
282
+ }
283
+
284
+ /**
285
+ * Per-connection ephemeral-key handshake state machine — mirror of
286
+ * lib/remote-session.ts (see that file for the protocol description).
287
+ */
288
+ export class HandshakeManager {
289
+ /**
290
+ * @param {"tester" | "agent"} role
291
+ * @param {CryptoKey} codeSecret
292
+ * @param {CryptoKey} handshakeKey
293
+ */
294
+ constructor(role, codeSecret, handshakeKey) {
295
+ this.role = role;
296
+ this.peerRole = role === "tester" ? "agent" : "tester";
297
+ this.codeSecret = codeSecret;
298
+ this.handshakeKey = handshakeKey;
299
+ this.connectionKey = null;
300
+ this.sendCounter = 0;
301
+ this.guard = new ReplayGuard();
302
+ this.pendingEpoch = -1;
303
+ this.pendingNonce = null;
304
+ this.lastEpoch = -1;
305
+ // Highest epoch the tester has established (idempotency guard; see
306
+ // lib/remote-session.ts).
307
+ this.establishedEpoch = -1;
308
+ this.respondedEpoch = -1;
309
+ this.respondedNonce = null;
310
+ }
311
+
312
+ /**
313
+ * @param {"tester" | "agent"} role
314
+ * @param {string} code
315
+ */
316
+ static async create(role, code) {
317
+ const codeSecret = await deriveCodeSecret(code);
318
+ const handshakeKey = await deriveHandshakeKey(codeSecret);
319
+ return new HandshakeManager(role, codeSecret, handshakeKey);
320
+ }
321
+
322
+ get ready() {
323
+ return this.connectionKey !== null;
324
+ }
325
+
326
+ /** @param {number} now */
327
+ nextEpoch(now) {
328
+ const e = Math.max(this.lastEpoch + 1, Math.floor(now));
329
+ this.lastEpoch = e;
330
+ return e;
331
+ }
332
+
333
+ /** TESTER: begin a fresh handshake. @param {number} now */
334
+ async startHandshake(now) {
335
+ if (this.role !== "tester") throw new Error("only the tester initiates the handshake");
336
+ this.connectionKey = null;
337
+ this.pendingEpoch = this.nextEpoch(now);
338
+ this.pendingNonce = generateHandshakeNonce();
339
+ return this.encryptPendingHandshake();
340
+ }
341
+
342
+ encryptPendingHandshake() {
343
+ return encryptHandshake(this.handshakeKey, "tester", {
344
+ epoch: this.pendingEpoch,
345
+ nonce: Buffer.from(this.pendingNonce).toString("base64"),
346
+ role: "tester",
347
+ });
348
+ }
349
+
350
+ /** TESTER: re-encrypt the in-flight handshake for retransmit (or null). */
351
+ async retransmit() {
352
+ if (this.role !== "tester" || this.connectionKey !== null || !this.pendingNonce) return null;
353
+ return this.encryptPendingHandshake();
354
+ }
355
+
356
+ /** Feed an incoming handshake frame. */
357
+ async onHandshakeFrame(payload) {
358
+ const msg = await decryptHandshake(this.handshakeKey, this.peerRole, payload);
359
+ if (!msg || msg.role !== this.peerRole) return { kind: "ignored" };
360
+
361
+ if (this.role === "agent") {
362
+ if (msg.epoch < this.respondedEpoch) return { kind: "ignored" };
363
+ if (msg.epoch === this.respondedEpoch && this.respondedNonce) {
364
+ const resend = await encryptHandshake(this.handshakeKey, "agent", {
365
+ epoch: msg.epoch,
366
+ nonce: Buffer.from(this.respondedNonce).toString("base64"),
367
+ role: "agent",
368
+ });
369
+ return { kind: "response", payload: resend, keyReady: this.connectionKey !== null };
370
+ }
371
+ const testerNonce = Buffer.from(msg.nonce, "base64");
372
+ // A wrong-size nonce would silently derive a mismatched key — fail loud.
373
+ if (testerNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
374
+ const agentNonce = generateHandshakeNonce();
375
+ this.connectionKey = await deriveConnectionKey(
376
+ this.codeSecret,
377
+ testerNonce,
378
+ agentNonce,
379
+ msg.epoch,
380
+ );
381
+ this.sendCounter = 0;
382
+ this.guard = new ReplayGuard();
383
+ this.respondedEpoch = msg.epoch;
384
+ this.respondedNonce = agentNonce;
385
+ const response = await encryptHandshake(this.handshakeKey, "agent", {
386
+ epoch: msg.epoch,
387
+ nonce: Buffer.from(agentNonce).toString("base64"),
388
+ role: "agent",
389
+ });
390
+ return { kind: "response", payload: response, keyReady: true };
391
+ }
392
+
393
+ if (msg.epoch !== this.pendingEpoch || !this.pendingNonce) return { kind: "ignored" };
394
+ // Idempotent: a re-delivered/replayed response for an already-established
395
+ // epoch must not re-derive or reset (would rewind the counter + reopen the
396
+ // replay hole). A legitimate reconnect uses a strictly higher epoch.
397
+ if (this.establishedEpoch >= msg.epoch) return { kind: "ignored" };
398
+ const agentNonce = Buffer.from(msg.nonce, "base64");
399
+ // A wrong-size nonce would silently derive a mismatched key — fail loud.
400
+ if (agentNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
401
+ this.connectionKey = await deriveConnectionKey(
402
+ this.codeSecret,
403
+ this.pendingNonce,
404
+ agentNonce,
405
+ msg.epoch,
406
+ );
407
+ this.sendCounter = 0;
408
+ this.guard = new ReplayGuard();
409
+ this.establishedEpoch = msg.epoch;
410
+ return { kind: "established" };
411
+ }
412
+
413
+ /** Encrypt a data message under the connection key. Throws if not ready. */
414
+ async encryptData(message) {
415
+ if (!this.connectionKey) throw new Error("handshake not complete");
416
+ return encryptFrame(this.connectionKey, message, this.role, this.sendCounter++);
417
+ }
418
+
419
+ /** Decrypt + replay-check a data frame. Null if not ready / invalid. */
420
+ async decryptData(payload) {
421
+ if (!this.connectionKey) return null;
422
+ const inner = await decryptFrame(this.connectionKey, payload, this.peerRole);
423
+ if (inner === null) return null;
424
+ if (!this.guard.accept(payload.c)) return null;
425
+ return inner;
426
+ }
427
+ }
428
+
111
429
  // ---------------------------------------------------------------------------
112
430
  // Frame chunking — mirror of lib/remote-session.ts (see that file for the
113
431
  // size rationale). Hosted relays cap WebSocket messages (Cloudflare: 1 MiB);
@@ -119,6 +437,8 @@ export async function decryptFrame(key, payload) {
119
437
  export const FRAME_CHUNK_THRESHOLD_BYTES = 700_000;
120
438
  /** UTF-8 bytes per chunk. */
121
439
  export const FRAME_CHUNK_BYTES = 500_000;
440
+ /** Hard cap on part count — bounds new Array(total) from a peer-supplied total. */
441
+ export const MAX_CHUNK_TOTAL = 1024;
122
442
 
123
443
  /**
124
444
  * Split an oversized inner message into `frame-chunk` parts. Returns null
@@ -174,6 +494,7 @@ export class FrameChunkAssembler {
174
494
  !Number.isInteger(seq) ||
175
495
  !Number.isInteger(total) ||
176
496
  total < 1 ||
497
+ total > MAX_CHUNK_TOTAL ||
177
498
  seq < 0 ||
178
499
  seq >= total
179
500
  ) {
@@ -25,13 +25,14 @@ import {
25
25
  audioFormatFromMime,
26
26
  isAllowedLiveShadowOrigin,
27
27
  envFlagEnabled,
28
+ interpretTranscription,
29
+ sanitizeWhisperLanguage,
28
30
  } from "./lib.mjs";
29
31
  import {
30
32
  normalizeSessionCode,
31
33
  deriveRoomId,
32
- deriveSessionKey,
33
- encryptFrame,
34
- decryptFrame,
34
+ HandshakeManager,
35
+ isHandshakeFrame,
35
36
  chunkInnerMessage,
36
37
  FrameChunkAssembler,
37
38
  } from "./live-crypto.mjs";
@@ -175,7 +176,9 @@ const WHISPER_CONCURRENCY = 2;
175
176
  * accepts the file directly without any ffmpeg hop.
176
177
  */
177
178
  async function transcribeWithLocalWhisper(audioPath, model, language) {
178
- const trimmedLang = language && language.trim() ? language.trim() : undefined;
179
+ // Reject anything that is not a plain language code before it reaches the
180
+ // shell — nodejs-whisper interpolates this value unescaped into `-l <lang>`.
181
+ const trimmedLang = sanitizeWhisperLanguage(language);
179
182
  const transcript = await nodewhisper(audioPath, {
180
183
  modelName: model,
181
184
  autoDownloadModelName: model,
@@ -1095,6 +1098,20 @@ const RELAY_MAX_RECONNECT_ATTEMPTS = 10;
1095
1098
  const PENDING_QUESTIONS_MAX = 50;
1096
1099
  const WATCH_LIVE_DEFAULT_LIMIT = 100;
1097
1100
  const WATCH_LIVE_MAX_LIMIT = 500;
1101
+ /** Max seconds watch_live_session will block waiting for a new event (long-poll). */
1102
+ const WATCH_LIVE_MAX_WAIT_SECONDS = 30;
1103
+ /**
1104
+ * How long the bridge keeps a relay session "active" after the tester's peer
1105
+ * drops, giving the tester's extension time to reconnect (it retries for ~30s)
1106
+ * before the session is declared ended. Covers transient service-worker
1107
+ * suspensions / network blips so an in-flight agent reply is not lost to a
1108
+ * momentary disconnect. An explicit `session-end` still ends immediately.
1109
+ * Override via TRACEGIST_PEER_GRACE_MS (0 disables the grace window).
1110
+ */
1111
+ const PEER_ABSENT_GRACE_MS = (() => {
1112
+ const raw = Number(process.env.TRACEGIST_PEER_GRACE_MS);
1113
+ return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
1114
+ })();
1098
1115
 
1099
1116
  /** @type {Array<{seq: number, ts: number, eventType: string, data: object}>} */
1100
1117
  const liveEvents = [];
@@ -1122,6 +1139,20 @@ const liveInteractions = [];
1122
1139
  /** @type {Array<{resolve: Function, reject: Function, timeout: ReturnType<typeof setTimeout>}>} */
1123
1140
  const screenshotWaiters = [];
1124
1141
 
1142
+ /**
1143
+ * Resolvers for watch_live_session long-poll: each waits for the next event to
1144
+ * be pushed (or a timeout). Notified by pushLiveEvent.
1145
+ * @type {Array<() => void>}
1146
+ */
1147
+ const eventWaiters = [];
1148
+
1149
+ /**
1150
+ * Timer that ends the relay session after the tester's peer has been absent for
1151
+ * PEER_ABSENT_GRACE_MS. Cleared if the tester reconnects. See peer-left handler.
1152
+ * @type {ReturnType<typeof setTimeout> | null}
1153
+ */
1154
+ let peerAbsentGraceTimer = null;
1155
+
1125
1156
  /**
1126
1157
  * Transcribe a voice data URL via the shared transcribeAudio path.
1127
1158
  * @param {string} voiceBlobDataUrl - data:audio/...;base64,... URL
@@ -1133,7 +1164,17 @@ async function transcribeLiveVoice(voiceBlobDataUrl) {
1133
1164
  const mimeType = match[1];
1134
1165
  const audioBuffer = Buffer.from(match[2], "base64");
1135
1166
  const result = await transcribeAudio({ audioBuffer, mimeType });
1136
- return result.transcription ? { transcription: result.transcription } : { error: result.error };
1167
+ if (!result.transcription) return { error: result.error };
1168
+ // Detect silent recordings (whisper's "[BLANK_AUDIO]" / empty) and surface them
1169
+ // as an explicit signal instead of handing the agent a bogus "answer".
1170
+ const interpreted = interpretTranscription(result.transcription);
1171
+ if (interpreted.silent) {
1172
+ return {
1173
+ error: "No speech detected — the tester's recording was silent. Consider re-asking.",
1174
+ silent: true,
1175
+ };
1176
+ }
1177
+ return { transcription: interpreted.transcription };
1137
1178
  }
1138
1179
 
1139
1180
  /** Push a live event into the ring buffer with auto-incrementing seq.
@@ -1144,11 +1185,62 @@ function pushLiveEvent(eventType, data, ts = Date.now()) {
1144
1185
  // Ensure our seq is always higher than the max seq in the buffer
1145
1186
  const maxExistingSeq = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1].seq : 0;
1146
1187
  liveEventSeq = Math.max(liveEventSeq, maxExistingSeq) + 1;
1147
- liveEvents.push({ seq: liveEventSeq, ts, eventType, data });
1188
+ // `ts` is the source event time: session-relative ms for extension/tester
1189
+ // events, epoch ms for bridge-originated control events — so it is NOT
1190
+ // consistent across sources. `tsEpoch` is always the bridge's wall-clock at
1191
+ // ingestion, giving agents one uniformly-comparable absolute timestamp.
1192
+ liveEvents.push({ seq: liveEventSeq, ts, tsEpoch: Date.now(), eventType, data });
1148
1193
  if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
1149
1194
  const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
1150
1195
  liveEvents.splice(0, evictCount);
1151
1196
  }
1197
+ // Wake any long-poll waiters now that a new event is available.
1198
+ for (const wake of eventWaiters.splice(0)) wake();
1199
+ }
1200
+
1201
+ /** Cancel a pending "tester absent" grace timer (tester reconnected or session ended). */
1202
+ function clearPeerAbsentGrace() {
1203
+ if (peerAbsentGraceTimer !== null) {
1204
+ clearTimeout(peerAbsentGraceTimer);
1205
+ peerAbsentGraceTimer = null;
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * Why a tester-directed action (ask_tester_question / get_live_screenshot) can't
1211
+ * run right now, or null if it can. Distinguishes the states that previously all
1212
+ * returned one opaque error, so the agent can tell the tester what to do.
1213
+ */
1214
+ function liveSessionUnavailableReason() {
1215
+ if (liveSessionActive && isExtensionLinked()) return null;
1216
+ if (relayRoomId) {
1217
+ if (peerAbsentGraceTimer !== null || (relayPeerPresent === false && liveSessionActive)) {
1218
+ return "The tester momentarily disconnected and may be reconnecting. Retry in a few seconds.";
1219
+ }
1220
+ if (!relayPeerPresent) {
1221
+ return "The tester has not joined the session (or has left). Ask them to start their remote session in the TraceGist extension.";
1222
+ }
1223
+ if (!liveSessionActive) {
1224
+ return "The tester is connected but hasn't started recording yet. Ask them to press record; keep polling watch_live_session for the 'session-start' event.";
1225
+ }
1226
+ }
1227
+ return "No active live shadowing session, or the extension is not connected. Ensure the tester is recording with Live Shadowing / a remote session enabled.";
1228
+ }
1229
+
1230
+ /** End the active relay session and notify polling agents. Idempotent. */
1231
+ function endRelaySession(reason) {
1232
+ clearPeerAbsentGrace();
1233
+ // Only end a RELAY-owned session. A local session that started (e.g. during the
1234
+ // grace window after a relay drop) must not be torn down by relay teardown.
1235
+ if (!liveSessionActive || liveSessionTransport !== "relay") return;
1236
+ liveSessionActive = false;
1237
+ console.error(`[${BRIDGE_NAME}] Relay: session ended (${reason})`);
1238
+ pushLiveEvent("session-ended", { reason });
1239
+ try {
1240
+ server.sendResourceListChanged();
1241
+ } catch {
1242
+ // Not all transports support notifications
1243
+ }
1152
1244
  }
1153
1245
 
1154
1246
  function startLiveShadowServer() {
@@ -1238,6 +1330,9 @@ function startLiveShadowServer() {
1238
1330
  function handleExtensionMessage(msg, source = "local") {
1239
1331
  switch (msg.type) {
1240
1332
  case "session-start": {
1333
+ // A session is (re)starting — cancel any pending grace-end so it can't
1334
+ // fire against this fresh session.
1335
+ clearPeerAbsentGrace();
1241
1336
  // The extension re-announces the session whenever the relay peer
1242
1337
  // (re)connects. A re-announce of the SAME session must not wipe
1243
1338
  // buffered events or un-retrieved question responses.
@@ -1402,6 +1497,8 @@ function handleExtensionMessage(msg, source = "local") {
1402
1497
  // A session-end from the transport that doesn't own the session must
1403
1498
  // not tear down the owning transport's session.
1404
1499
  if (liveSessionTransport && source !== liveSessionTransport) break;
1500
+ // Explicit end (recording stopped / consent revoked) — end now, no grace.
1501
+ clearPeerAbsentGrace();
1405
1502
  liveSessionActive = false;
1406
1503
  console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
1407
1504
  try {
@@ -1424,8 +1521,16 @@ function handleExtensionMessage(msg, source = "local") {
1424
1521
 
1425
1522
  /** @type {WebSocket | null} */
1426
1523
  let relayWs = null;
1427
- /** @type {CryptoKey | null} */
1428
- let relaySessionKey = null;
1524
+ /**
1525
+ * Per-connection ephemeral-key handshake (agent side). Owns the connection key,
1526
+ * send counter, and replay guard. Created on join_live_session.
1527
+ * @type {HandshakeManager | null}
1528
+ */
1529
+ let relayHandshake = null;
1530
+ /** Cap on messages buffered while the relay handshake is in flight (drop-oldest). */
1531
+ const RELAY_PENDING_SENDS_MAX = 2000;
1532
+ /** Inner messages awaiting the handshake before they can be encrypted + sent. */
1533
+ let relayPendingSends = [];
1429
1534
  let relayRoomId = null;
1430
1535
  let relayUrl = null;
1431
1536
  let relayPeerPresent = false;
@@ -1459,20 +1564,31 @@ let relaySendChain = Promise.resolve();
1459
1564
  * @param {object} msg
1460
1565
  */
1461
1566
  async function sendToExtension(msg) {
1462
- const canRelay = isRelayConnected() && relaySessionKey !== null;
1567
+ const canRelay = isRelayConnected() && relayHandshake !== null;
1463
1568
  const useRelay = canRelay && (liveSessionTransport === "relay" || activeConnection === null);
1464
1569
  if (useRelay) {
1465
1570
  // Hosted relays cap WebSocket messages (Cloudflare: 1 MiB); oversized
1466
1571
  // messages travel as encrypted frame-chunk parts (see live-crypto.mjs).
1572
+ const parts = chunkInnerMessage(msg) ?? [msg];
1573
+ // If the handshake hasn't completed yet, buffer the parts; they are flushed
1574
+ // once the connection key is established. Bounded (drop-oldest) so a relay
1575
+ // that never delivers the tester handshake can't grow this unboundedly —
1576
+ // matches the extension's buffer cap (the agent has no handshake timeout).
1577
+ if (!relayHandshake.ready) {
1578
+ for (const part of parts) {
1579
+ if (relayPendingSends.length >= RELAY_PENDING_SENDS_MAX) relayPendingSends.shift();
1580
+ relayPendingSends.push(part);
1581
+ }
1582
+ return;
1583
+ }
1467
1584
  const send = relaySendChain.then(async () => {
1468
1585
  // Re-check at execution time: the socket may have closed while an
1469
1586
  // earlier message in the chain was encrypting.
1470
- if (!isRelayConnected() || relaySessionKey === null) {
1587
+ if (!isRelayConnected() || relayHandshake === null || !relayHandshake.ready) {
1471
1588
  throw new Error("Extension not connected (relay closed while sending)");
1472
1589
  }
1473
- const parts = chunkInnerMessage(msg) ?? [msg];
1474
1590
  for (const part of parts) {
1475
- const payload = await encryptFrame(relaySessionKey, part);
1591
+ const payload = await relayHandshake.encryptData(part);
1476
1592
  relayWs.send(JSON.stringify({ type: "frame", payload }));
1477
1593
  }
1478
1594
  });
@@ -1487,8 +1603,26 @@ async function sendToExtension(msg) {
1487
1603
  throw new Error("Extension not connected (no local or relay transport)");
1488
1604
  }
1489
1605
 
1606
+ /** Encrypt + send inner messages buffered while the relay handshake was in flight. */
1607
+ function flushRelayPendingSends() {
1608
+ const hs = relayHandshake;
1609
+ if (!hs || !hs.ready || relayPendingSends.length === 0) return;
1610
+ const queued = relayPendingSends;
1611
+ relayPendingSends = [];
1612
+ const send = relaySendChain.then(async () => {
1613
+ for (const part of queued) {
1614
+ if (!isRelayConnected() || relayHandshake !== hs || !hs.ready) return;
1615
+ relayWs.send(JSON.stringify({ type: "frame", payload: await hs.encryptData(part) }));
1616
+ }
1617
+ });
1618
+ relaySendChain = send.catch(() => {});
1619
+ }
1620
+
1490
1621
  function disconnectRelay() {
1491
1622
  relayIntentionalClose = true;
1623
+ // Cancel any pending grace timer — otherwise it can fire after a new session
1624
+ // (e.g. a rejoin with a different code) has taken over and end it spuriously.
1625
+ clearPeerAbsentGrace();
1492
1626
  if (relayReconnectTimer) {
1493
1627
  clearTimeout(relayReconnectTimer);
1494
1628
  relayReconnectTimer = undefined;
@@ -1501,7 +1635,8 @@ function disconnectRelay() {
1501
1635
  }
1502
1636
  }
1503
1637
  relayWs = null;
1504
- relaySessionKey = null;
1638
+ relayHandshake = null;
1639
+ relayPendingSends = [];
1505
1640
  relayRoomId = null;
1506
1641
  relayPeerPresent = false;
1507
1642
  relayReconnectAttempts = 0;
@@ -1546,7 +1681,8 @@ function connectRelay() {
1546
1681
  // one frame can never reorder events behind a faster later frame (the
1547
1682
  // live event buffer assumes ascending seq).
1548
1683
  let frameChain = Promise.resolve();
1549
- // Per-connection: a new socket can never continue a previous reassembly.
1684
+ // Per-connection: a new socket can never continue a previous reassembly. The
1685
+ // handshake manager owns the connection key + counter + replay guard.
1550
1686
  const chunkAssembler = new FrameChunkAssembler();
1551
1687
 
1552
1688
  ws.on("message", (raw) => {
@@ -1570,6 +1706,8 @@ function connectRelay() {
1570
1706
 
1571
1707
  case "peer-joined":
1572
1708
  relayPeerPresent = true;
1709
+ // Tester is back within the grace window — cancel the pending end.
1710
+ clearPeerAbsentGrace();
1573
1711
  pushLiveEvent("tester-connected", {
1574
1712
  hint: "The tester's extension joined the remote session.",
1575
1713
  });
@@ -1577,17 +1715,28 @@ function connectRelay() {
1577
1715
 
1578
1716
  case "peer-left":
1579
1717
  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
1718
+ // Do NOT end the session immediately: the tester's extension retries
1719
+ // for ~30s and re-announces on reconnect, so a transient drop (service
1720
+ // worker suspend, network blip) should not kill the session and lose an
1721
+ // in-flight reply. Start a grace timer; an explicit `session-end` frame
1722
+ // still ends the session at once via handleExtensionMessage.
1723
+ if (liveSessionActive && peerAbsentGraceTimer === null) {
1724
+ if (PEER_ABSENT_GRACE_MS === 0) {
1725
+ endRelaySession("tester disconnected");
1726
+ } else {
1727
+ peerAbsentGraceTimer = setTimeout(() => {
1728
+ peerAbsentGraceTimer = null;
1729
+ if (!relayPeerPresent) endRelaySession("tester did not reconnect");
1730
+ }, PEER_ABSENT_GRACE_MS);
1731
+ if (typeof peerAbsentGraceTimer.unref === "function") peerAbsentGraceTimer.unref();
1587
1732
  }
1588
1733
  }
1589
1734
  pushLiveEvent("tester-disconnected", {
1590
- hint: "The tester's extension left the remote session.",
1735
+ reconnectGraceMs: PEER_ABSENT_GRACE_MS,
1736
+ hint:
1737
+ PEER_ABSENT_GRACE_MS > 0
1738
+ ? `The tester's extension dropped. Waiting up to ${Math.round(PEER_ABSENT_GRACE_MS / 1000)}s for it to reconnect before ending the session — keep polling.`
1739
+ : "The tester's extension left the remote session.",
1591
1740
  });
1592
1741
  break;
1593
1742
 
@@ -1595,9 +1744,23 @@ function connectRelay() {
1595
1744
  const payload = msg.payload;
1596
1745
  frameChain = frameChain
1597
1746
  .then(async () => {
1598
- const inner = await decryptFrame(relaySessionKey, payload);
1747
+ const hs = relayHandshake;
1748
+ if (!hs || relayWs !== ws) return;
1749
+ if (isHandshakeFrame(payload)) {
1750
+ // The tester initiated (or retransmitted) a handshake. Reply with
1751
+ // our nonce and, once the key is ready, flush any buffered sends.
1752
+ const result = await hs.onHandshakeFrame(payload);
1753
+ if (result.kind === "response") {
1754
+ relayWs.send(JSON.stringify({ type: "frame", payload: result.payload }));
1755
+ if (result.keyReady) flushRelayPendingSends();
1756
+ }
1757
+ return;
1758
+ }
1759
+ // Data frame from the tester: decrypt + replay-check under the
1760
+ // connection key (rejects reflected/replayed/previous-connection).
1761
+ const inner = await hs.decryptData(payload);
1599
1762
  if (inner === null) {
1600
- console.error(`[${BRIDGE_NAME}] Relay: dropped undecryptable frame`);
1763
+ console.error(`[${BRIDGE_NAME}] Relay: dropped undecryptable/replayed frame`);
1601
1764
  return;
1602
1765
  }
1603
1766
  // Chunk parts return null until the final part completes.
@@ -1648,6 +1811,7 @@ function scheduleRelayReconnect() {
1648
1811
  // The relay-owned session is unreachable for good — end it so polling
1649
1812
  // agents see sessionActive:false instead of a permanently dead session.
1650
1813
  if (liveSessionActive && liveSessionTransport === "relay") {
1814
+ clearPeerAbsentGrace();
1651
1815
  liveSessionActive = false;
1652
1816
  pushLiveEvent("relay-connection-lost", {
1653
1817
  hint: "Relay connection lost and reconnection failed; the remote session has ended. Use join_live_session to reconnect.",
@@ -1709,8 +1873,10 @@ server.registerTool(
1709
1873
  description:
1710
1874
  "Watch a live TraceGist shadowing session in real-time. Returns buffered events since the given sequence number.\n\n" +
1711
1875
  "IMPORTANT POLLING BEHAVIOR:\n" +
1712
- "- You MUST keep calling this tool every 2-4 seconds in a loop until sessionActive becomes false.\n" +
1713
- "- Do NOT stop polling just because there are no new events — the tester is still active.\n" +
1876
+ "- PREFER long-polling: pass `wait_seconds` (e.g. 25) with `since_seq = nextSeq`. The call blocks until a\n" +
1877
+ " new event arrives or the session ends, so you react instantly without a fixed sleep-and-poll loop.\n" +
1878
+ "- Keep calling in a loop until sessionActive becomes false; with wait_seconds you can loop back immediately.\n" +
1879
+ "- Do NOT stop just because a call returned no new events — the tester is still active.\n" +
1714
1880
  "- When you see 'voice-transcription' events, the tester recorded a voice marker — read the transcription and react to it.\n" +
1715
1881
  "- When you see 'question-response-received' events, use get_tester_response to retrieve the full answer with images.\n" +
1716
1882
  "- When you see 'question-response-transcribed' events, the tester's voice response has been transcribed — read it for context.\n" +
@@ -1742,9 +1908,47 @@ server.registerTool(
1742
1908
  `Maximum events to return per call (default: ${WATCH_LIVE_DEFAULT_LIMIT}, max: ${WATCH_LIVE_MAX_LIMIT}). ` +
1743
1909
  "Use the returned `nextSeq` as `since_seq` on the next call when `hasMore` is true.",
1744
1910
  ),
1911
+ wait_seconds: z
1912
+ .number()
1913
+ .min(0)
1914
+ .max(WATCH_LIVE_MAX_WAIT_SECONDS)
1915
+ .optional()
1916
+ .describe(
1917
+ `Long-poll: block up to this many seconds (max ${WATCH_LIVE_MAX_WAIT_SECONDS}) waiting for a new ` +
1918
+ "event after `since_seq`, returning immediately when one arrives (or the session ends). " +
1919
+ "STRONGLY PREFERRED over fixed-interval polling — pass e.g. 25 with `since_seq = nextSeq` to " +
1920
+ "react the instant the tester acts, instead of sleeping and re-polling. Omit or 0 to return at once.",
1921
+ ),
1745
1922
  }),
1746
1923
  },
1747
- async ({ since_seq, limit = WATCH_LIVE_DEFAULT_LIMIT }) => {
1924
+ async ({ since_seq, limit = WATCH_LIVE_DEFAULT_LIMIT, wait_seconds = 0 }) => {
1925
+ // Long-poll: block until there's a new event after since_seq, the session
1926
+ // ends, or the wait budget elapses. Only waits while a session is live or a
1927
+ // relay room is joined — otherwise falls straight through to the error below.
1928
+ const waitMs = Math.min(Math.max(wait_seconds, 0), WATCH_LIVE_MAX_WAIT_SECONDS) * 1000;
1929
+ if (waitMs > 0) {
1930
+ const deadline = Date.now() + waitMs;
1931
+ while ((liveSessionActive || relayRoomId) && Date.now() < deadline) {
1932
+ const hasNew =
1933
+ since_seq != null ? liveEvents.some((e) => e.seq > since_seq) : liveEvents.length > 0;
1934
+ if (hasNew) break;
1935
+ // Wait for the next pushLiveEvent, capped at 1s slices so we re-check
1936
+ // session/deadline state (e.g. the session ending) even without an event.
1937
+ await new Promise((resolve) => {
1938
+ let settled = false;
1939
+ const wake = () => {
1940
+ if (settled) return;
1941
+ settled = true;
1942
+ clearTimeout(timer);
1943
+ const i = eventWaiters.indexOf(wake);
1944
+ if (i >= 0) eventWaiters.splice(i, 1);
1945
+ resolve();
1946
+ };
1947
+ const timer = setTimeout(wake, Math.min(deadline - Date.now(), 1000));
1948
+ eventWaiters.push(wake);
1949
+ });
1950
+ }
1951
+ }
1748
1952
  if (!liveSessionActive && liveEvents.length === 0) {
1749
1953
  // Joined a remote room but the tester hasn't started yet — this is the
1750
1954
  // documented flow after join_live_session, not an error.
@@ -1822,14 +2026,15 @@ server.registerTool(
1822
2026
 
1823
2027
  if (liveSessionActive) {
1824
2028
  responseData.pollingGuidance =
1825
- "KEEP POLLING every 2-4 seconds. Do NOT stop — the tester is still active. " +
2029
+ "KEEP WATCHING — the tester is still active. Prefer calling again with wait_seconds (e.g. 25) and " +
2030
+ "since_seq = nextSeq to block until the next event instead of fixed-interval polling. " +
1826
2031
  "Watch for 'voice-transcription' events (tester voice markers) and 'question-response-received' events. " +
1827
2032
  "React to voice markers by acknowledging them or asking follow-up questions.";
1828
2033
  responseData.availableActions = [
1829
2034
  {
1830
2035
  tool: "ask_tester_question",
1831
2036
  description:
1832
- "Ask the tester a short question (max 200 chars). They respond with voice + highlights.",
2037
+ "Send the tester a question or short message (max 500 chars). They respond with voice + highlights.",
1833
2038
  },
1834
2039
  {
1835
2040
  tool: "get_live_screenshot",
@@ -1900,17 +2105,17 @@ server.registerTool(
1900
2105
  inputSchema: z.object({
1901
2106
  question: z
1902
2107
  .string()
1903
- .max(200)
2108
+ .max(500)
1904
2109
  .describe(
1905
- "Short, concise question for the tester (max 200 chars). " +
1906
- "Example: 'Can you click the Save button again?' or 'Does the error appear with a different email?'",
2110
+ "Message for the tester — a question OR a statement/reply (max 500 chars). " +
2111
+ "Examples: 'Can you click Save again?' · 'Thanks — I see the 500 now, fixing it.' · " +
2112
+ "'In my opinion the layout bug is the CSS grid gap.'",
1907
2113
  ),
1908
2114
  }),
1909
2115
  },
1910
2116
  async ({ question }) => {
1911
- if (!liveSessionActive || !isExtensionLinked()) {
1912
- return toolError("No active live shadowing session or extension not connected.");
1913
- }
2117
+ const unavailable = liveSessionUnavailableReason();
2118
+ if (unavailable) return toolError(unavailable);
1914
2119
 
1915
2120
  const questionId = crypto.randomUUID();
1916
2121
  try {
@@ -2113,9 +2318,8 @@ server.registerTool(
2113
2318
  inputSchema: z.object({}),
2114
2319
  },
2115
2320
  async () => {
2116
- if (!liveSessionActive || !isExtensionLinked()) {
2117
- return toolError("No active live shadowing session or extension not connected.");
2118
- }
2321
+ const unavailable = liveSessionUnavailableReason();
2322
+ if (unavailable) return toolError(unavailable);
2119
2323
 
2120
2324
  // Request screenshot from extension and wait for response
2121
2325
  const screenshotPromise = new Promise((resolve, reject) => {
@@ -2204,15 +2408,24 @@ server.registerTool(
2204
2408
  );
2205
2409
  }
2206
2410
 
2207
- // Replace any previous remote session
2411
+ // Replace any previous remote session. disconnectRelay() clears the grace
2412
+ // timer; also reset the owning session state (as leave_live_session does) so
2413
+ // a still-"active" session held open by the grace window doesn't carry its
2414
+ // stale sessionId/meta into the new room until a session-start corrects it.
2208
2415
  if (relayWs || relayRoomId) {
2209
2416
  disconnectRelay();
2417
+ if (liveSessionActive && liveSessionTransport === "relay") {
2418
+ liveSessionActive = false;
2419
+ liveSessionTransport = null;
2420
+ liveSessionMeta = null;
2421
+ }
2210
2422
  }
2211
2423
 
2212
2424
  relayIntentionalClose = false;
2213
2425
  relayUrl = process.env.TRACEGIST_RELAY_URL || DEFAULT_RELAY_URL;
2214
2426
  relayRoomId = await deriveRoomId(normalized);
2215
- relaySessionKey = await deriveSessionKey(normalized);
2427
+ relayPendingSends = [];
2428
+ relayHandshake = await HandshakeManager.create("agent", normalized);
2216
2429
 
2217
2430
  try {
2218
2431
  const { peerPresent } = await connectRelay();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracegist-mcp-bridge",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "description": "Local-first MCP bridge for reading and transcribing TraceGist package zips.",
5
5
  "type": "module",
6
6
  "bin": {