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.
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/shared/constants.ts
13
- var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE;
13
+ var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS;
14
14
  var init_constants = __esm({
15
15
  "src/shared/constants.ts"() {
16
16
  "use strict";
@@ -19,108 +19,22 @@ var init_constants = __esm({
19
19
  MAX_WS_PAYLOAD = 10 * 1024 * 1024;
20
20
  IDLE_TIMEOUT = 30 * 60 * 1e3;
21
21
  SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
22
+ CHANNEL_MAX_RETRIES = 5;
23
+ CHANNEL_RETRY_DELAY_MS = 2e3;
22
24
  }
23
25
  });
24
26
 
25
27
  // src/cli/skill-content.ts
26
- var SKILL_CONTENT;
28
+ import { readFileSync } from "fs";
29
+ import { dirname, resolve } from "path";
30
+ import { fileURLToPath } from "url";
31
+ var __dirname, SKILL_PATH, SKILL_CONTENT;
27
32
  var init_skill_content = __esm({
28
33
  "src/cli/skill-content.ts"() {
29
34
  "use strict";
30
- SKILL_CONTENT = `---
31
- name: tandem
32
- description: >
33
- Use when tandem_* MCP tools are available, the user asks about Tandem
34
- document editing, or collaborative document review. Provides workflow
35
- guidance, annotation strategy, and tool usage patterns for the Tandem
36
- collaborative editor.
37
- ---
38
-
39
- # Tandem \u2014 Collaborative Document Editor
40
-
41
- Tandem lets you annotate and edit documents alongside the user in real time. The user sees your changes in a browser editor; you interact via the tandem_* MCP tool suite.
42
-
43
- ## Hard Rules
44
-
45
- These prevent the most common failures. Follow them always.
46
-
47
- 1. **Resolve before mutating.** Call \`tandem_resolveRange\` (or \`tandem_search\`) to get offsets before calling \`tandem_edit\`, \`tandem_highlight\`, \`tandem_comment\`, \`tandem_suggest\`, or \`tandem_flag\`. Never compute offsets by counting characters in previously-read text \u2014 they go stale when the user edits.
48
- 2. **Pass \`textSnapshot\`.** Include the matched text as \`textSnapshot\` on mutations and annotations. If the text moved, the server returns \`RANGE_MOVED\` with relocated coordinates instead of corrupting the document.
49
- 3. **Use \`tandem_getTextContent\`, not \`tandem_getContent\`.** \`getContent\` returns ProseMirror JSON and burns tokens. Use \`getTextContent({ section: "Section Name" })\` for targeted reads. The \`section\` parameter is case-insensitive.
50
- 4. **\`tandem_edit\` cannot create paragraphs.** Newlines become literal characters. For multi-paragraph changes, use multiple \`tandem_edit\` calls or \`tandem_suggest\`.
51
- 5. **\`.docx\` files are read-only.** Use annotations instead of \`tandem_edit\`. Offer \`tandem_convertToMarkdown\` if the user wants an editable copy.
52
-
53
- ## Workflow
54
-
55
- Standard review sequence:
56
-
57
- 1. \`tandem_status\` \u2014 check for already-open documents (sessions restore automatically)
58
- 2. \`tandem_getOutline\` \u2014 understand document structure
59
- 3. \`tandem_setStatus("Reviewing [section]...", { focusParagraph: N })\` \u2014 show progress (use \`index\` from outline)
60
- 4. \`tandem_getTextContent({ section: "..." })\` \u2014 read one section at a time
61
- 5. Annotate findings (see annotation guide below)
62
- 6. \`tandem_checkInbox\` \u2014 check for user messages and actions
63
- 7. Repeat steps 3-6 for each section
64
- 8. \`tandem_save\` \u2014 persist edits to disk when done
65
-
66
- ## Annotation Guide
67
-
68
- Choose the right type for each finding:
69
-
70
- - **\`tandem_highlight\`** \u2014 Visual marker with a short note. Colors: green (verified/good), red (problem), yellow (needs attention). Use when the finding is self-evident from the color and a brief note.
71
- - **\`tandem_comment\`** \u2014 Observation requiring explanation. Use when you need more than one sentence to convey reasoning.
72
- - **\`tandem_suggest\`** \u2014 Specific text replacement. **Prefer over comment when you can provide replacement text** \u2014 the user gets one-click accept/reject. Cannot create new paragraphs.
73
- - **\`tandem_flag\`** \u2014 Factual errors, compliance risks, missing required content. Signals a blocking issue the user must address before the document ships.
74
-
75
- **User-created types:** \`question\` annotation is created by users, not Claude. When you see a \`question\` in \`tandem_checkInbox\` or \`tandem_getAnnotations\`, respond with a \`tandem_comment\` on the same range or \`tandem_reply\` for conversational answers.
76
-
77
- ## Collaboration Mode
78
-
79
- Check \`mode\` from \`tandem_status\` or \`tandem_checkInbox\` and adapt:
80
-
81
- - **Tandem** (\`"tandem"\`, default) \u2014 Full collaboration. Annotate freely and react to selections and document changes.
82
- - **Solo** (\`"solo"\`) \u2014 The user wants to write undisturbed. Only respond when the user sends a chat message. Do not proactively annotate or react to document activity.
83
-
84
- ## Reacting to Document Events
85
-
86
- Selection events can reach you two ways. Over the real-time channel they arrive as notifications with \`meta.respond_via = "tandem_reply"\`. When polling via \`tandem_checkInbox\`, the current selection shows up under \`activity.selectedText\` (no \`meta\` field \u2014 that only exists on channel pushes). Either way, when the user holds a selection, briefly acknowledge what they highlighted via \`tandem_reply\` \u2014 don't annotate unless asked. Use \`tandem_reply\` for any document-context reaction (chat messages, selections, question annotations); reserve terminal output for non-document work the user explicitly requests. In Solo mode, hold reactions until the user sends a chat message.
87
-
88
- ## Collaboration Etiquette
89
-
90
- - Check \`tandem_getActivity()\` before annotating near the user's cursor. If \`isTyping\` is true, wait for typing to stop before annotating that area.
91
- - Use \`tandem_setStatus\` to show what you're working on \u2014 the user sees it in the browser status bar.
92
- - **Call \`tandem_checkInbox\` every 2-3 tool calls**, not just at the end of a task. The real-time channel is often not connected; polling is the reliable path.
93
- - Reply to chat messages with \`tandem_reply\`, not annotations.
94
-
95
- ## .docx Review Workflow
96
-
97
- 1. \`tandem_open\` \u2014 opens in read-only mode (\`readOnly: true\`)
98
- 2. \`tandem_getAnnotations({ author: "import" })\` \u2014 check for imported Word comments; read and act on them
99
- 3. Annotate with findings (highlight, comment, suggest, flag)
100
- 4. \`tandem_exportAnnotations\` \u2014 generate a review summary the user can share
101
- 5. If the user wants editable text, offer \`tandem_convertToMarkdown\`
102
-
103
- ## Error Recovery
104
-
105
- - **\`RANGE_MOVED\`** \u2014 Text shifted since you read it. The response includes \`resolvedFrom\`/\`resolvedTo\` \u2014 use those coordinates for your next call.
106
- - **\`RANGE_GONE\`** \u2014 The text was deleted. Re-read the section with \`tandem_getTextContent\` and re-assess.
107
- - **\`INVALID_RANGE\`** \u2014 You hit heading markup (e.g., \`## \`). Target text content only, not the heading prefix.
108
- - **\`FORMAT_ERROR\`** \u2014 Attempted \`tandem_edit\` on a read-only \`.docx\`. Use annotations instead.
109
-
110
- ## Session Handoff
111
-
112
- When starting a new Claude session with Tandem already running:
113
-
114
- 1. \`tandem_status()\` \u2014 check \`openDocuments\` array for restored sessions
115
- 2. \`tandem_listDocuments()\` \u2014 see all open docs with details
116
- 3. \`tandem_getOutline()\` \u2014 orient on the active document
117
- 4. \`tandem_getAnnotations()\` \u2014 see what was already reviewed
118
- 5. Continue where the previous session left off
119
-
120
- ## Multi-Document
121
-
122
- When multiple documents are open, always pass \`documentId\` explicitly \u2014 omitting it targets the active document, which may have changed since your last call. Use \`tandem_listDocuments\` to see what's available. Cross-reference by reading both docs via \`tandem_getTextContent({ documentId: "..." })\` and annotating the relevant one.
123
- `;
35
+ __dirname = dirname(fileURLToPath(import.meta.url));
36
+ SKILL_PATH = resolve(__dirname, "../../skills/tandem/SKILL.md");
37
+ SKILL_CONTENT = readFileSync(SKILL_PATH, "utf-8");
124
38
  }
125
39
  });
126
40
 
@@ -131,26 +45,27 @@ __export(setup_exports, {
131
45
  buildMcpEntries: () => buildMcpEntries,
132
46
  detectTargets: () => detectTargets,
133
47
  installSkill: () => installSkill,
134
- runSetup: () => runSetup
48
+ runSetup: () => runSetup,
49
+ validateChannelShimPrereq: () => validateChannelShimPrereq
135
50
  });
136
51
  import { randomUUID } from "crypto";
137
- import { existsSync, readFileSync } from "fs";
52
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
138
53
  import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
139
54
  import { homedir } from "os";
140
- import { dirname, join, resolve } from "path";
141
- import { fileURLToPath } from "url";
142
- function buildMcpEntries(channelPath, nodeBinary = "node") {
143
- return {
144
- tandem: {
145
- type: "http",
146
- url: `${MCP_URL}/mcp`
147
- },
148
- "tandem-channel": {
149
- command: nodeBinary,
55
+ import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
56
+ import { fileURLToPath as fileURLToPath2 } from "url";
57
+ function buildMcpEntries(channelPath, opts = {}) {
58
+ const entries = {
59
+ tandem: { type: "http", url: `${MCP_URL}/mcp` }
60
+ };
61
+ if (opts.withChannelShim) {
62
+ entries["tandem-channel"] = {
63
+ command: opts.nodeBinary ?? "node",
150
64
  args: [channelPath],
151
65
  env: { TANDEM_URL: MCP_URL }
152
- }
153
- };
66
+ };
67
+ }
68
+ return entries;
154
69
  }
155
70
  function detectTargets(opts = {}) {
156
71
  const home = opts.homeOverride ?? homedir();
@@ -181,7 +96,7 @@ function detectTargets(opts = {}) {
181
96
  return targets;
182
97
  }
183
98
  async function atomicWrite(content, dest) {
184
- const tmp = join(dirname(dest), `.tandem-setup-${randomUUID()}.tmp`);
99
+ const tmp = join(dirname2(dest), `.tandem-setup-${randomUUID()}.tmp`);
185
100
  await writeFile(tmp, content, "utf-8");
186
101
  try {
187
102
  await rename(tmp, dest);
@@ -202,14 +117,23 @@ async function atomicWrite(content, dest) {
202
117
  async function applyConfig(configPath, entries) {
203
118
  let existing = {};
204
119
  try {
205
- existing = JSON.parse(readFileSync(configPath, "utf-8"));
120
+ existing = JSON.parse(readFileSync2(configPath, "utf-8"));
206
121
  } catch (err) {
207
122
  const code = err.code;
208
123
  if (code === "ENOENT") {
209
124
  } else if (err instanceof SyntaxError) {
210
- console.error(
211
- ` Warning: ${configPath} contains malformed JSON \u2014 replacing with fresh config`
212
- );
125
+ const backupPath = `${configPath}.broken-${Date.now()}`;
126
+ try {
127
+ await copyFile(configPath, backupPath);
128
+ console.error(
129
+ ` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${basename(backupPath)}, replacing with fresh config`
130
+ );
131
+ } catch (copyErr) {
132
+ console.error(
133
+ ` Warning: ${configPath} contains malformed JSON and backup failed (${copyErr instanceof Error ? copyErr.message : copyErr}) \u2014 refusing to overwrite. Fix the JSON manually and rerun 'tandem setup'.`
134
+ );
135
+ throw copyErr;
136
+ }
213
137
  } else {
214
138
  throw err;
215
139
  }
@@ -221,17 +145,27 @@ async function applyConfig(configPath, entries) {
221
145
  ...entries
222
146
  }
223
147
  };
224
- await mkdir(dirname(configPath), { recursive: true });
148
+ await mkdir(dirname2(configPath), { recursive: true });
225
149
  await atomicWrite(JSON.stringify(updated, null, 2) + "\n", configPath);
226
150
  }
227
151
  async function installSkill(opts = {}) {
228
152
  const home = opts.homeOverride ?? homedir();
229
153
  const skillPath = join(home, ".claude", "skills", "tandem", "SKILL.md");
230
- await mkdir(dirname(skillPath), { recursive: true });
154
+ await mkdir(dirname2(skillPath), { recursive: true });
231
155
  await atomicWrite(SKILL_CONTENT, skillPath);
232
156
  }
157
+ function validateChannelShimPrereq(channelPath) {
158
+ return existsSync(channelPath);
159
+ }
233
160
  async function runSetup(opts = {}) {
234
161
  console.error("\nTandem Setup\n");
162
+ if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
163
+ console.error(
164
+ `Error: --with-channel-shim requires dist/channel/index.js at ${CHANNEL_DIST}
165
+ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor.`
166
+ );
167
+ process.exit(1);
168
+ }
235
169
  console.error("Detecting Claude installations...");
236
170
  const targets = detectTargets({ force: opts.force });
237
171
  if (targets.length === 0) {
@@ -244,7 +178,7 @@ async function runSetup(opts = {}) {
244
178
  console.error(` Found: ${t.label} (${t.configPath})`);
245
179
  }
246
180
  console.error("\nWriting MCP configuration...");
247
- const entries = buildMcpEntries(CHANNEL_DIST);
181
+ const entries = buildMcpEntries(CHANNEL_DIST, { withChannelShim: opts.withChannelShim });
248
182
  let failures = 0;
249
183
  for (const t of targets) {
250
184
  try {
@@ -279,23 +213,656 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
279
213
  );
280
214
  }
281
215
  if (failures < targets.length) {
216
+ const pluginManifest = join(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
217
+ const devInstructions = existsSync(pluginManifest) ? ` Or for development, load directly from this package:
218
+
219
+ claude --plugin-dir ${PACKAGE_ROOT}
220
+
221
+ ` : ` (Development plugin dir not found at ${pluginManifest}; skipping local-plugin instructions.)
222
+
223
+ `;
282
224
  console.error(
283
- "\n\x1B[1mReal-time push notifications (optional):\x1B[0m\n To receive chat messages and events instantly (instead of polling),\n start Claude Code with the channel flag:\n\n claude --dangerously-load-development-channels server:tandem-channel\n\n Without this flag, Claude still works but relies on tandem_checkInbox polling.\n"
225
+ "\n\x1B[1mReal-time push notifications (recommended):\x1B[0m\n Install the Tandem plugin for instant events (one-time):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions + " Without the plugin, Claude still works but relies on tandem_checkInbox polling.\n"
284
226
  );
285
227
  }
286
228
  }
287
- var __dirname, CHANNEL_DIST, MCP_URL;
229
+ var __dirname2, PACKAGE_ROOT, CHANNEL_DIST, MCP_URL;
288
230
  var init_setup = __esm({
289
231
  "src/cli/setup.ts"() {
290
232
  "use strict";
291
233
  init_constants();
292
234
  init_skill_content();
293
- __dirname = dirname(fileURLToPath(import.meta.url));
294
- CHANNEL_DIST = resolve(__dirname, "../channel/index.js");
235
+ __dirname2 = dirname2(fileURLToPath2(import.meta.url));
236
+ PACKAGE_ROOT = resolve2(__dirname2, "../..");
237
+ CHANNEL_DIST = resolve2(PACKAGE_ROOT, "dist/channel/index.js");
295
238
  MCP_URL = `http://localhost:${DEFAULT_MCP_PORT}`;
296
239
  }
297
240
  });
298
241
 
242
+ // src/shared/cli-runtime.ts
243
+ function redirectConsoleToStderr() {
244
+ console.log = console.error;
245
+ console.warn = console.error;
246
+ console.info = console.error;
247
+ }
248
+ function resolveTandemUrl(override) {
249
+ const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
250
+ return raw.replace(/\/$/, "");
251
+ }
252
+ var init_cli_runtime = __esm({
253
+ "src/shared/cli-runtime.ts"() {
254
+ "use strict";
255
+ init_constants();
256
+ }
257
+ });
258
+
259
+ // src/cli/preflight.ts
260
+ async function ensureTandemServer(opts = {}) {
261
+ const url = resolveTandemUrl(opts.url);
262
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
263
+ const controller = new AbortController();
264
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
265
+ try {
266
+ const res = await fetch(`${url}/health`, { signal: controller.signal });
267
+ if (!res.ok) {
268
+ fail(url, `health endpoint returned HTTP ${res.status}`);
269
+ }
270
+ } catch (err) {
271
+ const msg = err instanceof Error ? err.message : String(err);
272
+ fail(url, msg);
273
+ } finally {
274
+ clearTimeout(timer);
275
+ }
276
+ }
277
+ function fail(url, detail) {
278
+ process.stderr.write(
279
+ `[tandem] Tandem server not reachable at ${url} (${detail}).
280
+ [tandem] Start the Tauri app or run \`tandem start\` on the host, then retry.
281
+ `
282
+ );
283
+ process.exit(1);
284
+ }
285
+ var DEFAULT_TIMEOUT_MS;
286
+ var init_preflight = __esm({
287
+ "src/cli/preflight.ts"() {
288
+ "use strict";
289
+ init_cli_runtime();
290
+ DEFAULT_TIMEOUT_MS = 2e3;
291
+ }
292
+ });
293
+
294
+ // src/cli/mcp-stdio.ts
295
+ var mcp_stdio_exports = {};
296
+ __export(mcp_stdio_exports, {
297
+ getRequestId: () => getRequestId,
298
+ runMcpStdio: () => runMcpStdio
299
+ });
300
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
301
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
302
+ async function runMcpStdio() {
303
+ const baseUrl = resolveTandemUrl();
304
+ await ensureTandemServer({ url: baseUrl });
305
+ const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
306
+ const stdio = new StdioServerTransport();
307
+ let shuttingDown = false;
308
+ const shutdown = async (code = 0) => {
309
+ if (!shuttingDown) {
310
+ shuttingDown = true;
311
+ await http.close().catch(() => {
312
+ });
313
+ await stdio.close().catch(() => {
314
+ });
315
+ }
316
+ process.exit(code);
317
+ };
318
+ stdio.onmessage = (msg) => {
319
+ http.send(msg).catch((err) => {
320
+ const detail = err instanceof Error ? err.message : String(err);
321
+ process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
322
+ `);
323
+ const requestId = getRequestId(msg);
324
+ if (requestId !== void 0) {
325
+ const errorResponse = {
326
+ jsonrpc: "2.0",
327
+ id: requestId,
328
+ error: {
329
+ // -32000 is the implementation-defined server error range per
330
+ // JSON-RPC 2.0 §5.1 — the upstream being unreachable is an
331
+ // application-level condition, not a generic Internal Error.
332
+ code: -32e3,
333
+ message: "Tandem HTTP upstream unreachable",
334
+ data: { detail }
335
+ }
336
+ };
337
+ stdio.send(errorResponse).catch(() => {
338
+ });
339
+ }
340
+ });
341
+ };
342
+ http.onmessage = (msg) => {
343
+ stdio.send(msg).catch((err) => {
344
+ const detail = err instanceof Error ? err.message : String(err);
345
+ process.stderr.write(`[tandem mcp-stdio] stdio write failed: ${detail}
346
+ `);
347
+ });
348
+ };
349
+ stdio.onerror = (err) => {
350
+ process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
351
+ `);
352
+ };
353
+ http.onerror = (err) => {
354
+ process.stderr.write(`[tandem mcp-stdio] http error: ${err.message}
355
+ `);
356
+ };
357
+ stdio.onclose = () => {
358
+ void shutdown(0);
359
+ };
360
+ http.onclose = () => {
361
+ void shutdown(0);
362
+ };
363
+ await stdio.start();
364
+ try {
365
+ await http.start();
366
+ } catch (err) {
367
+ const detail = err instanceof Error ? err.message : String(err);
368
+ process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}
369
+ `);
370
+ await shutdown(1);
371
+ }
372
+ }
373
+ function getRequestId(msg) {
374
+ const m = msg;
375
+ if (typeof m.method !== "string") return void 0;
376
+ if (typeof m.id === "string" || typeof m.id === "number") return m.id;
377
+ return void 0;
378
+ }
379
+ var init_mcp_stdio = __esm({
380
+ "src/cli/mcp-stdio.ts"() {
381
+ "use strict";
382
+ init_cli_runtime();
383
+ init_preflight();
384
+ redirectConsoleToStderr();
385
+ }
386
+ });
387
+
388
+ // src/shared/utils.ts
389
+ var init_utils = __esm({
390
+ "src/shared/utils.ts"() {
391
+ "use strict";
392
+ }
393
+ });
394
+
395
+ // src/server/events/types.ts
396
+ function parseTandemEvent(raw) {
397
+ if (typeof raw !== "object" || raw === null || !("id" in raw) || typeof raw.id !== "string" || !("type" in raw) || !VALID_EVENT_TYPES.has(raw.type) || !("timestamp" in raw) || typeof raw.timestamp !== "number" || !("payload" in raw) || typeof raw.payload !== "object") {
398
+ return null;
399
+ }
400
+ return raw;
401
+ }
402
+ function formatEventContent(event) {
403
+ const doc = event.documentId ? ` [doc: ${event.documentId}]` : "";
404
+ switch (event.type) {
405
+ case "annotation:created": {
406
+ const { annotationType, content, textSnippet, hasSuggestedText, directedAt } = event.payload;
407
+ const snippet = textSnippet ? ` on "${textSnippet}"` : "";
408
+ const label = hasSuggestedText ? "replacement" : directedAt === "claude" ? "question for Claude" : annotationType;
409
+ return `User created ${label}${snippet}: ${content || "(no content)"}${doc}`;
410
+ }
411
+ case "annotation:accepted": {
412
+ const { annotationId, textSnippet } = event.payload;
413
+ return `User accepted annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
414
+ }
415
+ case "annotation:dismissed": {
416
+ const { annotationId, textSnippet } = event.payload;
417
+ return `User dismissed annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
418
+ }
419
+ case "annotation:reply": {
420
+ const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;
421
+ const who = replyAuthor === "claude" ? "Claude" : "User";
422
+ const snippet = textSnippet ? ` (on "${textSnippet}")` : "";
423
+ return `${who} replied to annotation ${annotationId}${snippet}: ${replyText}${doc}`;
424
+ }
425
+ case "chat:message": {
426
+ const { text, replyTo, selection } = event.payload;
427
+ const reply = replyTo ? ` (replying to ${replyTo})` : "";
428
+ const sel = selection && selection.selectedText ? ` [selection: "${selection.selectedText}"${"from" in selection ? ` (${selection.from}-${selection.to})` : ""}]` : "";
429
+ return `User says${reply}: ${text}${sel}${doc}`;
430
+ }
431
+ case "document:opened": {
432
+ const { fileName, format } = event.payload;
433
+ return `User opened document: ${fileName} (${format})${doc}`;
434
+ }
435
+ case "document:closed": {
436
+ const { fileName } = event.payload;
437
+ return `User closed document: ${fileName}${doc}`;
438
+ }
439
+ case "document:switched": {
440
+ const { fileName } = event.payload;
441
+ return `User switched to document: ${fileName}${doc}`;
442
+ }
443
+ default: {
444
+ const _exhaustive = event;
445
+ return `Unknown event${doc}`;
446
+ }
447
+ }
448
+ }
449
+ function formatEventMeta(event) {
450
+ const meta = {
451
+ event_type: event.type
452
+ };
453
+ if (event.documentId) meta.document_id = event.documentId;
454
+ switch (event.type) {
455
+ case "annotation:created":
456
+ case "annotation:accepted":
457
+ case "annotation:dismissed":
458
+ meta.annotation_id = event.payload.annotationId;
459
+ break;
460
+ case "annotation:reply":
461
+ meta.annotation_id = event.payload.annotationId;
462
+ meta.reply_id = event.payload.replyId;
463
+ break;
464
+ case "chat:message":
465
+ meta.message_id = event.payload.messageId;
466
+ if (event.payload.selection?.selectedText) meta.has_selection = "true";
467
+ break;
468
+ case "document:opened":
469
+ case "document:closed":
470
+ case "document:switched":
471
+ break;
472
+ default: {
473
+ const _exhaustive = event;
474
+ break;
475
+ }
476
+ }
477
+ return meta;
478
+ }
479
+ var VALID_EVENT_TYPES;
480
+ var init_types = __esm({
481
+ "src/server/events/types.ts"() {
482
+ "use strict";
483
+ init_utils();
484
+ VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
485
+ "annotation:created",
486
+ "annotation:accepted",
487
+ "annotation:dismissed",
488
+ "annotation:reply",
489
+ "chat:message",
490
+ "document:opened",
491
+ "document:closed",
492
+ "document:switched"
493
+ ]);
494
+ }
495
+ });
496
+
497
+ // src/channel/event-bridge.ts
498
+ async function startEventBridge(mcp, tandemUrl) {
499
+ let retries = 0;
500
+ let lastEventId;
501
+ while (retries < CHANNEL_MAX_RETRIES) {
502
+ try {
503
+ await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
504
+ lastEventId = id;
505
+ retries = 0;
506
+ });
507
+ } catch (err) {
508
+ retries++;
509
+ console.error(
510
+ `[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
511
+ err instanceof Error ? err.message : err
512
+ );
513
+ if (retries >= CHANNEL_MAX_RETRIES) {
514
+ console.error("[Channel] SSE connection exhausted, reporting error and exiting");
515
+ try {
516
+ await fetch(`${tandemUrl}/api/channel-error`, {
517
+ method: "POST",
518
+ headers: { "Content-Type": "application/json" },
519
+ body: JSON.stringify({
520
+ error: "CHANNEL_CONNECT_FAILED",
521
+ message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
522
+ })
523
+ });
524
+ } catch (reportErr) {
525
+ console.error(
526
+ "[Channel] Could not report failure to server:",
527
+ reportErr instanceof Error ? reportErr.message : reportErr
528
+ );
529
+ }
530
+ process.exit(1);
531
+ }
532
+ await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));
533
+ }
534
+ }
535
+ }
536
+ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
537
+ const headers = { Accept: "text/event-stream" };
538
+ if (lastEventId) headers["Last-Event-ID"] = lastEventId;
539
+ const res = await fetch(`${tandemUrl}/api/events`, { headers });
540
+ if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
541
+ if (!res.body) throw new Error("SSE endpoint returned no body");
542
+ const reader = res.body.getReader();
543
+ const decoder = new TextDecoder();
544
+ let buffer = "";
545
+ let awarenessTimer = null;
546
+ let clearAwarenessTimer = null;
547
+ let pendingAwareness = null;
548
+ const AWARENESS_CLEAR_MS = 3e3;
549
+ function clearAwareness(documentId) {
550
+ fetch(`${tandemUrl}/api/channel-awareness`, {
551
+ method: "POST",
552
+ headers: { "Content-Type": "application/json" },
553
+ body: JSON.stringify({
554
+ documentId: documentId ?? null,
555
+ status: "idle",
556
+ active: false
557
+ })
558
+ }).catch(() => {
559
+ });
560
+ }
561
+ function flushAwareness() {
562
+ if (!pendingAwareness) return;
563
+ const event = pendingAwareness;
564
+ pendingAwareness = null;
565
+ fetch(`${tandemUrl}/api/channel-awareness`, {
566
+ method: "POST",
567
+ headers: { "Content-Type": "application/json" },
568
+ body: JSON.stringify({
569
+ documentId: event.documentId,
570
+ status: `processing: ${event.type}`,
571
+ active: true
572
+ })
573
+ }).catch((err) => {
574
+ console.error("[Channel] Awareness update failed:", err instanceof Error ? err.message : err);
575
+ });
576
+ if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
577
+ clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
578
+ }
579
+ function scheduleAwareness(event) {
580
+ pendingAwareness = event;
581
+ if (awarenessTimer) clearTimeout(awarenessTimer);
582
+ awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
583
+ }
584
+ while (true) {
585
+ const { done, value } = await reader.read();
586
+ if (done) throw new Error("SSE stream ended");
587
+ buffer += decoder.decode(value, { stream: true });
588
+ let boundary;
589
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
590
+ const frame = buffer.slice(0, boundary);
591
+ buffer = buffer.slice(boundary + 2);
592
+ if (frame.startsWith(":")) continue;
593
+ let eventId;
594
+ let data;
595
+ for (const line of frame.split("\n")) {
596
+ if (line.startsWith("id: ")) eventId = line.slice(4);
597
+ else if (line.startsWith("data: ")) data = line.slice(6);
598
+ }
599
+ if (!data) continue;
600
+ let event;
601
+ try {
602
+ event = parseTandemEvent(JSON.parse(data));
603
+ } catch {
604
+ console.error("[Channel] Malformed SSE event data (skipping):", data.slice(0, 200));
605
+ continue;
606
+ }
607
+ if (!event) {
608
+ console.error("[Channel] Received invalid SSE event, skipping");
609
+ continue;
610
+ }
611
+ if (event.type !== "chat:message") {
612
+ const mode = await getCachedMode(tandemUrl);
613
+ if (mode === "solo") {
614
+ console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
615
+ if (eventId) onEventId(eventId);
616
+ continue;
617
+ }
618
+ }
619
+ if (eventId) onEventId(eventId);
620
+ try {
621
+ await mcp.notification({
622
+ method: "notifications/claude/channel",
623
+ params: {
624
+ content: formatEventContent(event),
625
+ meta: formatEventMeta(event)
626
+ }
627
+ });
628
+ } catch (err) {
629
+ console.error("[Channel] MCP notification failed (transport broken?):", err);
630
+ throw err;
631
+ }
632
+ scheduleAwareness(event);
633
+ }
634
+ }
635
+ }
636
+ async function getCachedMode(tandemUrl) {
637
+ const now = Date.now();
638
+ if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
639
+ try {
640
+ const res = await fetch(`${tandemUrl}/api/mode`);
641
+ if (res.ok) {
642
+ const { mode } = await res.json();
643
+ cachedMode = mode;
644
+ } else {
645
+ console.error(`[Channel] Mode check returned ${res.status}, using cached: "${cachedMode}"`);
646
+ }
647
+ cachedModeAt = now;
648
+ } catch (err) {
649
+ console.error(
650
+ "[Channel] Mode check failed, delivering event (fail-open):",
651
+ err instanceof Error ? err.message : err
652
+ );
653
+ cachedModeAt = now;
654
+ }
655
+ return cachedMode;
656
+ }
657
+ var AWARENESS_DEBOUNCE_MS, MODE_CACHE_TTL_MS, cachedMode, cachedModeAt;
658
+ var init_event_bridge = __esm({
659
+ "src/channel/event-bridge.ts"() {
660
+ "use strict";
661
+ init_types();
662
+ init_constants();
663
+ AWARENESS_DEBOUNCE_MS = 500;
664
+ MODE_CACHE_TTL_MS = 2e3;
665
+ cachedMode = "tandem";
666
+ cachedModeAt = 0;
667
+ }
668
+ });
669
+
670
+ // src/channel/run.ts
671
+ import { createConnection } from "net";
672
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
673
+ import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
674
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
675
+ import { z } from "zod";
676
+ async function runChannel(opts = {}) {
677
+ redirectConsoleToStderr();
678
+ const tandemUrl = resolveTandemUrl();
679
+ const mcp = new Server(
680
+ { name: "tandem-channel", version: "0.1.0" },
681
+ {
682
+ capabilities: {
683
+ experimental: {
684
+ "claude/channel": {},
685
+ "claude/channel/permission": {}
686
+ },
687
+ tools: {}
688
+ },
689
+ instructions: [
690
+ 'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
691
+ "These are real-time push notifications of user actions in the collaborative document editor.",
692
+ "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
693
+ "chat:message, document:opened, document:closed, document:switched.",
694
+ "Chat messages may include a 'selection' field with buffered selection context.",
695
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
696
+ "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
697
+ "Do not reply to non-chat events \u2014 just act on them using tools.",
698
+ "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
699
+ ].join(" ")
700
+ }
701
+ );
702
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
703
+ tools: [
704
+ {
705
+ name: "tandem_reply",
706
+ description: "Reply to a chat message in Tandem",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ text: { type: "string", description: "The reply message" },
711
+ documentId: {
712
+ type: "string",
713
+ description: "Document ID from the channel event (optional)"
714
+ },
715
+ replyTo: {
716
+ type: "string",
717
+ description: "Message ID being replied to (optional)"
718
+ }
719
+ },
720
+ required: ["text"]
721
+ }
722
+ }
723
+ ]
724
+ }));
725
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
726
+ if (req.params.name === "tandem_reply") {
727
+ const args2 = req.params.arguments;
728
+ try {
729
+ const res = await fetch(`${tandemUrl}/api/channel-reply`, {
730
+ method: "POST",
731
+ headers: { "Content-Type": "application/json" },
732
+ body: JSON.stringify(args2)
733
+ });
734
+ let data;
735
+ try {
736
+ data = await res.json();
737
+ } catch {
738
+ data = { message: "Non-JSON response" };
739
+ }
740
+ if (!res.ok) {
741
+ return {
742
+ content: [
743
+ {
744
+ type: "text",
745
+ text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
746
+ }
747
+ ],
748
+ isError: true
749
+ };
750
+ }
751
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
752
+ } catch (err) {
753
+ return {
754
+ content: [
755
+ {
756
+ type: "text",
757
+ text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
758
+ }
759
+ ],
760
+ isError: true
761
+ };
762
+ }
763
+ }
764
+ throw new Error(`Unknown tool: ${req.params.name}`);
765
+ });
766
+ const PermissionRequestSchema = z.object({
767
+ method: z.literal("notifications/claude/channel/permission_request"),
768
+ params: z.object({
769
+ request_id: z.string(),
770
+ tool_name: z.string(),
771
+ description: z.string(),
772
+ input_preview: z.string()
773
+ })
774
+ });
775
+ mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
776
+ try {
777
+ const res = await fetch(`${tandemUrl}/api/channel-permission`, {
778
+ method: "POST",
779
+ headers: { "Content-Type": "application/json" },
780
+ body: JSON.stringify({
781
+ requestId: params.request_id,
782
+ toolName: params.tool_name,
783
+ description: params.description,
784
+ inputPreview: params.input_preview
785
+ })
786
+ });
787
+ if (!res.ok) {
788
+ console.error(
789
+ `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
790
+ );
791
+ }
792
+ } catch (err) {
793
+ console.error("[Channel] Failed to forward permission request:", err);
794
+ }
795
+ });
796
+ console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);
797
+ if (!opts.skipReachabilityLog) {
798
+ const reachable = await checkServerReachable(tandemUrl);
799
+ if (!reachable) {
800
+ console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);
801
+ console.error("[Channel] Start it with: tandem start");
802
+ }
803
+ }
804
+ const transport = new StdioServerTransport2();
805
+ await mcp.connect(transport);
806
+ console.error("[Channel] Connected to Claude Code via stdio");
807
+ startEventBridge(mcp, tandemUrl).catch((err) => {
808
+ console.error("[Channel] Event bridge failed unexpectedly:", err);
809
+ process.exit(1);
810
+ });
811
+ }
812
+ async function checkServerReachable(url, timeoutMs = 2e3) {
813
+ let parsed;
814
+ try {
815
+ parsed = new URL(url);
816
+ } catch {
817
+ console.error(
818
+ `[Channel] Invalid TANDEM_URL: "${url}" \u2014 expected format: http://localhost:3479`
819
+ );
820
+ return false;
821
+ }
822
+ const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);
823
+ return new Promise((resolve4) => {
824
+ const socket = createConnection({ port, host: parsed.hostname }, () => {
825
+ socket.destroy();
826
+ resolve4(true);
827
+ });
828
+ socket.setTimeout(timeoutMs);
829
+ socket.on("timeout", () => {
830
+ socket.destroy();
831
+ resolve4(false);
832
+ });
833
+ socket.on("error", (err) => {
834
+ console.error(`[Channel] Server probe failed: ${err.message}`);
835
+ socket.destroy();
836
+ resolve4(false);
837
+ });
838
+ });
839
+ }
840
+ var init_run = __esm({
841
+ "src/channel/run.ts"() {
842
+ "use strict";
843
+ init_cli_runtime();
844
+ init_constants();
845
+ init_event_bridge();
846
+ }
847
+ });
848
+
849
+ // src/cli/channel.ts
850
+ var channel_exports = {};
851
+ __export(channel_exports, {
852
+ runChannelCli: () => runChannelCli
853
+ });
854
+ async function runChannelCli() {
855
+ await ensureTandemServer();
856
+ await runChannel({ skipReachabilityLog: true });
857
+ }
858
+ var init_channel = __esm({
859
+ "src/cli/channel.ts"() {
860
+ "use strict";
861
+ init_run();
862
+ init_preflight();
863
+ }
864
+ });
865
+
299
866
  // src/cli/start.ts
