tandem-editor 0.9.0 → 0.9.1

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.
@@ -17914,6 +17914,14 @@ var IDLE_TIMEOUT = 30 * 60 * 1e3;
17914
17914
  var SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
17915
17915
  var CHANNEL_MAX_RETRIES = 5;
17916
17916
  var CHANNEL_RETRY_DELAY_MS = 2e3;
17917
+ var CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 1e4;
17918
+ var CHANNEL_SSE_INACTIVITY_TIMEOUT_MS = 6e4;
17919
+ var CHANNEL_MODE_FETCH_TIMEOUT_MS = 2e3;
17920
+ var CHANNEL_AWARENESS_FETCH_TIMEOUT_MS = 5e3;
17921
+ var CHANNEL_ERROR_REPORT_TIMEOUT_MS = 3e3;
17922
+ var CHANNEL_REPLY_FETCH_TIMEOUT_MS = 5e3;
17923
+ var CHANNEL_PERMISSION_FETCH_TIMEOUT_MS = 5e3;
17924
+ var CHANNEL_MAX_SSE_BUFFER_BYTES = 1e6;
17917
17925
 
17918
17926
  // src/shared/cli-runtime.ts
17919
17927
  function redirectConsoleToStderr() {
@@ -17945,6 +17953,21 @@ async function authFetch(url, init) {
17945
17953
  return fetch(url, init);
17946
17954
  }
17947
17955
 
17956
+ // src/shared/fetch-with-timeout.ts
17957
+ async function fetchWithTimeout(url, init, timeoutMs) {
17958
+ const signal = AbortSignal.timeout(timeoutMs);
17959
+ return authFetch(url, { ...init, signal });
17960
+ }
17961
+ function describeFetchError(err, endpoint, timeoutMs) {
17962
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
17963
+ return `${endpoint} timed out after ${timeoutMs}ms`;
17964
+ }
17965
+ return err instanceof Error ? err.message : String(err);
17966
+ }
17967
+ function isAbortOrTimeoutError(err) {
17968
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
17969
+ }
17970
+
17948
17971
  // src/shared/events/types.ts
17949
17972
  var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
17950
17973
  "annotation:created",
