tandem-editor 0.4.0 → 0.6.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.
@@ -6799,7 +6799,7 @@ var require_dist = __commonJS({
6799
6799
  }
6800
6800
  });
6801
6801
 
6802
- // src/channel/index.ts
6802
+ // src/channel/run.ts
6803
6803
  import { createConnection } from "net";
6804
6804
 
6805
6805
  // node_modules/zod/v3/external.js
@@ -17915,13 +17915,24 @@ 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
17917
 
17918
+ // src/shared/cli-runtime.ts
17919
+ function redirectConsoleToStderr() {
17920
+ console.log = console.error;
17921
+ console.warn = console.error;
17922
+ console.info = console.error;
17923
+ }
17924
+ function resolveTandemUrl(override) {
17925
+ const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
17926
+ return raw.replace(/\/$/, "");
17927
+ }
17928
+
17918
17929
  // src/server/events/types.ts
17919
17930
  var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
17920
17931
  "annotation:created",
17921
17932
  "annotation:accepted",
17922
17933
  "annotation:dismissed",
17934
+ "annotation:reply",
17923
17935
  "chat:message",
17924
- "selection:changed",
17925
17936
  "document:opened",
17926
17937
  "document:closed",
17927
17938
  "document:switched"
@@ -17949,15 +17960,17 @@ function formatEventContent(event) {
17949
17960
  const { annotationId, textSnippet } = event.payload;
17950
17961
  return `User dismissed annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
17951
17962
  }
17963
+ case "annotation:reply": {
17964
+ const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;
17965
+ const who = replyAuthor === "claude" ? "Claude" : "User";
17966
+ const snippet = textSnippet ? ` (on "${textSnippet}")` : "";
17967
+ return `${who} replied to annotation ${annotationId}${snippet}: ${replyText}${doc}`;
17968
+ }
17952
17969
  case "chat:message": {
17953
- const { text, replyTo } = event.payload;
17970
+ const { text, replyTo, selection } = event.payload;
17954
17971
  const reply = replyTo ? ` (replying to ${replyTo})` : "";
17955
- return `User says${reply}: ${text}${doc}`;
17956
- }
17957
- case "selection:changed": {
17958
- const { from, to, selectedText } = event.payload;
17959
- if (!selectedText) return `User cleared selection${doc}`;
17960
- return `User is pointing at text (${from}-${to}): "${selectedText}"${doc} \u2014 respond via tandem_reply`;
17972
+ const sel = selection && selection.selectedText ? ` [selection: "${selection.selectedText}"${"from" in selection ? ` (${selection.from}-${selection.to})` : ""}]` : "";
17973
+ return `User says${reply}: ${text}${sel}${doc}`;
17961
17974
  }
17962
17975
  case "document:opened": {
17963
17976
  const { fileName, format } = event.payload;
@@ -17988,11 +18001,13 @@ function formatEventMeta(event) {
17988
18001
  case "annotation:dismissed":
17989
18002
  meta.annotation_id = event.payload.annotationId;
17990
18003
  break;
18004
+ case "annotation:reply":
18005
+ meta.annotation_id = event.payload.annotationId;
18006
+ meta.reply_id = event.payload.replyId;
18007
+ break;
17991
18008
  case "chat:message":
17992
18009
  meta.message_id = event.payload.messageId;
17993
- break;
17994
- case "selection:changed":
17995
- meta.respond_via = "tandem_reply";
18010
+ if (event.payload.selection?.selectedText) meta.has_selection = "true";
17996
18011
  break;
17997
18012
  case "document:opened":
17998
18013
  case "document:closed":
@@ -18008,14 +18023,13 @@ function formatEventMeta(event) {
18008
18023
 
18009
18024
  // src/channel/event-bridge.ts
18010
18025
  var AWARENESS_DEBOUNCE_MS = 500;
18011
- var SELECTION_DEBOUNCE_MS = 300;
18012
18026
  var MODE_CACHE_TTL_MS = 2e3;
18013
- async function startEventBridge(mcp2, tandemUrl) {
18027
+ async function startEventBridge(mcp, tandemUrl) {
18014
18028
  let retries = 0;
18015
18029
  let lastEventId;
18016
18030
  while (retries < CHANNEL_MAX_RETRIES) {
18017
18031
  try {
18018
- await connectAndStream(mcp2, tandemUrl, lastEventId, (id) => {
18032
+ await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
18019
18033
  lastEventId = id;
18020
18034
  retries = 0;
18021
18035
  });
@@ -18048,7 +18062,7 @@ async function startEventBridge(mcp2, tandemUrl) {
18048
18062
  }
18049
18063
  }
18050
18064
  }
18051
- async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
18065
+ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18052
18066
  const headers = { Accept: "text/event-stream" };
18053
18067
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
18054
18068
  const res = await fetch(`${tandemUrl}/api/events`, { headers });
@@ -18096,35 +18110,7 @@ async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
18096
18110
  if (awarenessTimer) clearTimeout(awarenessTimer);
18097
18111
  awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
18098
18112
  }
18099
- let selectionTimer = null;
18100
- let pendingSelection = null;
18101
- let transportBroken = false;
18102
- async function flushSelection() {
18103
- if (!pendingSelection) return;
18104
- const { event, eventId } = pendingSelection;
18105
- pendingSelection = null;
18106
- if (eventId) onEventId(eventId);
18107
- try {
18108
- await mcp2.notification({
18109
- method: "notifications/claude/channel",
18110
- params: {
18111
- content: formatEventContent(event),
18112
- meta: formatEventMeta(event)
18113
- }
18114
- });
18115
- } catch (err) {
18116
- console.error("[Channel] MCP notification failed (transport broken?):", err);
18117
- transportBroken = true;
18118
- return;
18119
- }
18120
- scheduleAwareness(event);
18121
- }
18122
- function isSelectionCleared(event) {
18123
- const p = event.payload;
18124
- return !p || p.from === p.to && !p.selectedText;
18125
- }
18126
18113
  while (true) {
18127
- if (transportBroken) throw new Error("MCP transport broken (detected in debounced flush)");
18128
18114
  const { done, value } = await reader.read();
18129
18115
  if (done) throw new Error("SSE stream ended");
18130
18116
  buffer += decoder.decode(value, { stream: true });
@@ -18159,17 +18145,9 @@ async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
18159
18145
  continue;
18160
18146
  }
18161
18147
  }
18162
- if (event.type === "selection:changed") {
18163
- if (eventId) onEventId(eventId);
18164
- if (isSelectionCleared(event)) continue;
18165
- pendingSelection = { event, eventId };
18166
- if (selectionTimer) clearTimeout(selectionTimer);
18167
- selectionTimer = setTimeout(flushSelection, SELECTION_DEBOUNCE_MS);
18168
- continue;
18169
- }
18170
18148
  if (eventId) onEventId(eventId);
18171
18149
  try {
18172
- await mcp2.notification({
18150
+ await mcp.notification({
18173
18151
  method: "notifications/claude/channel",
18174
18152
  params: {
18175
18153
  content: formatEventContent(event),
@@ -18208,11 +18186,143 @@ async function getCachedMode(tandemUrl) {
18208
18186
  return cachedMode;
18209
18187
  }
18210
18188
 
18211
- // src/channel/index.ts
18212
- console.log = console.error;
18213
- console.warn = console.error;
18214
- console.info = console.error;
18215
- var TANDEM_URL = process.env.TANDEM_URL || "http://localhost:3479";
18189
+ // src/channel/run.ts
18190
+ async function runChannel(opts = {}) {
18191
+ redirectConsoleToStderr();
18192
+ const tandemUrl = resolveTandemUrl();
18193
+ const mcp = new Server(
18194
+ { name: "tandem-channel", version: "0.1.0" },
18195
+ {
18196
+ capabilities: {
18197
+ experimental: {
18198
+ "claude/channel": {},
18199
+ "claude/channel/permission": {}
18200
+ },
18201
+ tools: {}
18202
+ },
18203
+ instructions: [
18204
+ 'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
18205
+ "These are real-time push notifications of user actions in the collaborative document editor.",
18206
+ "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18207
+ "chat:message, document:opened, document:closed, document:switched.",
18208
+ "Chat messages may include a 'selection' field with buffered selection context.",
18209
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
18210
+ "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18211
+ "Do not reply to non-chat events \u2014 just act on them using tools.",
18212
+ "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
18213
+ ].join(" ")
18214
+ }
18215
+ );
18216
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
18217
+ tools: [
18218
+ {
18219
+ name: "tandem_reply",
18220
+ description: "Reply to a chat message in Tandem",
18221
+ inputSchema: {
18222
+ type: "object",
18223
+ properties: {
18224
+ text: { type: "string", description: "The reply message" },
18225
+ documentId: {
18226
+ type: "string",
18227
+ description: "Document ID from the channel event (optional)"
18228
+ },
18229
+ replyTo: {
18230
+ type: "string",
18231
+ description: "Message ID being replied to (optional)"
18232
+ }
18233
+ },
18234
+ required: ["text"]
18235
+ }
18236
+ }
18237
+ ]
18238
+ }));
18239
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
18240
+ if (req.params.name === "tandem_reply") {
18241
+ const args = req.params.arguments;
18242
+ try {
18243
+ const res = await fetch(`${tandemUrl}/api/channel-reply`, {
18244
+ method: "POST",
18245
+ headers: { "Content-Type": "application/json" },
18246
+ body: JSON.stringify(args)
18247
+ });
18248
+ let data;
18249
+ try {
18250
+ data = await res.json();
18251
+ } catch {
18252
+ data = { message: "Non-JSON response" };
18253
+ }
18254
+ if (!res.ok) {
18255
+ return {
18256
+ content: [
18257
+ {
18258
+ type: "text",
18259
+ text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
18260
+ }
18261
+ ],
18262
+ isError: true
18263
+ };
18264
+ }
18265
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
18266
+ } catch (err) {
18267
+ return {
18268
+ content: [
18269
+ {
18270
+ type: "text",
18271
+ text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18272
+ }
18273
+ ],
18274
+ isError: true
18275
+ };
18276
+ }
18277
+ }
18278
+ throw new Error(`Unknown tool: ${req.params.name}`);
18279
+ });
18280
+ const PermissionRequestSchema = external_exports.object({
18281
+ method: external_exports.literal("notifications/claude/channel/permission_request"),
18282
+ params: external_exports.object({
18283
+ request_id: external_exports.string(),
18284
+ tool_name: external_exports.string(),
18285
+ description: external_exports.string(),
18286
+ input_preview: external_exports.string()
18287
+ })
18288
+ });
18289
+ mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18290
+ try {
18291
+ const res = await fetch(`${tandemUrl}/api/channel-permission`, {
18292
+ method: "POST",
18293
+ headers: { "Content-Type": "application/json" },
18294
+ body: JSON.stringify({
18295
+ requestId: params.request_id,
18296
+ toolName: params.tool_name,
18297
+ description: params.description,
18298
+ inputPreview: params.input_preview
18299
+ })
18300
+ });
18301
+ if (!res.ok) {
18302
+ console.error(
18303
+ `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18304
+ );
18305
+ }
18306
+ } catch (err) {
18307
+ console.error("[Channel] Failed to forward permission request:", err);
18308
+ }
18309
+ });
18310
+ console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);
18311
+ if (!opts.skipReachabilityLog) {
18312
+ const reachable = await checkServerReachable(tandemUrl);
18313
+ if (!reachable) {
18314
+ console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);
18315
+ console.error("[Channel] Start it with: tandem start");
18316
+ }
18317
+ }
18318
+ const transport = new StdioServerTransport();
18319
+ await mcp.connect(transport);
18320
+ console.error("[Channel] Connected to Claude Code via stdio");
18321
+ startEventBridge(mcp, tandemUrl).catch((err) => {
18322
+ console.error("[Channel] Event bridge failed unexpectedly:", err);
18323
+ process.exit(1);
18324
+ });
18325
+ }
18216
18326
  async function checkServerReachable(url, timeoutMs = 2e3) {
18217
18327
  let parsed;
18218
18328
  try {
@@ -18241,138 +18351,9 @@ async function checkServerReachable(url, timeoutMs = 2e3) {
18241
18351
  });
18242
18352
  });
