tandem-editor 0.8.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,42 +18113,80 @@ 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(() => {
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) => {
18159
+ console.error(
18160
+ "[Channel] clearAwareness failed (non-fatal):",
18161
+ describeFetchError(err, "/api/channel-awareness clear", CHANNEL_AWARENESS_FETCH_TIMEOUT_MS)
18162
+ );
18109
18163
  });
18110
18164
  }
18111
18165
  function flushAwareness() {
18112
18166
  if (!pendingAwareness) return;
18113
18167
  const event = pendingAwareness;
18114
18168
  pendingAwareness = null;
18115
- authFetch(`${tandemUrl}/api/channel-awareness`, {
18116
- method: "POST",
18117
- headers: { "Content-Type": "application/json" },
18118
- body: JSON.stringify({
18119
- documentId: event.documentId,
18120
- status: `processing: ${event.type}`,
18121
- active: true
18122
- })
18123
- }).catch((err) => {
18124
- 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
+ );
18125
18190
  });
18126
18191
  if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18127
18192
  clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
@@ -18131,56 +18196,81 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18131
18196
  if (awarenessTimer) clearTimeout(awarenessTimer);
18132
18197
  awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
18133
18198
  }
18134
- while (true) {
18135
- const { done, value } = await reader.read();
18136
- if (done) throw new Error("SSE stream ended");
18137
- buffer += decoder.decode(value, { stream: true });
18138
- let boundary;
18139
- while ((boundary = buffer.indexOf("\n\n")) !== -1) {
18140
- const frame = buffer.slice(0, boundary);
18141
- buffer = buffer.slice(boundary + 2);
18142
- if (frame.startsWith(":")) continue;
18143
- let eventId;
18144
- let data;
18145
- for (const line of frame.split("\n")) {
18146
- if (line.startsWith("id: ")) eventId = line.slice(4);
18147
- else if (line.startsWith("data: ")) data = line.slice(6);
18148
- }
18149
- if (!data) continue;
18150
- let event;
18151
- try {
18152
- event = parseTandemEvent(JSON.parse(data));
18153
- } catch {
18154
- console.error("[Channel] Malformed SSE event data (skipping):", data.slice(0, 200));
18155
- continue;
18156
- }
18157
- if (!event) {
18158
- console.error("[Channel] Received invalid SSE event, skipping");
18159
- continue;
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`
18211
+ );
18160
18212
  }
18161
- if (event.type !== "chat:message") {
18162
- const mode = await getCachedMode(tandemUrl);
18163
- if (mode === "solo") {
18164
- 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
+ );
18165
18234
  if (eventId) onEventId(eventId);
18166
18235
  continue;
18167
18236
  }
18168
- }
18169
- if (eventId) onEventId(eventId);
18170
- try {
18171
- await mcp.notification({
18172
- method: "notifications/claude/channel",
18173
- params: {
18174
- content: formatEventContent(event),
18175
- 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;
18176
18252
  }
18177
- });
18178
- } catch (err) {
18179
- console.error("[Channel] MCP notification failed (transport broken?):", err);
18180
- 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);
18181
18268
  }
18182
- scheduleAwareness(event);
18183
18269
  }
18270
+ } finally {
18271
+ clearInterval(watchdog);
18272
+ if (awarenessTimer) clearTimeout(awarenessTimer);
18273
+ if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18184
18274
  }
18185
18275
  }
18186
18276
  var cachedMode = "tandem";
@@ -18189,7 +18279,7 @@ async function getCachedMode(tandemUrl) {
18189
18279
  const now = Date.now();
18190
18280
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
18191
18281
  try {
18192
- const res = await authFetch(`${tandemUrl}/api/mode`);
18282
+ const res = await fetchWithTimeout(`${tandemUrl}/api/mode`, {}, CHANNEL_MODE_FETCH_TIMEOUT_MS);
18193
18283
  if (res.ok) {
18194
18284
  const { mode } = await res.json();
18195
18285
  cachedMode = mode;
@@ -18200,7 +18290,7 @@ async function getCachedMode(tandemUrl) {
18200
18290
  } catch (err) {
18201
18291
  console.error(
18202
18292
  "[Channel] Mode check failed, delivering event (fail-open):",
18203
- err instanceof Error ? err.message : err
18293
+ describeFetchError(err, "/api/mode", CHANNEL_MODE_FETCH_TIMEOUT_MS)
18204
18294
  );
18205
18295
  cachedModeAt = now;
18206
18296
  }
@@ -18227,7 +18317,7 @@ async function runChannel(opts = {}) {
18227
18317
  "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18228
18318
  "chat:message, document:opened, document:closed, document:switched.",
18229
18319
  "Chat messages may include a 'selection' field with buffered selection context.",
18230
- "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.",
18231
18321
  "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18232
18322
  "Do not reply to non-chat events \u2014 just act on them using tools.",
18233
18323
  "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
@@ -18261,15 +18351,20 @@ async function runChannel(opts = {}) {
18261
18351
  if (req.params.name === "tandem_reply") {
18262
18352
  const args = req.params.arguments;
18263
18353
  try {
18264
- const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
18265
- method: "POST",
18266
- headers: { "Content-Type": "application/json" },
18267
- body: JSON.stringify(args)
18268
- });
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
+ );
18269
18363
  let data;
18270
18364
  try {
18271
18365
  data = await res.json();
18272
- } catch {
18366
+ } catch (parseErr) {
18367
+ if (isAbortOrTimeoutError(parseErr)) throw parseErr;
18273
18368
  data = { message: "Non-JSON response" };
18274
18369
  }
18275
18370
  if (!res.ok) {
@@ -18289,7 +18384,11 @@ async function runChannel(opts = {}) {
18289
18384
  content: [
18290
18385
  {
18291
18386
  type: "text",
18292
- 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
+ )}`
18293
18392
  }
18294
18393
  ],
18295
18394
  isError: true
@@ -18309,23 +18408,30 @@ async function runChannel(opts = {}) {
18309
18408
  });
18310
18409
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18311
18410
  try {
18312
- const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
18313
- method: "POST",
18314
- headers: { "Content-Type": "application/json" },
18315
- body: JSON.stringify({
18316
- requestId: params.request_id,
18317
- toolName: params.tool_name,
18318
- description: params.description,
18319
- inputPreview: params.input_preview
18320
- })
18321
- });
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
+ );
18322
18425
  if (!res.ok) {
18323
18426
  console.error(
18324
18427
  `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18325
18428
  );
18326
18429
  }
18327
18430
  } catch (err) {
18328
- 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
+ );
18329
18435
  }
18330
18436
  });
18331
18437
  console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);