300
867
  var start_exports = {};
301
868
  __export(start_exports, {
@@ -303,8 +870,8 @@ __export(start_exports, {
303
870
  });
304
871
  import { spawn } from "child_process";
305
872
  import { existsSync as existsSync2 } from "fs";
306
- import { dirname as dirname2, resolve as resolve2 } from "path";
307
- import { fileURLToPath as fileURLToPath2 } from "url";
873
+ import { dirname as dirname3, resolve as resolve3 } from "path";
874
+ import { fileURLToPath as fileURLToPath3 } from "url";
308
875
  function runStart() {
309
876
  if (!existsSync2(SERVER_DIST)) {
310
877
  console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
@@ -327,27 +894,36 @@ function runStart() {
327
894
  process.once(sig, () => proc.kill());
328
895
  }
329
896
  }
330
- var __dirname2, SERVER_DIST;
897
+ var __dirname3, SERVER_DIST;
331
898
  var init_start = __esm({
332
899
  "src/cli/start.ts"() {
333
900
  "use strict";
334
- __dirname2 = dirname2(fileURLToPath2(import.meta.url));
335
- SERVER_DIST = resolve2(__dirname2, "../server/index.js");
901
+ __dirname3 = dirname3(fileURLToPath3(import.meta.url));
902
+ SERVER_DIST = resolve3(__dirname3, "../server/index.js");
336
903
  }
337
904
  });
338
905
 
339
906
  // src/cli/index.ts
340
907
  import updateNotifier from "update-notifier";
341
- var version = true ? "0.4.0" : "0.0.0-dev";
342
- updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
908
+ var version = true ? "0.6.0" : "0.0.0-dev";
343
909
  var args = process.argv.slice(2);
910
+ var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
911
+ if (!isStdioMode) {
912
+ updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
913
+ }
344
914
  if (args.includes("--help") || args.includes("-h")) {
345
915
  console.log(`tandem v${version}
346
916
 
347
917
  Usage:
348
- tandem Start Tandem server and open the browser
349
- tandem setup Register MCP tools with Claude Code / Claude Desktop
350
- tandem setup --force Register to default paths regardless of detection
918
+ tandem Start Tandem server and open the browser
919
+ tandem setup Register MCP tools with Claude Code / Claude Desktop
920
+ tandem setup --force Register to default paths regardless of detection
921
+ tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
922
+ tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
923
+ (used by the plugin's Cowork bridge; requires
924
+ tandem server running on the host)
925
+ tandem channel Run the Tandem channel shim (stdio MCP)
926
+ (used by the plugin's tandem-channel entry)
351
927
  tandem --version
352
928
  tandem --help
353
929
  `);
@@ -360,7 +936,16 @@ if (args.includes("--version") || args.includes("-v")) {
360
936
  try {
361
937
  if (args[0] === "setup") {
362
938
  const { runSetup: runSetup2 } = await Promise.resolve().then(() => (init_setup(), setup_exports));
363
- await runSetup2({ force: args.includes("--force") });
939
+ await runSetup2({
940
+ force: args.includes("--force"),
941
+ withChannelShim: args.includes("--with-channel-shim")
942
+ });
943
+ } else if (args[0] === "mcp-stdio") {
944
+ const { runMcpStdio: runMcpStdio2 } = await Promise.resolve().then(() => (init_mcp_stdio(), mcp_stdio_exports));
945
+ await runMcpStdio2();
946
+ } else if (args[0] === "channel") {
947
+ const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
948
+ await runChannelCli2();
364
949
  } else if (!args[0] || args[0] === "start") {
365
950
  const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
366
951
  runStart2();