tandem-editor 0.9.0 → 0.11.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.
@@ -3106,6 +3106,9 @@ var require_utils = __commonJS({
3106
3106
  "use strict";
3107
3107
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3108
3108
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
3109
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
3110
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
3111
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
3109
3112
  function stringArrayToHexStripped(input) {
3110
3113
  let acc = "";
3111
3114
  let code = 0;
@@ -3298,27 +3301,77 @@ var require_utils = __commonJS({
3298
3301
  }
3299
3302
  return output.join("");
3300
3303
  }
3301
- function normalizeComponentEncoding(component, esc2) {
3302
- const func = esc2 !== true ? escape : unescape;
3303
- if (component.scheme !== void 0) {
3304
- component.scheme = func(component.scheme);
3305
- }
3306
- if (component.userinfo !== void 0) {
3307
- component.userinfo = func(component.userinfo);
3308
- }
3309
- if (component.host !== void 0) {
3310
- component.host = func(component.host);
3304
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3305
+ var HOST_DELIM_RE = /[@/?#:]/g;
3306
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3307
+ function reescapeHostDelimiters(host, isIP) {
3308
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3309
+ re.lastIndex = 0;
3310
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
3311
+ }
3312
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
3313
+ if (input.indexOf("%") === -1) {
3314
+ return input;
3311
3315
  }
3312
- if (component.path !== void 0) {
3313
- component.path = func(component.path);
3316
+ let output = "";
3317
+ for (let i = 0; i < input.length; i++) {
3318
+ if (input[i] === "%" && i + 2 < input.length) {
3319
+ const hex = input.slice(i + 1, i + 3);
3320
+ if (isHexPair(hex)) {
3321
+ const normalizedHex = hex.toUpperCase();
3322
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3323
+ if (decodeUnreserved && isUnreserved(decoded)) {
3324
+ output += decoded;
3325
+ } else {
3326
+ output += "%" + normalizedHex;
3327
+ }
3328
+ i += 2;
3329
+ continue;
3330
+ }
3331
+ }
3332
+ output += input[i];
3314
3333
  }
3315
- if (component.query !== void 0) {
3316
- component.query = func(component.query);
3334
+ return output;
3335
+ }
3336
+ function normalizePathEncoding(input) {
3337
+ let output = "";
3338
+ for (let i = 0; i < input.length; i++) {
3339
+ if (input[i] === "%" && i + 2 < input.length) {
3340
+ const hex = input.slice(i + 1, i + 3);
3341
+ if (isHexPair(hex)) {
3342
+ const normalizedHex = hex.toUpperCase();
3343
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3344
+ if (decoded !== "." && isUnreserved(decoded)) {
3345
+ output += decoded;
3346
+ } else {
3347
+ output += "%" + normalizedHex;
3348
+ }
3349
+ i += 2;
3350
+ continue;
3351
+ }
3352
+ }
3353
+ if (isPathCharacter(input[i])) {
3354
+ output += input[i];
3355
+ } else {
3356
+ output += escape(input[i]);
3357
+ }
3317
3358
  }
3318
- if (component.fragment !== void 0) {
3319
- component.fragment = func(component.fragment);
3359
+ return output;
3360
+ }
3361
+ function escapePreservingEscapes(input) {
3362
+ let output = "";
3363
+ for (let i = 0; i < input.length; i++) {
3364
+ if (input[i] === "%" && i + 2 < input.length) {
3365
+ const hex = input.slice(i + 1, i + 3);
3366
+ if (isHexPair(hex)) {
3367
+ output += "%" + hex.toUpperCase();
3368
+ i += 2;
3369
+ continue;
3370
+ }
3371
+ }
3372
+ output += escape(input[i]);
3320
3373
  }
3321
- return component;
3374
+ return output;
3322
3375
  }
3323
3376
  function recomposeAuthority(component) {
3324
3377
  const uriTokens = [];
@@ -3333,7 +3386,7 @@ var require_utils = __commonJS({
3333
3386
  if (ipV6res.isIPV6 === true) {
3334
3387
  host = `[${ipV6res.escapedHost}]`;
3335
3388
  } else {
3336
- host = component.host;
3389
+ host = reescapeHostDelimiters(host, false);
3337
3390
  }
3338
3391
  }
3339
3392
  uriTokens.push(host);
@@ -3347,7 +3400,10 @@ var require_utils = __commonJS({
3347
3400
  module.exports = {
3348
3401
  nonSimpleDomain,
3349
3402
  recomposeAuthority,
3350
- normalizeComponentEncoding,
3403
+ reescapeHostDelimiters,
3404
+ normalizePercentEncoding,
3405
+ normalizePathEncoding,
3406
+ escapePreservingEscapes,
3351
3407
  removeDotSegments,
3352
3408
  isIPv4,
3353
3409
  isUUID,
@@ -3571,12 +3627,12 @@ var require_schemes = __commonJS({
3571
3627
  var require_fast_uri = __commonJS({
3572
3628
  "node_modules/fast-uri/index.js"(exports, module) {
3573
3629
  "use strict";
3574
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3630
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3575
3631
  var { SCHEMES, getSchemeHandler } = require_schemes();
3576
3632
  function normalize(uri, options) {
3577
3633
  if (typeof uri === "string") {
3578
3634
  uri = /** @type {T} */
3579
- serialize(parse3(uri, options), options);
3635
+ normalizeString(uri, options);
3580
3636
  } else if (typeof uri === "object") {
3581
3637
  uri = /** @type {T} */
3582
3638
  parse3(serialize(uri, options), options);
@@ -3643,19 +3699,9 @@ var require_fast_uri = __commonJS({
3643
3699
  return target;
3644
3700
  }
3645
3701
  function equal(uriA, uriB, options) {
3646
- if (typeof uriA === "string") {
3647
- uriA = unescape(uriA);
3648
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
3649
- } else if (typeof uriA === "object") {
3650
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
3651
- }
3652
- if (typeof uriB === "string") {
3653
- uriB = unescape(uriB);
3654
- uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
3655
- } else if (typeof uriB === "object") {
3656
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
3657
- }
3658
- return uriA.toLowerCase() === uriB.toLowerCase();
3702
+ const normalizedA = normalizeComparableURI(uriA, options);
3703
+ const normalizedB = normalizeComparableURI(uriB, options);
3704
+ return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3659
3705
  }
3660
3706
  function serialize(cmpts, opts) {
3661
3707
  const component = {
@@ -3680,12 +3726,12 @@ var require_fast_uri = __commonJS({
3680
3726
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
3681
3727
  if (component.path !== void 0) {
3682
3728
  if (!options.skipEscape) {
3683
- component.path = escape(component.path);
3729
+ component.path = escapePreservingEscapes(component.path);
3684
3730
  if (component.scheme !== void 0) {
3685
3731
  component.path = component.path.split("%3A").join(":");
3686
3732
  }
3687
3733
  } else {
3688
- component.path = unescape(component.path);
3734
+ component.path = normalizePercentEncoding(component.path);
3689
3735
  }
3690
3736
  }
3691
3737
  if (options.reference !== "suffix" && component.scheme) {
@@ -3720,7 +3766,16 @@ var require_fast_uri = __commonJS({
3720
3766
  return uriTokens.join("");
3721
3767
  }
3722
3768
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3723
- function parse3(uri, opts) {
3769
+ function getParseError(parsed, matches) {
3770
+ if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3771
+ return 'URI path must start with "/" when authority is present.';
3772
+ }
3773
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
3774
+ return "URI port is malformed.";
3775
+ }
3776
+ return void 0;
3777
+ }
3778
+ function parseWithStatus(uri, opts) {
3724
3779
  const options = Object.assign({}, opts);
3725
3780
  const parsed = {
3726
3781
  scheme: void 0,
@@ -3731,6 +3786,7 @@ var require_fast_uri = __commonJS({
3731
3786
  query: void 0,
3732
3787
  fragment: void 0
3733
3788
  };
3789
+ let malformedAuthorityOrPort = false;
3734
3790
  let isIP = false;
3735
3791
  if (options.reference === "suffix") {
3736
3792
  if (options.scheme) {
@@ -3751,6 +3807,11 @@ var require_fast_uri = __commonJS({
3751
3807
  if (isNaN(parsed.port)) {
3752
3808
  parsed.port = matches[5];
3753
3809
  }
3810
+ const parseError = getParseError(parsed, matches);
3811
+ if (parseError !== void 0) {
3812
+ parsed.error = parsed.error || parseError;
3813
+ malformedAuthorityOrPort = true;
3814
+ }
3754
3815
  if (parsed.host) {
3755
3816
  const ipv4result = isIPv4(parsed.host);
3756
3817
  if (ipv4result === false) {
@@ -3789,14 +3850,18 @@ var require_fast_uri = __commonJS({
3789
3850
  parsed.scheme = unescape(parsed.scheme);
3790
3851
  }
3791
3852
  if (parsed.host !== void 0) {
3792
- parsed.host = unescape(parsed.host);
3853
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3793
3854
  }
3794
3855
  }
3795
3856
  if (parsed.path) {
3796
- parsed.path = escape(unescape(parsed.path));
3857
+ parsed.path = normalizePathEncoding(parsed.path);
3797
3858
  }
3798
3859
  if (parsed.fragment) {
3799
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3860
+ try {
3861
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3862
+ } catch {
3863
+ parsed.error = parsed.error || "URI malformed";
3864
+ }
3800
3865
  }
3801
3866
  }
3802
3867
  if (schemeHandler && schemeHandler.parse) {
@@ -3805,7 +3870,29 @@ var require_fast_uri = __commonJS({
3805
3870
  } else {
3806
3871
  parsed.error = parsed.error || "URI can not be parsed.";
3807
3872
  }
3808
- return parsed;
3873
+ return { parsed, malformedAuthorityOrPort };
3874
+ }
3875
+ function parse3(uri, opts) {
3876
+ return parseWithStatus(uri, opts).parsed;
3877
+ }
3878
+ function normalizeString(uri, opts) {
3879
+ return normalizeStringWithStatus(uri, opts).normalized;
3880
+ }
3881
+ function normalizeStringWithStatus(uri, opts) {
3882
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3883
+ return {
3884
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
3885
+ malformedAuthorityOrPort
3886
+ };
3887
+ }
3888
+ function normalizeComparableURI(uri, opts) {
3889
+ if (typeof uri === "string") {
3890
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
3891
+ return malformedAuthorityOrPort ? void 0 : normalized;
3892
+ }
3893
+ if (typeof uri === "object") {
3894
+ return serialize(uri, opts);
3895
+ }
3809
3896
  }
3810
3897
  var fastUri = {
3811
3898
  SCHEMES,
@@ -17914,6 +18001,14 @@ var IDLE_TIMEOUT = 30 * 60 * 1e3;
17914
18001
  var SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
17915
18002
  var CHANNEL_MAX_RETRIES = 5;
17916
18003
  var CHANNEL_RETRY_DELAY_MS = 2e3;
18004
+ var CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 1e4;
18005
+ var CHANNEL_SSE_INACTIVITY_TIMEOUT_MS = 6e4;
18006
+ var CHANNEL_MODE_FETCH_TIMEOUT_MS = 2e3;
18007
+ var CHANNEL_AWARENESS_FETCH_TIMEOUT_MS = 5e3;
18008
+ var CHANNEL_ERROR_REPORT_TIMEOUT_MS = 3e3;
18009
+ var CHANNEL_REPLY_FETCH_TIMEOUT_MS = 5e3;
18010
+ var CHANNEL_PERMISSION_FETCH_TIMEOUT_MS = 5e3;
18011
+ var CHANNEL_MAX_SSE_BUFFER_BYTES = 1e6;
17917
18012
 
17918
18013
  // src/shared/cli-runtime.ts
17919
18014
  function redirectConsoleToStderr() {
@@ -17922,34 +18017,73 @@ function redirectConsoleToStderr() {
17922
18017
  console.info = console.error;
17923
18018
  }
17924
18019
  function resolveTandemUrl(override) {
17925
- const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
17926
- return raw.replace(/\/$/, "");
18020
+ return resolveTandemUrlCandidate(override).replace(/\/+$/, "");
18021
+ }
18022
+ function resolveTandemUrlCandidate(override) {
18023
+ const candidates = [
18024
+ override,
18025
+ process.env.CLAUDE_PLUGIN_OPTION_SERVER_URL,
18026
+ process.env.TANDEM_URL
18027
+ ];
18028
+ for (const url of candidates) {
18029
+ if (url !== void 0 && url.trim() !== "") return url.trim();
18030
+ }
18031
+ return `http://localhost:${DEFAULT_MCP_PORT}`;
18032
+ }
18033
+ function resolveAuthTokenCandidate(override) {
18034
+ const candidates = [
18035
+ ["explicit override", override],
18036
+ ["CLAUDE_PLUGIN_OPTION_AUTH_TOKEN", process.env.CLAUDE_PLUGIN_OPTION_AUTH_TOKEN],
18037
+ ["TANDEM_AUTH_TOKEN", process.env.TANDEM_AUTH_TOKEN]
18038
+ ];
18039
+ for (const [source, token] of candidates) {
18040
+ if (token !== void 0 && token.trim() !== "") return { token, source };
18041
+ }
18042
+ return { token: void 0, source: void 0 };
17927
18043
  }
17928
18044
  var VALID_TOKEN_RE = /^[A-Za-z0-9_\-]{32,}$/;
17929
18045
  var _warnedInvalidToken = false;
17930
18046
  async function authFetch(url, init) {
17931
- const token = process.env.TANDEM_AUTH_TOKEN;
17932
- if (token !== void 0 && token.trim() !== "") {
17933
- if (VALID_TOKEN_RE.test(token.trim())) {
18047
+ const { token, source } = resolveAuthTokenCandidate();
18048
+ if (token !== void 0) {
18049
+ const trimmed = token.trim();
18050
+ if (VALID_TOKEN_RE.test(trimmed)) {
17934
18051
  const headers = new Headers(init?.headers);
17935
- headers.set("Authorization", `Bearer ${token.trim()}`);
18052
+ headers.set("Authorization", `Bearer ${trimmed}`);
17936
18053
  return fetch(url, { ...init, headers });
17937
18054
  }
17938
18055
  if (!_warnedInvalidToken) {
17939
18056
  _warnedInvalidToken = true;
17940
18057
  console.error(
17941
- "[tandem] authFetch: TANDEM_AUTH_TOKEN is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header"
18058
+ `[tandem] authFetch: ${source} is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header`
17942
18059
  );
17943
18060
  }
17944
18061
  }
17945
18062
  return fetch(url, init);
17946
18063
  }
17947
18064
 
18065
+ // src/shared/fetch-with-timeout.ts
18066
+ async function fetchWithTimeout(url, init, timeoutMs) {
18067
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
18068
+ const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
18069
+ return authFetch(url, { ...init, signal });
18070
+ }
18071
+ function describeFetchError(err, endpoint, timeoutMs) {
18072
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
18073
+ return `${endpoint} timed out after ${timeoutMs}ms`;
18074
+ }
18075
+ return err instanceof Error ? err.message : String(err);
18076
+ }
18077
+ function isAbortOrTimeoutError(err) {
18078
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
18079
+ }
18080
+
17948
18081
  // src/shared/events/types.ts
17949
18082
  var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
17950
18083
  "annotation:created",
17951
18084
  "annotation:accepted",
17952
18085
  "annotation:dismissed",
18086
+ "annotation:edited",
17953
18087
  "annotation:reply",
17954
18088
  "chat:message",
17955
18089
  "document:opened",
@@ -17966,9 +18100,9 @@ function formatEventContent(event) {
17966
18100
  const doc = event.documentId ? ` [doc: ${event.documentId}]` : "";
17967
18101
  switch (event.type) {
17968
18102
  case "annotation:created": {
17969
- const { annotationType, content, textSnippet, hasSuggestedText, directedAt } = event.payload;
18103
+ const { annotationType, content, textSnippet, hasSuggestedText } = event.payload;
17970
18104
  const snippet = textSnippet ? ` on "${textSnippet}"` : "";
17971
- const label = hasSuggestedText ? "replacement" : directedAt === "claude" ? "question for Claude" : annotationType;
18105
+ const label = hasSuggestedText ? "replacement" : annotationType;
17972
18106
  return `User created ${label}${snippet}: ${content || "(no content)"}${doc}`;
17973
18107
  }
17974
18108
  case "annotation:accepted": {
@@ -17979,6 +18113,10 @@ function formatEventContent(event) {
17979
18113
  const { annotationId, textSnippet } = event.payload;
17980
18114
  return `User dismissed annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
17981
18115
  }
18116
+ case "annotation:edited": {
18117
+ const { content } = event.payload;
18118
+ return `User edited annotation: "${content}"${doc}`;
18119
+ }
17982
18120
  case "annotation:reply": {
17983
18121
  const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;
17984
18122
  const who = replyAuthor === "claude" ? "Claude" : "User";
@@ -18021,6 +18159,10 @@ function formatEventMeta(event) {
18021
18159
  case "annotation:dismissed":
18022
18160
  meta.annotation_id = event.payload.annotationId;
18023
18161
  break;
18162
+ case "annotation:edited":
18163
+ meta.annotation_id = event.payload.annotationId;
18164
+ meta.edited_at = String(event.payload.editedAt);
18165
+ break;
18024
18166
  case "annotation:reply":
18025
18167
  meta.annotation_id = event.payload.annotationId;
18026
18168
  meta.reply_id = event.payload.replyId;
@@ -18063,18 +18205,22 @@ async function startEventBridge(mcp, tandemUrl) {
18063
18205
  if (retries >= CHANNEL_MAX_RETRIES) {
18064
18206
  console.error("[Channel] SSE connection exhausted, reporting error and exiting");
18065
18207
  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
- });
18208
+ await fetchWithTimeout(
18209
+ `${tandemUrl}/api/channel-error`,
18210
+ {
18211
+ method: "POST",
18212
+ headers: { "Content-Type": "application/json" },
18213
+ body: JSON.stringify({
18214
+ error: "CHANNEL_CONNECT_FAILED",
18215
+ message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
18216
+ })
18217
+ },
18218
+ CHANNEL_ERROR_REPORT_TIMEOUT_MS
18219
+ );
18074
18220
  } catch (reportErr) {
18075
18221
  console.error(
18076
18222
  "[Channel] Could not report failure to server:",
18077
- reportErr instanceof Error ? reportErr.message : reportErr
18223
+ describeFetchError(reportErr, "/api/channel-error", CHANNEL_ERROR_REPORT_TIMEOUT_MS)
18078
18224
  );
18079
18225
  }
18080
18226
  process.exit(1);
@@ -18086,29 +18232,52 @@ async function startEventBridge(mcp, tandemUrl) {
18086
18232
  async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18087
18233
  const headers = { Accept: "text/event-stream" };
18088
18234
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
18089
- const res = await authFetch(`${tandemUrl}/api/events`, { headers });
18235
+ const connectCtrl = new AbortController();
18236
+ const connectTimer = setTimeout(
18237
+ () => connectCtrl.abort(new Error("handshake timeout")),
18238
+ CHANNEL_CONNECT_FETCH_TIMEOUT_MS
18239
+ );
18240
+ let res;
18241
+ try {
18242
+ res = await authFetch(`${tandemUrl}/api/events`, { headers, signal: connectCtrl.signal });
18243
+ } finally {
18244
+ clearTimeout(connectTimer);
18245
+ }
18090
18246
  if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
18091
18247
  if (!res.body) throw new Error("SSE endpoint returned no body");
18092
18248
  const reader = res.body.getReader();
18093
18249
  const decoder = new TextDecoder();
18094
18250
  let buffer = "";
18251
+ let lastActivityAt = Date.now();
18252
+ let inactivityTimedOut = false;
18253
+ const watchdog = setInterval(() => {
18254
+ if (Date.now() - lastActivityAt > CHANNEL_SSE_INACTIVITY_TIMEOUT_MS) {
18255
+ inactivityTimedOut = true;
18256
+ reader.cancel(new Error("SSE inactivity timeout")).catch(() => {
18257
+ });
18258
+ }
18259
+ }, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);
18095
18260
  let awarenessTimer = null;
18096
18261
  let clearAwarenessTimer = null;
18097
18262
  let pendingAwareness = null;
18098
18263
  const AWARENESS_CLEAR_MS = 3e3;
18099
18264
  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) => {
18265
+ fetchWithTimeout(
18266
+ `${tandemUrl}/api/channel-awareness`,
18267
+ {
18268
+ method: "POST",
18269
+ headers: { "Content-Type": "application/json" },
18270
+ body: JSON.stringify({
18271
+ documentId: documentId ?? null,
18272
+ status: "idle",
18273
+ active: false
18274
+ })
18275
+ },
18276
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18277
+ ).catch((err) => {
18109
18278
  console.error(
18110
18279
  "[Channel] clearAwareness failed (non-fatal):",
18111
- err instanceof Error ? err.message : err
18280
+ describeFetchError(err, "/api/channel-awareness clear", CHANNEL_AWARENESS_FETCH_TIMEOUT_MS)
18112
18281
  );
18113
18282
  });
18114
18283
  }
@@ -18116,16 +18285,27 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18116
18285
  if (!pendingAwareness) return;
18117
18286
  const event = pendingAwareness;
18118
18287
  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);
18288
+ fetchWithTimeout(
18289
+ `${tandemUrl}/api/channel-awareness`,
18290
+ {
18291
+ method: "POST",
18292
+ headers: { "Content-Type": "application/json" },
18293
+ body: JSON.stringify({
18294
+ documentId: event.documentId,
18295
+ status: `processing: ${event.type}`,
18296
+ active: true
18297
+ })
18298
+ },
18299
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18300
+ ).catch((err) => {
18301
+ console.error(
18302
+ "[Channel] Awareness update failed:",
18303
+ describeFetchError(
18304
+ err,
18305
+ "/api/channel-awareness update",
18306
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
18307
+ )
18308
+ );
18129
18309
  });
18130
18310
  if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18131
18311
  clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
@@ -18135,66 +18315,81 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18135
18315
  if (awarenessTimer) clearTimeout(awarenessTimer);
18136
18316
  awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
18137
18317
  }
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)
18318
+ try {
18319
+ while (true) {
18320
+ const { done, value } = await reader.read();
18321
+ if (done) {
18322
+ if (inactivityTimedOut) throw new Error("SSE inactivity timeout");
18323
+ throw new Error("SSE stream ended");
18324
+ }
18325
+ lastActivityAt = Date.now();
18326
+ buffer += decoder.decode(value, { stream: true });
18327
+ if (buffer.length > CHANNEL_MAX_SSE_BUFFER_BYTES) {
18328
+ throw new Error(
18329
+ `SSE buffer exceeded ${CHANNEL_MAX_SSE_BUFFER_BYTES} bytes without a frame boundary`
18171
18330
  );
18172
- if (eventId) onEventId(eventId);
18173
- continue;
18174
18331
  }
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`);
18332
+ let boundary;
18333
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
18334
+ const frame = buffer.slice(0, boundary);
18335
+ buffer = buffer.slice(boundary + 2);
18336
+ if (frame.startsWith(":")) continue;
18337
+ let eventId;
18338
+ let data;
18339
+ for (const line of frame.split("\n")) {
18340
+ if (line.startsWith("id: ")) eventId = line.slice(4);
18341
+ else if (line.startsWith("data: ")) data = line.slice(6);
18342
+ }
18343
+ if (!data) continue;
18344
+ let event;
18345
+ try {
18346
+ event = parseTandemEvent(JSON.parse(data));
18347
+ } catch {
18348
+ console.error(
18349
+ "[Channel] Malformed SSE event data (skipping), eventId=%s:",
18350
+ eventId,
18351
+ data.slice(0, 200)
18352
+ );
18179
18353
  if (eventId) onEventId(eventId);
18180
18354
  continue;
18181
18355
  }
18182
- }
18183
- try {
18184
- await mcp.notification({
18185
- method: "notifications/claude/channel",
18186
- params: {
18187
- content: formatEventContent(event),
18188
- meta: formatEventMeta(event)
18356
+ if (!event) {
18357
+ console.error(
18358
+ "[Channel] Invalid SSE event structure (skipping), eventId=%s:",
18359
+ eventId,
18360
+ data.slice(0, 200)
18361
+ );
18362
+ if (eventId) onEventId(eventId);
18363
+ continue;
18364
+ }
18365
+ if (event.type !== "chat:message") {
18366
+ const mode = await getCachedMode(tandemUrl);
18367
+ if (mode === "solo") {
18368
+ console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
18369
+ if (eventId) onEventId(eventId);
18370
+ continue;
18189
18371
  }
18190
- });
18191
- } catch (err) {
18192
- console.error("[Channel] MCP notification failed (transport broken?):", err);
18193
- throw err;
18372
+ }
18373
+ try {
18374
+ await mcp.notification({
18375
+ method: "notifications/claude/channel",
18376
+ params: {
18377
+ content: formatEventContent(event),
18378
+ meta: formatEventMeta(event)
18379
+ }
18380
+ });
18381
+ } catch (err) {
18382
+ console.error("[Channel] MCP notification failed (transport broken?):", err);
18383
+ throw err;
18384
+ }
18385
+ if (eventId) onEventId(eventId);
18386
+ scheduleAwareness(event);
18194
18387
  }
18195
- if (eventId) onEventId(eventId);
18196
- scheduleAwareness(event);
18197
18388
  }
18389
+ } finally {
18390
+ clearInterval(watchdog);
18391
+ if (awarenessTimer) clearTimeout(awarenessTimer);
18392
+ if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
18198
18393
  }
18199
18394
  }
18200
18395
  var cachedMode = "tandem";
@@ -18203,7 +18398,7 @@ async function getCachedMode(tandemUrl) {
18203
18398
  const now = Date.now();
18204
18399
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
18205
18400
  try {
18206
- const res = await authFetch(`${tandemUrl}/api/mode`);
18401
+ const res = await fetchWithTimeout(`${tandemUrl}/api/mode`, {}, CHANNEL_MODE_FETCH_TIMEOUT_MS);
18207
18402
  if (res.ok) {
18208
18403
  const { mode } = await res.json();
18209
18404
  cachedMode = mode;
@@ -18214,7 +18409,7 @@ async function getCachedMode(tandemUrl) {
18214
18409
  } catch (err) {
18215
18410
  console.error(
18216
18411
  "[Channel] Mode check failed, delivering event (fail-open):",
18217
- err instanceof Error ? err.message : err
18412
+ describeFetchError(err, "/api/mode", CHANNEL_MODE_FETCH_TIMEOUT_MS)
18218
18413
  );
18219
18414
  cachedModeAt = now;
18220
18415
  }
@@ -18241,7 +18436,7 @@ async function runChannel(opts = {}) {
18241
18436
  "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18242
18437
  "chat:message, document:opened, document:closed, document:switched.",
18243
18438
  "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.",
18439
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_edit, etc.) to act on them.",
18245
18440
  "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18246
18441
  "Do not reply to non-chat events \u2014 just act on them using tools.",
18247
18442
  "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
@@ -18275,15 +18470,20 @@ async function runChannel(opts = {}) {
18275
18470
  if (req.params.name === "tandem_reply") {
18276
18471
  const args = req.params.arguments;
18277
18472
  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
- });
18473
+ const res = await fetchWithTimeout(
18474
+ `${tandemUrl}/api/channel-reply`,
18475
+ {
18476
+ method: "POST",
18477
+ headers: { "Content-Type": "application/json" },
18478
+ body: JSON.stringify(args)
18479
+ },
18480
+ CHANNEL_REPLY_FETCH_TIMEOUT_MS
18481
+ );
18283
18482
  let data;
18284
18483
  try {
18285
18484
  data = await res.json();
18286
- } catch {
18485
+ } catch (parseErr) {
18486
+ if (isAbortOrTimeoutError(parseErr)) throw parseErr;
18287
18487
  data = { message: "Non-JSON response" };
18288
18488
  }
18289
18489
  if (!res.ok) {
@@ -18303,7 +18503,11 @@ async function runChannel(opts = {}) {
18303
18503
  content: [
18304
18504
  {
18305
18505
  type: "text",
18306
- text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18506
+ text: `Failed to send reply: ${describeFetchError(
18507
+ err,
18508
+ "/api/channel-reply",
18509
+ CHANNEL_REPLY_FETCH_TIMEOUT_MS
18510
+ )}`
18307
18511
  }
18308
18512
  ],
18309
18513
  isError: true
@@ -18323,23 +18527,30 @@ async function runChannel(opts = {}) {
18323
18527
  });
18324
18528
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18325
18529
  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
- });
18530
+ const res = await fetchWithTimeout(
18531
+ `${tandemUrl}/api/channel-permission`,
18532
+ {
18533
+ method: "POST",
18534
+ headers: { "Content-Type": "application/json" },
18535
+ body: JSON.stringify({
18536
+ requestId: params.request_id,
18537
+ toolName: params.tool_name,
18538
+ description: params.description,
18539
+ inputPreview: params.input_preview
18540
+ })
18541
+ },
18542
+ CHANNEL_PERMISSION_FETCH_TIMEOUT_MS
18543
+ );
18336
18544
  if (!res.ok) {
18337
18545
  console.error(
18338
18546
  `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18339
18547
  );
18340
18548
  }
18341
18549
  } catch (err) {
18342
- console.error("[Channel] Failed to forward permission request:", err);
18550
+ console.error(
18551
+ "[Channel] Failed to forward permission request:",
18552
+ describeFetchError(err, "/api/channel-permission", CHANNEL_PERMISSION_FETCH_TIMEOUT_MS)
18553
+ );
18343
18554
  }
18344
18555
  });
18345
18556
  console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);