@@ -17966,9 +17989,9 @@ function formatEventContent(event) {
17966
17989
  const doc = event.documentId ? ` [doc: ${event.documentId}]` : "";
17967
17990
  switch (event.type) {
17968
17991
  case "annotation:created": {
17969
- const { annotationType, content, textSnippet, hasSuggestedText, directedAt } = event.payload;
17992
+ const { annotationType, content, textSnippet, hasSuggestedText } = event.payload;
17970
17993
  const snippet = textSnippet ? ` on "${textSnippet}"` : "";
17971
- const label = hasSuggestedText ? "replacement" : directedAt === "claude" ? "question for Claude" : annotationType;
17994
+ const label = hasSuggestedText ? "replacement" : annotationType;
17972
17995
  return `User created ${label}${snippet}: ${content || "(no content)"}${doc}`;
17973
17996
  }
17974
17997
  case "annotation:accepted": {
@@ -18063,18 +18086,22 @@ async function startEventBridge(mcp, tandemUrl) {
18063
18086
  if (retries >= CHANNEL_MAX_RETRIES) {
18064
18087
  console.error("[Channel] SSE connection exhausted, reporting error and exiting");
18065
18088
  try {
18066
- await authFetch(`${tandemUrl}/api/channel-error`, {
18067
- method: "POST",
18068
- headers: { "Content-Type": "application/json" },
18069
- body: JSON.stringify({
18070
- error: "CHANNEL_CONNECT_FAILED",
18071
- message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
18072
- })
18073
- });
18089
+ await fetchWithTimeout(
18090
+ `${tandemUrl}/api/channel-error`,
18091
+ {
18092
+ method: "POST",
18093
+ headers: { "Content-Type": "application/json" },
18094
+ body: JSON.stringify({
18095
+ error: "CHANNEL_CONNECT_FAILED",
18096
+ message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
18097
+ })
18098
+ },
18099
+ CHANNEL_ERROR_REPORT_TIMEOUT_MS
18100
+ );
18074
18101
  } catch (reportErr) {
18075
18102
  console.error(
18076
18103
  "[Channel] Could not report failure to server:",
18077
- reportErr instanceof Error ? reportErr.message : reportErr
18104
+ describeFetchError(reportErr, "/api/channel-error", CHANNEL_ERROR_REPORT_TIMEOUT_MS)
18078
18105
  );
18079
18106
  }
18080
18107
  process.exit(1);
@@ -18086,29 +18113,52 @@ async function startEventBridge(mcp, tandemUrl) {
18086
18113
  async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18087
18114
  const headers = { Accept: "text/event-stream" };
18088
18115
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
18089
- const res = await authFetch(`${tandemUrl}/api/events`, { headers });
18116
+ const connectCtrl = new AbortController();
18117
+ const connectTimer = setTimeout(
18118
+ () => connectCtrl.abort(new Error("handshake timeout")),
18119
+ CHANNEL_CONNECT_FETCH_TIMEOUT_MS
18120
+ );
18121
+ let res;
18122
+ try {
18123
+ res = await authFetch(`${tandemUrl}/api/events`, { headers, signal: connectCtrl.signal });
18124
+ } finally {
18125
+ clearTimeout(connectTimer);
18126
+ }
18090
18127
  if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
18091
18128
  if (!res.body) throw new Error("SSE endpoint returned no body");
18092
18129
  const reader = res.body.getReader();
18093
18130
  const decoder = new TextDecoder();
18094
18131
  let buffer = "";
18132
+ let lastActivityAt = Date.now();
18133
+ let inactivityTimedOut = false;
18134
+ const watchdog = setInterval(() => {
18135
+ if (Date.now() - lastActivityAt > CHANNEL_SSE_INACTIVITY_TIMEOUT_MS) {
18136
+ inactivityTimedOut = true;
18137
+ reader.cancel(new Error("SSE inactivity timeout")).catch(() => {
18138
+ });
18139
+ }
18140
+ }, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);
18095
18141
  let awarenessTimer = null;
18096
18142
  let clearAwarenessTimer = null;
18097
18143
  let pendingAwareness = null;
18098
18144
  const AWARENESS_CLEAR_MS = 3e3;
18099
18145
  function clearAwareness(documentId) {
18100
- authFetch(`${tandemUrl}/api/channel-awareness`, {
18101
- method: "POST",
18102
- headers: { "Content-Type": "application/json" },
18103
- body: JSON.stringify({
18104
- documentId: documentId ?? null,
18105
- status: "idle",
18106
- active: false
18107
- })
18108
- }).catch((err) => {
18146
+ fetchWithTimeout(
18147
+ `${tandemUrl}/api/channel-awareness`,
18148
+ {
18149
+ method: "POST",
18150
+ headers: { "Content-Type": "application/json" },
18151
+ body: JSON.stringify({
18152
+ documentId: documentId ?? null,
18153
+ status: "idle",
18154
+ active: false
18155
+ })
18156
+ },
18157
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18158
+ ).catch((err) => {
18109
18159
  console.error(
18110
18160
  "[Channel] clearAwareness failed (non-fatal):",
18111
- err instanceof Error ? err.message : err
18161
+ describeFetchError(err, "/api/channel-awareness clear", CHANNEL_AWARENESS_FETCH_TIMEOUT_MS)
18112
18162
  );
18113
18163
  });
18114
18164
  }
@@ -18116,16 +18166,27 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18116
18166
  if (!pendingAwareness) return;
18117
18167
  const event = pendingAwareness;
18118
18168
  pendingAwareness = null;
18119
- authFetch(`${tandemUrl}/api/channel-awareness`, {
18120
- method: "POST",
18121
- headers: { "Content-Type": "application/json" },
18122
- body: JSON.stringify({
18123
- documentId: event.documentId,
18124
- status: `processing: ${event.type}`,
18125
- active: true
18126
- })
18127
- }).catch((err) => {
18128
- console.error("[Channel] Awareness update failed:", err instanceof Error ? err.message : err);
18169
+ fetchWithTimeout(
18170
+ `${tandemUrl}/api/channel-awareness`,
18171
+ {
18172
+ method: "POST",
18173
+ headers: { "Content-Type": "application/json" },
18174
+ body: JSON.stringify({
18175
+ documentId: event.documentId,
18176
+ status: `processing: ${event.type}`,
18177
+ active: true
18178
+ })
18179
+ },
18180
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18181
+ ).catch((err) => {
18182
+ console.error(
18183
+ "[Channel] Awareness update failed:",
18184
+ describeFetchError(
18185
+ err,
18186
+ "/api/channel-awareness update",
18187
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18188
+ )
18189
+ );
18129
18190
  });
18130
18191
  if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18131
18192
  clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
@@ -18135,66 +18196,81 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18135
18196
  if (awarenessTimer) clearTimeout(awarenessTimer);
18136
18197
  awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
18137
18198
  }
18138
- while (true) {
18139
- const { done, value } = await reader.read();
18140
- if (done) throw new Error("SSE stream ended");
18141
- buffer += decoder.decode(value, { stream: true });
18142
- let boundary;
18143
- while ((boundary = buffer.indexOf("\n\n")) !== -1) {
18144
- const frame = buffer.slice(0, boundary);
18145
- buffer = buffer.slice(boundary + 2);
18146
- if (frame.startsWith(":")) continue;
18147
- let eventId;
18148
- let data;
18149
- for (const line of frame.split("\n")) {
18150
- if (line.startsWith("id: ")) eventId = line.slice(4);
18151
- else if (line.startsWith("data: ")) data = line.slice(6);
18152
- }
18153
- if (!data) continue;
18154
- let event;
18155
- try {
18156
- event = parseTandemEvent(JSON.parse(data));
18157
- } catch {
18158
- console.error(
18159
- "[Channel] Malformed SSE event data (skipping), eventId=%s:",
18160
- eventId,
18161
- data.slice(0, 200)
18162
- );
18163
- if (eventId) onEventId(eventId);
18164
- continue;
18165
- }
18166
- if (!event) {
18167
- console.error(
18168
- "[Channel] Invalid SSE event structure (skipping), eventId=%s:",
18169
- eventId,
18170
- data.slice(0, 200)
18199
+ try {
18200
+ while (true) {
18201
+ const { done, value } = await reader.read();
18202
+ if (done) {
18203
+ if (inactivityTimedOut) throw new Error("SSE inactivity timeout");
18204
+ throw new Error("SSE stream ended");
18205
+ }
18206
+ lastActivityAt = Date.now();
18207
+ buffer += decoder.decode(value, { stream: true });
18208
+ if (buffer.length > CHANNEL_MAX_SSE_BUFFER_BYTES) {
18209
+ throw new Error(
18210
+ `SSE buffer exceeded ${CHANNEL_MAX_SSE_BUFFER_BYTES} bytes without a frame boundary`
18171
18211
  );
18172
- if (eventId) onEventId(eventId);
18173
- continue;
18174
18212
  }
18175
- if (event.type !== "chat:message") {
18176
- const mode = await getCachedMode(tandemUrl);
18177
- if (mode === "solo") {
18178
- console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
18213
+ let boundary;
18214
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
18215
+ const frame = buffer.slice(0, boundary);
18216
+ buffer = buffer.slice(boundary + 2);
18217
+ if (frame.startsWith(":")) continue;
18218
+ let eventId;
18219
+ let data;
18220
+ for (const line of frame.split("\n")) {
18221
+ if (line.startsWith("id: ")) eventId = line.slice(4);
18222
+ else if (line.startsWith("data: ")) data = line.slice(6);
18223
+ }
18224
+ if (!data) continue;
18225
+ let event;
18226
+ try {
18227
+ event = parseTandemEvent(JSON.parse(data));
18228
+ } catch {
18229
+ console.error(
18230
+ "[Channel] Malformed SSE event data (skipping), eventId=%s:",
18231
+ eventId,
18232
+ data.slice(0, 200)
18233
+ );
18179
18234
  if (eventId) onEventId(eventId);
18180
18235
  continue;
18181
18236
  }
18182
- }
18183
- try {
18184
- await mcp.notification({
18185
- method: "notifications/claude/channel",
18186
- params: {
18187
- content: formatEventContent(event),
18188
- meta: formatEventMeta(event)
18237
+ if (!event) {
18238
+ console.error(
18239
+ "[Channel] Invalid SSE event structure (skipping), eventId=%s:",
18240
+ eventId,
18241
+ data.slice(0, 200)
18242
+ );
18243
+ if (eventId) onEventId(eventId);
18244
+ continue;
18245
+ }
18246
+ if (event.type !== "chat:message") {
18247
+ const mode = await getCachedMode(tandemUrl);
18248
+ if (mode === "solo") {
18249
+ console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
18250
+ if (eventId) onEventId(eventId);
18251
+ continue;
18189
18252
  }
18190
- });
18191
- } catch (err) {
18192
- console.error("[Channel] MCP notification failed (transport broken?):", err);
18193
- throw err;
18253
+ }
18254
+ try {
18255
+ await mcp.notification({
18256
+ method: "notifications/claude/channel",
18257
+ params: {
18258
+ content: formatEventContent(event),
18259
+ meta: formatEventMeta(event)
18260
+ }
18261
+ });
18262
+ } catch (err) {
18263
+ console.error("[Channel] MCP notification failed (transport broken?):", err);
18264
+ throw err;
18265
+ }
18266
+ if (eventId) onEventId(eventId);
18267
+ scheduleAwareness(event);
18194
18268
  }
18195
- if (eventId) onEventId(eventId);
18196
- scheduleAwareness(event);
18197
18269
  }
18270
+ } finally {
18271
+ clearInterval(watchdog);
18272
+ if (awarenessTimer) clearTimeout(awarenessTimer);
18273
+ if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18198
18274
  }
18199
18275
  }
18200
18276
  var cachedMode = "tandem";
@@ -18203,7 +18279,7 @@ async function getCachedMode(tandemUrl) {
18203
18279
  const now = Date.now();
18204
18280
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
18205
18281
  try {
18206
- const res = await authFetch(`${tandemUrl}/api/mode`);
18282
+ const res = await fetchWithTimeout(`${tandemUrl}/api/mode`, {}, CHANNEL_MODE_FETCH_TIMEOUT_MS);
18207
18283
  if (res.ok) {
18208
18284
  const { mode } = await res.json();
18209
18285
  cachedMode = mode;
@@ -18214,7 +18290,7 @@ async function getCachedMode(tandemUrl) {
18214
18290
  } catch (err) {
18215
18291
  console.error(
18216
18292
  "[Channel] Mode check failed, delivering event (fail-open):",
18217
- err instanceof Error ? err.message : err
18293
+ describeFetchError(err, "/api/mode", CHANNEL_MODE_FETCH_TIMEOUT_MS)
18218
18294
  );
18219
18295
  cachedModeAt = now;
18220
18296
  }
@@ -18241,7 +18317,7 @@ async function runChannel(opts = {}) {
18241
18317
  "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18242
18318
  "chat:message, document:opened, document:closed, document:switched.",
18243
18319
  "Chat messages may include a 'selection' field with buffered selection context.",
18244
- "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
18320
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_edit, etc.) to act on them.",
18245
18321
  "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18246
18322
  "Do not reply to non-chat events \u2014 just act on them using tools.",
18247
18323
  "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
@@ -18275,15 +18351,20 @@ async function runChannel(opts = {}) {
18275
18351
  if (req.params.name === "tandem_reply") {
18276
18352
  const args = req.params.arguments;
18277
18353
  try {
18278
- const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
18279
- method: "POST",
18280
- headers: { "Content-Type": "application/json" },
18281
- body: JSON.stringify(args)
18282
- });
18354
+ const res = await fetchWithTimeout(
18355
+ `${tandemUrl}/api/channel-reply`,
18356
+ {
18357
+ method: "POST",
18358
+ headers: { "Content-Type": "application/json" },
18359
+ body: JSON.stringify(args)
18360
+ },
18361
+ CHANNEL_REPLY_FETCH_TIMEOUT_MS
18362
+ );
18283
18363
  let data;
18284
18364
  try {
18285
18365
  data = await res.json();
18286
- } catch {
18366
+ } catch (parseErr) {
18367
+ if (isAbortOrTimeoutError(parseErr)) throw parseErr;
18287
18368
  data = { message: "Non-JSON response" };
18288
18369
  }
18289
18370
  if (!res.ok) {
@@ -18303,7 +18384,11 @@ async function runChannel(opts = {}) {
18303
18384
  content: [
18304
18385
  {
18305
18386
  type: "text",
18306
- text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18387
+ text: `Failed to send reply: ${describeFetchError(
18388
+ err,
18389
+ "/api/channel-reply",
18390
+ CHANNEL_REPLY_FETCH_TIMEOUT_MS
18391
+ )}`
18307
18392
  }
18308
18393
  ],
18309
18394
  isError: true
@@ -18323,23 +18408,30 @@ async function runChannel(opts = {}) {
18323
18408
  });
18324
18409
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18325
18410
  try {
18326
- const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
18327
- method: "POST",
18328
- headers: { "Content-Type": "application/json" },
18329
- body: JSON.stringify({
18330
- requestId: params.request_id,
18331
- toolName: params.tool_name,
18332
- description: params.description,
18333
- inputPreview: params.input_preview
18334
- })
18335
- });
18411
+ const res = await fetchWithTimeout(
18412
+ `${tandemUrl}/api/channel-permission`,
18413
+ {
18414
+ method: "POST",
18415
+ headers: { "Content-Type": "application/json" },
18416
+ body: JSON.stringify({
18417
+ requestId: params.request_id,
18418
+ toolName: params.tool_name,
18419
+ description: params.description,
18420
+ inputPreview: params.input_preview
18421
+ })
18422
+ },
18423
+ CHANNEL_PERMISSION_FETCH_TIMEOUT_MS
18424
+ );
18336
18425
  if (!res.ok) {
18337
18426
  console.error(
18338
18427
  `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18339
18428
  );
18340
18429
  }
18341
18430
  } catch (err) {
18342
- console.error("[Channel] Failed to forward permission request:", err);
18431
+ console.error(
18432
+ "[Channel] Failed to forward permission request:",
18433
+ describeFetchError(err, "/api/channel-permission", CHANNEL_PERMISSION_FETCH_TIMEOUT_MS)
18434
+ );
18343
18435
  }
18344
18436
  });
18345
18437
  console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);