18243
18353
  }
18244
- var mcp = new Server(
18245
- { name: "tandem-channel", version: "0.1.0" },
18246
- {
18247
- capabilities: {
18248
- experimental: {
18249
- "claude/channel": {},
18250
- "claude/channel/permission": {}
18251
- },
18252
- tools: {}
18253
- },
18254
- instructions: [
18255
- 'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
18256
- "These are real-time push notifications of user actions in the collaborative document editor.",
18257
- "Event types: annotation:created, annotation:accepted, annotation:dismissed,",
18258
- "chat:message, selection:changed, document:opened, document:closed, document:switched.",
18259
- "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
18260
- "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18261
- "Do not reply to non-chat events \u2014 just act on them using tools.",
18262
- "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
18263
- ].join(" ")
18264
- }
18265
- );
18266
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
18267
- tools: [
18268
- {
18269
- name: "tandem_reply",
18270
- description: "Reply to a chat message in Tandem",
18271
- inputSchema: {
18272
- type: "object",
18273
- properties: {
18274
- text: { type: "string", description: "The reply message" },
18275
- documentId: {
18276
- type: "string",
18277
- description: "Document ID from the channel event (optional)"
18278
- },
18279
- replyTo: {
18280
- type: "string",
18281
- description: "Message ID being replied to (optional)"
18282
- }
18283
- },
18284
- required: ["text"]
18285
- }
18286
- }
18287
- ]
18288
- }));
18289
- mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
18290
- if (req.params.name === "tandem_reply") {
18291
- const args = req.params.arguments;
18292
- try {
18293
- const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {
18294
- method: "POST",
18295
- headers: { "Content-Type": "application/json" },
18296
- body: JSON.stringify(args)
18297
- });
18298
- let data;
18299
- try {
18300
- data = await res.json();
18301
- } catch {
18302
- data = { message: "Non-JSON response" };
18303
- }
18304
- if (!res.ok) {
18305
- return {
18306
- content: [
18307
- {
18308
- type: "text",
18309
- text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
18310
- }
18311
- ],
18312
- isError: true
18313
- };
18314
- }
18315
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
18316
- } catch (err) {
18317
- return {
18318
- content: [
18319
- {
18320
- type: "text",
18321
- text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18322
- }
18323
- ],
18324
- isError: true
18325
- };
18326
- }
18327
- }
18328
- throw new Error(`Unknown tool: ${req.params.name}`);
18329
- });
18330
- var PermissionRequestSchema = external_exports.object({
18331
- method: external_exports.literal("notifications/claude/channel/permission_request"),
18332
- params: external_exports.object({
18333
- request_id: external_exports.string(),
18334
- tool_name: external_exports.string(),
18335
- description: external_exports.string(),
18336
- input_preview: external_exports.string()
18337
- })
18338
- });
18339
- mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18340
- try {
18341
- const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {
18342
- method: "POST",
18343
- headers: { "Content-Type": "application/json" },
18344
- body: JSON.stringify({
18345
- requestId: params.request_id,
18346
- toolName: params.tool_name,
18347
- description: params.description,
18348
- inputPreview: params.input_preview
18349
- })
18350
- });
18351
- if (!res.ok) {
18352
- console.error(
18353
- `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18354
- );
18355
- }
18356
- } catch (err) {
18357
- console.error("[Channel] Failed to forward permission request:", err);
18358
- }
18359
- });
18360
- async function main() {
18361
- console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);
18362
- const reachable = await checkServerReachable(TANDEM_URL);
18363
- if (!reachable) {
18364
- console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);
18365
- console.error("[Channel] Start it with: npm run dev:standalone");
18366
- }
18367
- const transport = new StdioServerTransport();
18368
- await mcp.connect(transport);
18369
- console.error("[Channel] Connected to Claude Code via stdio");
18370
- startEventBridge(mcp, TANDEM_URL).catch((err) => {
18371
- console.error("[Channel] Event bridge failed unexpectedly:", err);
18372
- process.exit(1);
18373
- });
18374
- }
18375
- main().catch((err) => {
18354
+
18355
+ // src/channel/index.ts
18356
+ runChannel().catch((err) => {
18376
18357
  console.error("[Channel] Fatal error:", err);
18377
18358
  process.exit(1);
18378
18359
  });