tandem-editor 0.2.12 → 0.3.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.
@@ -9,7 +9,7 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/shared/constants.ts
12
- var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, SUPPORTED_EXTENSIONS, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, INTERRUPTION_MODE_DEFAULT, CHARS_PER_PAGE, LARGE_FILE_PAGE_THRESHOLD, VERY_LARGE_FILE_PAGE_THRESHOLD, CTRL_ROOM, Y_MAP_ANNOTATIONS, Y_MAP_AWARENESS, Y_MAP_USER_AWARENESS, Y_MAP_CHAT, Y_MAP_DOCUMENT_META, Y_MAP_SAVED_AT_VERSION, NOTIFICATION_BUFFER_SIZE, TUTORIAL_ANNOTATION_PREFIX, CHANNEL_EVENT_BUFFER_SIZE, CHANNEL_EVENT_BUFFER_AGE_MS, CHANNEL_SSE_KEEPALIVE_MS;
12
+ var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, SUPPORTED_EXTENSIONS, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, SELECTION_DWELL_DEFAULT_MS, SELECTION_DWELL_MIN_MS, SELECTION_DWELL_MAX_MS, CHARS_PER_PAGE, LARGE_FILE_PAGE_THRESHOLD, VERY_LARGE_FILE_PAGE_THRESHOLD, CTRL_ROOM, Y_MAP_ANNOTATIONS, Y_MAP_AWARENESS, Y_MAP_USER_AWARENESS, Y_MAP_MODE, Y_MAP_DWELL_MS, Y_MAP_CHAT, Y_MAP_DOCUMENT_META, Y_MAP_SAVED_AT_VERSION, NOTIFICATION_BUFFER_SIZE, TUTORIAL_ANNOTATION_PREFIX, CHANNEL_EVENT_BUFFER_SIZE, CHANNEL_EVENT_BUFFER_AGE_MS, CHANNEL_SSE_KEEPALIVE_MS;
13
13
  var init_constants = __esm({
14
14
  "src/shared/constants.ts"() {
15
15
  "use strict";
@@ -20,7 +20,10 @@ var init_constants = __esm({
20
20
  MAX_WS_PAYLOAD = 10 * 1024 * 1024;
21
21
  IDLE_TIMEOUT = 30 * 60 * 1e3;
22
22
  SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
23
- INTERRUPTION_MODE_DEFAULT = "all";
23
+ TANDEM_MODE_DEFAULT = "tandem";
24
+ SELECTION_DWELL_DEFAULT_MS = 1e3;
25
+ SELECTION_DWELL_MIN_MS = 500;
26
+ SELECTION_DWELL_MAX_MS = 3e3;
24
27
  CHARS_PER_PAGE = 3e3;
25
28
  LARGE_FILE_PAGE_THRESHOLD = 50;
26
29
  VERY_LARGE_FILE_PAGE_THRESHOLD = 100;
@@ -28,6 +31,8 @@ var init_constants = __esm({
28
31
  Y_MAP_ANNOTATIONS = "annotations";
29
32
  Y_MAP_AWARENESS = "awareness";
30
33
  Y_MAP_USER_AWARENESS = "userAwareness";
34
+ Y_MAP_MODE = "mode";
35
+ Y_MAP_DWELL_MS = "selectionDwellMs";
31
36
  Y_MAP_CHAT = "chat";
32
37
  Y_MAP_DOCUMENT_META = "documentMeta";
33
38
  Y_MAP_SAVED_AT_VERSION = "savedAtVersion";
@@ -39,106 +44,79 @@ var init_constants = __esm({
39
44
  }
40
45
  });
41
46
 
42
- // src/server/yjs/provider.ts
43
- import { Hocuspocus } from "@hocuspocus/server";
44
- import * as Y from "yjs";
45
- function setDocLifecycleCallbacks(swapped, unloaded) {
46
- onDocSwapped = swapped;
47
- onDocUnloaded = unloaded;
48
- }
49
- function setShouldKeepDocument(fn) {
50
- shouldKeepDocument = fn;
51
- }
52
- function getDocument(name) {
53
- return documents.get(name);
54
- }
55
- function getOrCreateDocument(name) {
56
- let doc = documents.get(name);
57
- if (!doc) {
58
- doc = new Y.Doc();
59
- documents.set(name, doc);
60
- }
61
- return doc;
62
- }
63
- async function startHocuspocus(port) {
64
- hocuspocusInstance = new Hocuspocus({
65
- port,
66
- address: "127.0.0.1",
67
- quiet: true,
68
- // stdout is the MCP wire — suppress the startup banner
69
- async onConnect({ request, documentName }) {
70
- const origin = request?.headers?.origin;
71
- if (!origin) {
72
- console.error("[Hocuspocus] Rejected connection: missing Origin header");
73
- throw new Error("Connection rejected: missing origin header");
74
- }
75
- const url = new URL(origin);
76
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
77
- console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
78
- throw new Error("Connection rejected: invalid origin");
79
- }
80
- console.error(`[Hocuspocus] Client connected to: ${documentName}`);
81
- },
82
- async onDisconnect({ documentName }) {
83
- console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
84
- },
85
- async onLoadDocument({ document, documentName }) {
86
- console.error(`[Hocuspocus] Loading document: ${documentName}`);
87
- const existing = documents.get(documentName);
88
- if (existing && existing !== document) {
89
- const update = Y.encodeStateAsUpdate(existing);
90
- Y.applyUpdate(document, update);
91
- existing.destroy();
92
- console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
93
- }
94
- documents.set(documentName, document);
95
- if (onDocSwapped) {
96
- onDocSwapped(documentName, document);
97
- } else {
98
- console.error(
99
- `[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
100
- );
101
- }
102
- return document;
103
- },
104
- async afterUnloadDocument({ documentName }) {
105
- if (shouldKeepDocument?.(documentName)) {
106
- console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
47
+ // src/server/file-watcher.ts
48
+ import fs from "fs";
49
+ function watchFile(filePath, onChanged) {
50
+ if (watched.has(filePath)) return;
51
+ let watcher;
52
+ try {
53
+ watcher = fs.watch(filePath, (eventType) => {
54
+ if (eventType !== "change") return;
55
+ const entry = watched.get(filePath);
56
+ if (!entry) return;
57
+ if (entry.suppressed) {
58
+ entry.suppressed = false;
107
59
  return;
108
60
  }
109
- if (documents.has(documentName)) {
110
- onDocUnloaded?.(documentName);
111
- documents.delete(documentName);
112
- console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
61
+ if (entry.timer !== null) {
62
+ clearTimeout(entry.timer);
113
63
  }
114
- }
115
- });
116
- await hocuspocusInstance.listen();
117
- const internal = hocuspocusInstance.server?.httpServer;
118
- if (internal) {
119
- internal.on("error", (err) => {
120
- console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
64
+ entry.timer = setTimeout(() => {
65
+ entry.timer = null;
66
+ onChanged(filePath).catch((err) => {
67
+ console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
68
+ });
69
+ }, 500);
121
70
  });
71
+ } catch (err) {
72
+ console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
73
+ return;
122
74
  }
123
- return hocuspocusInstance;
75
+ watcher.on("error", (err) => {
76
+ console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
77
+ unwatchFile(filePath);
78
+ });
79
+ watched.set(filePath, { watcher, timer: null, suppressed: false });
80
+ console.error(`[FileWatcher] Watching ${filePath}`);
124
81
  }
125
- var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
126
- var init_provider = __esm({
127
- "src/server/yjs/provider.ts"() {
82
+ function suppressNextChange(filePath) {
83
+ const entry = watched.get(filePath);
84
+ if (entry) {
85
+ entry.suppressed = true;
86
+ }
87
+ }
88
+ function unwatchFile(filePath) {
89
+ const entry = watched.get(filePath);
90
+ if (!entry) return;
91
+ if (entry.timer !== null) {
92
+ clearTimeout(entry.timer);
93
+ }
94
+ try {
95
+ entry.watcher.close();
96
+ } catch (err) {
97
+ console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
98
+ }
99
+ watched.delete(filePath);
100
+ console.error(`[FileWatcher] Unwatched ${filePath}`);
101
+ }
102
+ function unwatchAll() {
103
+ for (const filePath of [...watched.keys()]) {
104
+ unwatchFile(filePath);
105
+ }
106
+ }
107
+ var watched;
108
+ var init_file_watcher = __esm({
109
+ "src/server/file-watcher.ts"() {
128
110
  "use strict";
129
- hocuspocusInstance = null;
130
- documents = /* @__PURE__ */ new Map();
131
- shouldKeepDocument = null;
132
- onDocSwapped = null;
133
- onDocUnloaded = null;
111
+ watched = /* @__PURE__ */ new Map();
134
112
  }
135
113
  });
136
114
 
137
115
  // src/server/platform.ts
138
116
  import { execSync } from "child_process";
117
+ import envPaths from "env-paths";
139
118
  import net from "net";
140
119
  import path from "path";
141
- import envPaths from "env-paths";
142
120
  function freePort(port) {
143
121
  try {
144
122
  if (process.platform === "win32") {
@@ -231,15 +209,15 @@ var init_platform = __esm({
231
209
  });
232
210
 
233
211
  // src/server/session/manager.ts
234
- import fs from "fs/promises";
212
+ import fs2 from "fs/promises";
235
213
  import path2 from "path";
236
- import * as Y2 from "yjs";
214
+ import * as Y from "yjs";
237
215
  async function atomicWrite(sessionPath, content) {
238
216
  const tmpPath = `${sessionPath}.tmp`;
239
- await fs.writeFile(tmpPath, content, "utf-8");
217
+ await fs2.writeFile(tmpPath, content, "utf-8");
240
218
  for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
241
219
  try {
242
- await fs.rename(tmpPath, sessionPath);
220
+ await fs2.rename(tmpPath, sessionPath);
243
221
  return;
244
222
  } catch (err) {
245
223
  const code = err.code;
@@ -247,7 +225,7 @@ async function atomicWrite(sessionPath, content) {
247
225
  await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
248
226
  continue;
249
227
  }
250
- await fs.unlink(tmpPath).catch(() => {
228
+ await fs2.unlink(tmpPath).catch(() => {
251
229
  });
252
230
  throw err;
253
231
  }
@@ -261,12 +239,12 @@ async function saveSession(filePath, format, doc) {
261
239
  let sourceFileMtime = 0;
262
240
  if (!filePath.startsWith("upload://")) {
263
241
  try {
264
- const stat = await fs.stat(filePath);
242
+ const stat = await fs2.stat(filePath);
265
243
  sourceFileMtime = stat.mtimeMs;
266
244
  } catch {
267
245
  }
268
246
  }
269
- const state = Y2.encodeStateAsUpdate(doc);
247
+ const state = Y.encodeStateAsUpdate(doc);
270
248
  const ydocState = Buffer.from(state).toString("base64");
271
249
  const data = {
272
250
  filePath,
@@ -276,7 +254,7 @@ async function saveSession(filePath, format, doc) {
276
254
  lastAccessed: Date.now()
277
255
  };
278
256
  if (!sessionDirReady) {
279
- await fs.mkdir(SESSION_DIR, { recursive: true });
257
+ await fs2.mkdir(SESSION_DIR, { recursive: true });
280
258
  sessionDirReady = true;
281
259
  }
282
260
  const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
@@ -286,14 +264,14 @@ async function loadSession(filePath) {
286
264
  const key = sessionKey(filePath);
287
265
  const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
288
266
  try {
289
- const content = await fs.readFile(sessionPath, "utf-8");
267
+ const content = await fs2.readFile(sessionPath, "utf-8");
290
268
  return JSON.parse(content);
291
269
  } catch (err) {
292
270
  const code = err.code;
293
271
  if (code === "ENOENT") return null;
294
272
  if (err instanceof SyntaxError) {
295
273
  console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
296
- await fs.unlink(sessionPath).catch((unlinkErr) => {
274
+ await fs2.unlink(sessionPath).catch((unlinkErr) => {
297
275
  console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
298
276
  });
299
277
  return null;
@@ -304,12 +282,12 @@ async function loadSession(filePath) {
304
282
  }
305
283
  function restoreYDoc(doc, session) {
306
284
  const state = Buffer.from(session.ydocState, "base64");
307
- Y2.applyUpdate(doc, new Uint8Array(state));
285
+ Y.applyUpdate(doc, new Uint8Array(state));
308
286
  }
309
287
  async function sourceFileChanged(session) {
310
288
  if (session.filePath.startsWith("upload://")) return false;
311
289
  try {
312
- const stat = await fs.stat(session.filePath);
290
+ const stat = await fs2.stat(session.filePath);
313
291
  return stat.mtimeMs !== session.sourceFileMtime;
314
292
  } catch {
315
293
  return true;
@@ -319,7 +297,7 @@ async function deleteSession(filePath) {
319
297
  const key = sessionKey(filePath);
320
298
  const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
321
299
  try {
322
- await fs.unlink(sessionPath);
300
+ await fs2.unlink(sessionPath);
323
301
  } catch (err) {
324
302
  const code = err.code;
325
303
  if (code !== "ENOENT") {
@@ -329,7 +307,7 @@ async function deleteSession(filePath) {
329
307
  }
330
308
  async function saveCtrlSession(doc) {
331
309
  if (!sessionDirReady) {
332
- await fs.mkdir(SESSION_DIR, { recursive: true });
310
+ await fs2.mkdir(SESSION_DIR, { recursive: true });
333
311
  sessionDirReady = true;
334
312
  }
335
313
  const chatMap = doc.getMap(Y_MAP_CHAT);
@@ -347,7 +325,7 @@ async function saveCtrlSession(doc) {
347
325
  }
348
326
  }, MCP_ORIGIN);
349
327
  }
350
- const state = Y2.encodeStateAsUpdate(doc);
328
+ const state = Y.encodeStateAsUpdate(doc);
351
329
  const ydocState = Buffer.from(state).toString("base64");
352
330
  const data = { ydocState, lastAccessed: Date.now() };
353
331
  const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
@@ -356,7 +334,7 @@ async function saveCtrlSession(doc) {
356
334
  async function loadCtrlSession() {
357
335
  const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
358
336
  try {
359
- const content = await fs.readFile(sessionPath, "utf-8");
337
+ const content = await fs2.readFile(sessionPath, "utf-8");
360
338
  const data = JSON.parse(content);
361
339
  return data.ydocState ?? null;
362
340
  } catch (err) {
@@ -364,7 +342,7 @@ async function loadCtrlSession() {
364
342
  if (code === "ENOENT") return null;
365
343
  if (err instanceof SyntaxError) {
366
344
  console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
367
- await fs.unlink(sessionPath).catch((unlinkErr) => {
345
+ await fs2.unlink(sessionPath).catch((unlinkErr) => {
368
346
  console.error(
369
347
  `[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
370
348
  unlinkErr
@@ -378,18 +356,18 @@ async function loadCtrlSession() {
378
356
  }
379
357
  function restoreCtrlDoc(doc, base64State) {
380
358
  const state = Buffer.from(base64State, "base64");
381
- Y2.applyUpdate(doc, new Uint8Array(state));
359
+ Y.applyUpdate(doc, new Uint8Array(state));
382
360
  }
383
361
  async function listSessionFilePaths() {
384
362
  try {
385
- await fs.mkdir(SESSION_DIR, { recursive: true });
386
- const files = await fs.readdir(SESSION_DIR);
363
+ await fs2.mkdir(SESSION_DIR, { recursive: true });
364
+ const files = await fs2.readdir(SESSION_DIR);
387
365
  const results = [];
388
366
  for (const file of files) {
389
367
  if (!file.endsWith(".json")) continue;
390
368
  if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
391
369
  try {
392
- const raw = await fs.readFile(path2.join(SESSION_DIR, file), "utf-8");
370
+ const raw = await fs2.readFile(path2.join(SESSION_DIR, file), "utf-8");
393
371
  const data = JSON.parse(raw);
394
372
  if (!data.filePath || data.filePath.startsWith("upload://")) continue;
395
373
  results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
@@ -408,7 +386,7 @@ async function cleanupSessions() {
408
386
  let cleaned = 0;
409
387
  let files;
410
388
  try {
411
- files = await fs.readdir(SESSION_DIR);
389
+ files = await fs2.readdir(SESSION_DIR);
412
390
  } catch (err) {
413
391
  if (err.code === "ENOENT") return 0;
414
392
  console.error("[Tandem] Failed to read session directory:", err);
@@ -418,9 +396,9 @@ async function cleanupSessions() {
418
396
  for (const file of files) {
419
397
  try {
420
398
  const filePath = path2.join(SESSION_DIR, file);
421
- const stat = await fs.stat(filePath);
399
+ const stat = await fs2.stat(filePath);
422
400
  if (now - stat.mtimeMs > SESSION_MAX_AGE) {
423
- await fs.unlink(filePath);
401
+ await fs2.unlink(filePath);
424
402
  cleaned++;
425
403
  }
426
404
  } catch (err) {
@@ -454,9 +432,9 @@ var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirRead
454
432
  var init_manager = __esm({
455
433
  "src/server/session/manager.ts"() {
456
434
  "use strict";
457
- init_platform();
458
435
  init_constants();
459
436
  init_queue();
437
+ init_platform();
460
438
  AUTO_SAVE_INTERVAL = 60 * 1e3;
461
439
  RENAME_MAX_RETRIES = 3;
462
440
  RENAME_RETRY_BASE_MS = 50;
@@ -467,71 +445,136 @@ var init_manager = __esm({
467
445
  }
468
446
  });
469
447
 
470
- // src/server/file-watcher.ts
471
- import fs2 from "fs";
472
- function watchFile(filePath, onChanged) {
473
- if (watched.has(filePath)) return;
474
- let watcher;
475
- try {
476
- watcher = fs2.watch(filePath, (eventType) => {
477
- if (eventType !== "change") return;
478
- const entry = watched.get(filePath);
479
- if (!entry) return;
480
- if (entry.suppressed) {
481
- entry.suppressed = false;
448
+ // src/server/yjs/provider.ts
449
+ import { Hocuspocus } from "@hocuspocus/server";
450
+ import * as Y2 from "yjs";
451
+ function setDocLifecycleCallbacks(swapped, unloaded) {
452
+ onDocSwapped = swapped;
453
+ onDocUnloaded = unloaded;
454
+ }
455
+ function setShouldKeepDocument(fn) {
456
+ shouldKeepDocument = fn;
457
+ }
458
+ function getDocument(name) {
459
+ return documents.get(name);
460
+ }
461
+ function getOrCreateDocument(name) {
462
+ let doc = documents.get(name);
463
+ if (!doc) {
464
+ doc = new Y2.Doc();
465
+ documents.set(name, doc);
466
+ }
467
+ return doc;
468
+ }
469
+ async function startHocuspocus(port) {
470
+ hocuspocusInstance = new Hocuspocus({
471
+ port,
472
+ address: "127.0.0.1",
473
+ quiet: true,
474
+ // stdout is the MCP wire — suppress the startup banner
475
+ async onConnect({ request, documentName }) {
476
+ const origin = request?.headers?.origin;
477
+ if (!origin) {
478
+ console.error("[Hocuspocus] Rejected connection: missing Origin header");
479
+ throw new Error("Connection rejected: missing origin header");
480
+ }
481
+ const url = new URL(origin);
482
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
483
+ console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
484
+ throw new Error("Connection rejected: invalid origin");
485
+ }
486
+ console.error(`[Hocuspocus] Client connected to: ${documentName}`);
487
+ },
488
+ async onDisconnect({ documentName }) {
489
+ console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
490
+ },
491
+ async onLoadDocument({ document, documentName }) {
492
+ console.error(`[Hocuspocus] Loading document: ${documentName}`);
493
+ const existing = documents.get(documentName);
494
+ if (existing && existing !== document) {
495
+ const update = Y2.encodeStateAsUpdate(existing);
496
+ Y2.applyUpdate(document, update);
497
+ existing.destroy();
498
+ console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
499
+ }
500
+ documents.set(documentName, document);
501
+ if (onDocSwapped) {
502
+ onDocSwapped(documentName, document);
503
+ } else {
504
+ console.error(
505
+ `[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
506
+ );
507
+ }
508
+ return document;
509
+ },
510
+ async afterUnloadDocument({ documentName }) {
511
+ if (shouldKeepDocument?.(documentName)) {
512
+ console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
482
513
  return;
483
514
  }
484
- if (entry.timer !== null) {
485
- clearTimeout(entry.timer);
515
+ if (documents.has(documentName)) {
516
+ onDocUnloaded?.(documentName);
517
+ documents.delete(documentName);
518
+ console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
486
519
  }
487
- entry.timer = setTimeout(() => {
488
- entry.timer = null;
489
- onChanged(filePath).catch((err) => {
490
- console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
491
- });
492
- }, 500);
520
+ }
521
+ });
522
+ await hocuspocusInstance.listen();
523
+ const internal = hocuspocusInstance.server?.httpServer;
524
+ if (internal) {
525
+ internal.on("error", (err) => {
526
+ console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
493
527
  });
494
- } catch (err) {
495
- console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
496
- return;
497
528
  }
498
- watcher.on("error", (err) => {
499
- console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
500
- unwatchFile(filePath);
501
- });
502
- watched.set(filePath, { watcher, timer: null, suppressed: false });
503
- console.error(`[FileWatcher] Watching ${filePath}`);
529
+ return hocuspocusInstance;
504
530
  }
505
- function suppressNextChange(filePath) {
506
- const entry = watched.get(filePath);
507
- if (entry) {
508
- entry.suppressed = true;
531
+ var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
532
+ var init_provider = __esm({
533
+ "src/server/yjs/provider.ts"() {
534
+ "use strict";
535
+ hocuspocusInstance = null;
536
+ documents = /* @__PURE__ */ new Map();
537
+ shouldKeepDocument = null;
538
+ onDocSwapped = null;
539
+ onDocUnloaded = null;
509
540
  }
541
+ });
542
+
543
+ // src/shared/utils.ts
544
+ function generateId(prefix) {
545
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
510
546
  }
511
- function unwatchFile(filePath) {
512
- const entry = watched.get(filePath);
513
- if (!entry) return;
514
- if (entry.timer !== null) {
515
- clearTimeout(entry.timer);
516
- }
517
- try {
518
- entry.watcher.close();
519
- } catch (err) {
520
- console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
521
- }
522
- watched.delete(filePath);
523
- console.error(`[FileWatcher] Unwatched ${filePath}`);
547
+ function generateAnnotationId() {
548
+ return generateId("ann");
524
549
  }
525
- function unwatchAll() {
526
- for (const filePath of [...watched.keys()]) {
527
- unwatchFile(filePath);
550
+ function generateMessageId() {
551
+ return generateId("msg");
552
+ }
553
+ function generateEventId() {
554
+ return generateId("evt");
555
+ }
556
+ function generateNotificationId() {
557
+ return generateId("ntf");
558
+ }
559
+ var init_utils = __esm({
560
+ "src/shared/utils.ts"() {
561
+ "use strict";
528
562
  }
563
+ });
564
+
565
+ // src/shared/offsets.ts
566
+ function headingPrefixLength(level) {
567
+ if (!level) return 0;
568
+ return level + 1;
529
569
  }
530
- var watched;
531
- var init_file_watcher = __esm({
532
- "src/server/file-watcher.ts"() {
570
+ function headingPrefix(level) {
571
+ return "#".repeat(level) + " ";
572
+ }
573
+ var FLAT_SEPARATOR;
574
+ var init_offsets = __esm({
575
+ "src/shared/offsets.ts"() {
533
576
  "use strict";
534
- watched = /* @__PURE__ */ new Map();
577
+ FLAT_SEPARATOR = "\n";
535
578
  }
536
579
  });
537
580
 
@@ -853,10 +896,10 @@ var init_mdast_ydoc = __esm({
853
896
  });
854
897
 
855
898
  // src/server/file-io/markdown.ts
856
- import { unified } from "unified";
857
- import remarkParse from "remark-parse";
858
899
  import remarkGfm from "remark-gfm";
900
+ import remarkParse from "remark-parse";
859
901
  import remarkStringify from "remark-stringify";
902
+ import { unified } from "unified";
860
903
  function loadMarkdown(doc, markdown) {
861
904
  const tree = parser.parse(markdown);
862
905
  mdastToYDoc(doc, tree);
@@ -881,22 +924,6 @@ var init_markdown = __esm({
881
924
  }
882
925
  });
883
926
 
884
- // src/shared/offsets.ts
885
- function headingPrefixLength(level) {
886
- if (!level) return 0;
887
- return level + 1;
888
- }
889
- function headingPrefix(level) {
890
- return "#".repeat(level) + " ";
891
- }
892
- var FLAT_SEPARATOR;
893
- var init_offsets = __esm({
894
- "src/shared/offsets.ts"() {
895
- "use strict";
896
- FLAT_SEPARATOR = "\n";
897
- }
898
- });
899
-
900
927
  // src/server/mcp/document-model.ts
901
928
  import path3 from "path";
902
929
  import * as Y4 from "yjs";
@@ -1426,6 +1453,42 @@ var init_types = __esm({
1426
1453
  }
1427
1454
  });
1428
1455
 
1456
+ // src/shared/types.ts
1457
+ import { z } from "zod";
1458
+ var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
1459
+ var init_types2 = __esm({
1460
+ "src/shared/types.ts"() {
1461
+ "use strict";
1462
+ init_types();
1463
+ AnnotationTypeSchema = z.enum([
1464
+ "highlight",
1465
+ "comment",
1466
+ "suggestion",
1467
+ "overlay",
1468
+ "question",
1469
+ "flag"
1470
+ ]);
1471
+ AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
1472
+ HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
1473
+ SeveritySchema = z.enum(["info", "warning", "error", "success"]);
1474
+ TandemModeSchema = z.enum(["solo", "tandem"]);
1475
+ AuthorSchema = z.enum(["user", "claude", "import"]);
1476
+ AnnotationActionSchema = z.enum(["accept", "dismiss"]);
1477
+ ExportFormatSchema = z.enum(["markdown", "json"]);
1478
+ DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
1479
+ ToolErrorCodeSchema = z.enum([
1480
+ "RANGE_GONE",
1481
+ "RANGE_MOVED",
1482
+ "FILE_LOCKED",
1483
+ "FILE_NOT_FOUND",
1484
+ "NO_DOCUMENT",
1485
+ "INVALID_RANGE",
1486
+ "FORMAT_ERROR",
1487
+ "PERMISSION_DENIED"
1488
+ ]);
1489
+ }
1490
+ });
1491
+
1429
1492
  // src/shared/positions/index.ts
1430
1493
  var init_positions = __esm({
1431
1494
  "src/shared/positions/index.ts"() {
@@ -1632,49 +1695,12 @@ function refreshAllRanges(annotations, ydoc, map) {
1632
1695
  var init_positions2 = __esm({
1633
1696
  "src/server/positions.ts"() {
1634
1697
  "use strict";
1635
- init_queue();
1636
1698
  init_positions();
1699
+ init_queue();
1637
1700
  init_document_model();
1638
1701
  }
1639
1702
  });
1640
1703
 
1641
- // src/shared/types.ts
1642
- import { z } from "zod";
1643
- var AnnotationTypeSchema, AnnotationStatusSchema, AnnotationPrioritySchema, InterruptionModeSchema, HighlightColorSchema, SeveritySchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
1644
- var init_types2 = __esm({
1645
- "src/shared/types.ts"() {
1646
- "use strict";
1647
- init_types();
1648
- AnnotationTypeSchema = z.enum([
1649
- "highlight",
1650
- "comment",
1651
- "suggestion",
1652
- "overlay",
1653
- "question",
1654
- "flag"
1655
- ]);
1656
- AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
1657
- AnnotationPrioritySchema = z.enum(["normal", "urgent"]);
1658
- InterruptionModeSchema = z.enum(["all", "urgent-only", "paused"]);
1659
- HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
1660
- SeveritySchema = z.enum(["info", "warning", "error", "success"]);
1661
- AuthorSchema = z.enum(["user", "claude", "import"]);
1662
- AnnotationActionSchema = z.enum(["accept", "dismiss"]);
1663
- ExportFormatSchema = z.enum(["markdown", "json"]);
1664
- DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
1665
- ToolErrorCodeSchema = z.enum([
1666
- "RANGE_GONE",
1667
- "RANGE_MOVED",
1668
- "FILE_LOCKED",
1669
- "FILE_NOT_FOUND",
1670
- "NO_DOCUMENT",
1671
- "INVALID_RANGE",
1672
- "FORMAT_ERROR",
1673
- "PERMISSION_DENIED"
1674
- ]);
1675
- }
1676
- });
1677
-
1678
1704
  // src/server/file-io/docx-walker.ts
1679
1705
  import { parseDocument as parseDocument2 } from "htmlparser2";
1680
1706
  function isElement2(node) {
@@ -1805,8 +1831,8 @@ var init_docx_walker = __esm({
1805
1831
  });
1806
1832
 
1807
1833
  // src/server/file-io/docx-comments.ts
1808
- import JSZip from "jszip";
1809
1834
  import { parseDocument as parseDocument3 } from "htmlparser2";
1835
+ import JSZip from "jszip";
1810
1836
  async function extractDocxComments(buffer3) {
1811
1837
  const zip = await JSZip.loadAsync(buffer3);
1812
1838
  const commentsXml = await zip.file("word/comments.xml")?.async("text");
@@ -1914,9 +1940,9 @@ var init_docx_comments = __esm({
1914
1940
  "src/server/file-io/docx-comments.ts"() {
1915
1941
  "use strict";
1916
1942
  init_constants();
1917
- init_positions2();
1918
1943
  init_types2();
1919
1944
  init_queue();
1945
+ init_positions2();
1920
1946
  init_docx_walker();
1921
1947
  }
1922
1948
  });
@@ -2239,9 +2265,9 @@ var init_dist2 = __esm({
2239
2265
  });
2240
2266
 
2241
2267
  // src/server/file-io/docx-apply.ts
2242
- import JSZip2 from "jszip";
2243
- import { parseDocument as parseDocument4 } from "htmlparser2";
2244
2268
  import render from "dom-serializer";
2269
+ import { parseDocument as parseDocument4 } from "htmlparser2";
2270
+ import JSZip2 from "jszip";
2245
2271
  function buildOffsetMap(xml, targetOffsets) {
2246
2272
  const entries = /* @__PURE__ */ new Map();
2247
2273
  const commentParagraphIds = /* @__PURE__ */ new Map();
@@ -2797,10 +2823,10 @@ var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
2797
2823
  var init_file_io = __esm({
2798
2824
  "src/server/file-io/index.ts"() {
2799
2825
  "use strict";
2800
- init_markdown();
2826
+ init_document_model();
2801
2827
  init_docx();
2802
2828
  init_docx_comments();
2803
- init_document_model();
2829
+ init_markdown();
2804
2830
  init_docx_apply();
2805
2831
  markdownAdapter = {
2806
2832
  canSave: true,
@@ -2881,28 +2907,6 @@ var init_notifications = __esm({
2881
2907
  }
2882
2908
  });
2883
2909
 
2884
- // src/shared/utils.ts
2885
- function generateId(prefix) {
2886
- return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2887
- }
2888
- function generateAnnotationId() {
2889
- return generateId("ann");
2890
- }
2891
- function generateMessageId() {
2892
- return generateId("msg");
2893
- }
2894
- function generateEventId() {
2895
- return generateId("evt");
2896
- }
2897
- function generateNotificationId() {
2898
- return generateId("ntf");
2899
- }
2900
- var init_utils = __esm({
2901
- "src/shared/utils.ts"() {
2902
- "use strict";
2903
- }
2904
- });
2905
-
2906
2910
  // src/server/mcp/file-opener.ts
2907
2911
  var file_opener_exports = {};
2908
2912
  __export(file_opener_exports, {
@@ -2910,10 +2914,10 @@ __export(file_opener_exports, {
2910
2914
  openFileByPath: () => openFileByPath,
2911
2915
  openFileFromContent: () => openFileFromContent
2912
2916
  });
2913
- import fs4 from "fs/promises";
2917
+ import { randomUUID } from "crypto";
2914
2918
  import fsSync from "fs";
2919
+ import fs4 from "fs/promises";
2915
2920
  import path5 from "path";
2916
- import { randomUUID } from "crypto";
2917
2921
  async function openFileByPath(filePath, options) {
2918
2922
  let resolved = path5.resolve(filePath);
2919
2923
  try {
@@ -3262,19 +3266,19 @@ var reloadInProgress;
3262
3266
  var init_file_opener = __esm({
3263
3267
  "src/server/mcp/file-opener.ts"() {
3264
3268
  "use strict";
3265
- init_provider();
3266
3269
  init_constants();
3270
+ init_utils();
3267
3271
  init_queue();
3272
+ init_docx();
3273
+ init_docx_comments();
3274
+ init_docx_html();
3268
3275
  init_file_io();
3276
+ init_markdown();
3269
3277
  init_file_watcher();
3270
- init_positions2();
3271
3278
  init_notifications();
3272
- init_utils();
3273
- init_markdown();
3274
- init_docx();
3275
- init_docx_html();
3276
- init_docx_comments();
3279
+ init_positions2();
3277
3280
  init_manager();
3281
+ init_provider();
3278
3282
  init_document_model();
3279
3283
  init_document_service();
3280
3284
  reloadInProgress = /* @__PURE__ */ new Set();
@@ -3282,8 +3286,8 @@ var init_file_opener = __esm({
3282
3286
  });
3283
3287
 
3284
3288
  // src/server/mcp/document-service.ts
3285
- import path6 from "path";
3286
3289
  import { randomUUID as randomUUID2 } from "crypto";
3290
+ import path6 from "path";
3287
3291
  function getOpenDocs() {
3288
3292
  return openDocs;
3289
3293
  }
@@ -3443,11 +3447,11 @@ var openDocs, activeDocId;
3443
3447
  var init_document_service = __esm({
3444
3448
  "src/server/mcp/document-service.ts"() {
3445
3449
  "use strict";
3446
- init_provider();
3447
- init_manager();
3448
3450
  init_constants();
3449
3451
  init_queue();
3450
3452
  init_file_watcher();
3453
+ init_manager();
3454
+ init_provider();
3451
3455
  openDocs = /* @__PURE__ */ new Map();
3452
3456
  setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
3453
3457
  activeDocId = null;
@@ -3463,6 +3467,19 @@ var init_types3 = __esm({
3463
3467
  });
3464
3468
 
3465
3469
  // src/server/events/queue.ts
3470
+ function getDwellMs() {
3471
+ const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
3472
+ const awareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
3473
+ const val = awareness.get(Y_MAP_DWELL_MS);
3474
+ if (val === void 0) return SELECTION_DWELL_DEFAULT_MS;
3475
+ if (typeof val === "number" && val >= SELECTION_DWELL_MIN_MS && val <= SELECTION_DWELL_MAX_MS) {
3476
+ return val;
3477
+ }
3478
+ console.warn(
3479
+ `[EventQueue] Invalid dwell time in CTRL_ROOM awareness (type=${typeof val}, value=${String(val)}); using default ${SELECTION_DWELL_DEFAULT_MS}ms`
3480
+ );
3481
+ return SELECTION_DWELL_DEFAULT_MS;
3482
+ }
3466
3483
  function getTrackableId(event) {
3467
3484
  switch (event.type) {
3468
3485
  case "annotation:created":
@@ -3569,26 +3586,37 @@ function attachObservers(docName, doc) {
3569
3586
  annotationsMap.observe(annotationsObs);
3570
3587
  cleanups.push(() => annotationsMap.unobserve(annotationsObs));
3571
3588
  const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
3589
+ let selectionDwellTimer = null;
3572
3590
  const awarenessObs = (event, txn) => {
3573
3591
  if (txn.origin === MCP_ORIGIN) return;
3574
3592
  if (event.keysChanged.has("selection")) {
3575
3593
  const selection = userAwareness.get("selection");
3594
+ if (selectionDwellTimer) {
3595
+ clearTimeout(selectionDwellTimer);
3596
+ selectionDwellTimer = null;
3597
+ }
3576
3598
  if (!selection || selection.from === selection.to) return;
3577
- pushEvent({
3578
- id: generateEventId(),
3579
- type: "selection:changed",
3580
- timestamp: Date.now(),
3581
- documentId: docName,
3582
- payload: {
3583
- from: selection.from,
3584
- to: selection.to,
3585
- selectedText: selection.selectedText ?? ""
3586
- }
3587
- });
3599
+ selectionDwellTimer = setTimeout(() => {
3600
+ selectionDwellTimer = null;
3601
+ pushEvent({
3602
+ id: generateEventId(),
3603
+ type: "selection:changed",
3604
+ timestamp: Date.now(),
3605
+ documentId: docName,
3606
+ payload: {
3607
+ from: selection.from,
3608
+ to: selection.to,
3609
+ selectedText: selection.selectedText ?? ""
3610
+ }
3611
+ });
3612
+ }, getDwellMs());
3588
3613
  }
3589
3614
  };
3590
3615
  userAwareness.observe(awarenessObs);
3591
- cleanups.push(() => userAwareness.unobserve(awarenessObs));
3616
+ cleanups.push(() => {
3617
+ userAwareness.unobserve(awarenessObs);
3618
+ if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
3619
+ });
3592
3620
  docObservers.set(docName, cleanups);
3593
3621
  console.error(`[EventQueue] Attached observers for document: ${docName}`);
3594
3622
  }
@@ -3788,115 +3816,62 @@ var init_launcher = __esm({
3788
3816
  init_constants();
3789
3817
  claudeProcess = null;
3790
3818
  TANDEM_SYSTEM_PROMPT = [
3791
- "You are Claude, connected to Tandem \u2014 a collaborative document editor.",
3792
- "You will receive real-time push notifications via the tandem-channel when users",
3793
- "create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
3794
- "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
3795
- "tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
3796
- "Start by calling tandem_checkInbox to see what needs attention."
3797
- ].join(" ");
3798
- }
3799
- });
3800
-
3801
- // src/server/index.ts
3802
- import path10 from "path";
3803
- import { fileURLToPath as fileURLToPath2 } from "url";
3804
-
3805
- // src/server/mcp/server.ts
3806
- import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
3807
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3808
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3809
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3810
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
3811
- import { randomUUID as randomUUID3 } from "crypto";
3812
- import { existsSync } from "fs";
3813
- import { dirname, join } from "path";
3814
- import { fileURLToPath } from "url";
3815
- import { createRequire } from "module";
3816
-
3817
- // src/server/open-browser.ts
3818
- import { execFile } from "child_process";
3819
- function openBrowser(url) {
3820
- let command;
3821
- let args;
3822
- if (process.platform === "win32") {
3823
- command = "cmd";
3824
- args = ["/c", "start", "", url];
3825
- } else if (process.platform === "darwin") {
3826
- command = "open";
3827
- args = [url];
3828
- } else {
3829
- command = "xdg-open";
3830
- args = [url];
3831
- }
3832
- execFile(command, args, (err) => {
3833
- if (err) {
3834
- console.error("[Tandem] Could not open browser automatically.");
3835
- console.error(`[Tandem] Open this URL manually: ${url}`);
3836
- }
3837
- });
3838
- }
3839
-
3840
- // src/server/mcp/annotations.ts
3841
- init_constants();
3842
- init_queue();
3843
- init_provider();
3844
- import { z as z3 } from "zod";
3845
-
3846
- // src/server/mcp/document.ts
3847
- init_provider();
3848
- import { z as z2 } from "zod";
3849
- import * as Y8 from "yjs";
3850
-
3851
- // src/server/mcp/response.ts
3852
- function mcpSuccess(data) {
3853
- return {
3854
- content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
3855
- };
3856
- }
3857
- function mcpError(code, message, details) {
3858
- return {
3859
- content: [
3860
- {
3861
- type: "text",
3862
- text: JSON.stringify({ error: true, code, message, ...details && { details } })
3863
- }
3864
- ]
3865
- };
3866
- }
3867
- function noDocumentError() {
3868
- return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
3869
- }
3870
- function getErrorMessage(err) {
3871
- return err instanceof Error ? err.message : String(err);
3872
- }
3873
- function withErrorBoundary(toolName, handler) {
3874
- return async (args) => {
3875
- try {
3876
- return await handler(args);
3877
- } catch (err) {
3878
- console.error(`[Tandem] Tool ${toolName} threw:`, err);
3879
- return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
3819
+ "You are Claude, connected to Tandem \u2014 a collaborative document editor.",
3820
+ "You will receive real-time push notifications via the tandem-channel when users",
3821
+ "create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
3822
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
3823
+ "tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
3824
+ "Start by calling tandem_checkInbox to see what needs attention."
3825
+ ].join(" ");
3826
+ }
3827
+ });
3828
+
3829
+ // src/server/index.ts
3830
+ init_constants();
3831
+ import path10 from "path";
3832
+ import { fileURLToPath as fileURLToPath2 } from "url";
3833
+
3834
+ // src/server/error-filter.ts
3835
+ function isKnownHocuspocusError(err) {
3836
+ if (!(err instanceof Error)) return false;
3837
+ if ("code" in err) {
3838
+ const code = err.code;
3839
+ if (typeof code === "string" && code.startsWith("WS_ERR_")) {
3840
+ return true;
3880
3841
  }
3881
- };
3882
- }
3883
- function escapeRegex(str) {
3884
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3842
+ }
3843
+ const msg = err.message;
3844
+ if (msg.startsWith("WebSocket is not open")) return true;
3845
+ if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
3846
+ if (msg.startsWith("Received a message with an unknown type:")) return true;
3847
+ return false;
3885
3848
  }
3886
3849
 
3850
+ // src/server/index.ts
3851
+ init_queue();
3852
+ init_file_watcher();
3853
+
3887
3854
  // src/server/mcp/document.ts
3888
- init_notifications();
3889
- init_utils();
3855
+ init_constants();
3890
3856
  init_offsets();
3857
+ init_types2();
3858
+ init_utils();
3859
+ init_queue();
3891
3860
  init_file_io();
3892
3861
  init_file_watcher();
3862
+ init_notifications();
3863
+ init_positions2();
3864
+ init_manager();
3865
+ init_provider();
3866
+ import * as Y8 from "yjs";
3867
+ import { z as z2 } from "zod";
3893
3868
 
3894
3869
  // src/server/mcp/convert.ts
3870
+ init_file_io();
3895
3871
  init_provider();
3896
3872
  init_document_model();
3897
- init_file_io();
3898
- init_file_opener();
3899
3873
  init_document_service();
3874
+ init_file_opener();
3900
3875
  import fs5 from "fs/promises";
3901
3876
  import path7 from "path";
3902
3877
  async function findAvailablePath(basePath) {
@@ -3990,16 +3965,49 @@ async function convertToMarkdown(documentId, outputPath) {
3990
3965
  }
3991
3966
 
3992
3967
  // src/server/mcp/document.ts
3993
- init_manager();
3994
- init_file_opener();
3995
- init_constants();
3996
- init_types2();
3997
- init_queue();
3998
3968
  init_document_model();
3999
- init_positions2();
4000
3969
  init_document_service();
4001
- init_document_model();
3970
+ init_file_opener();
3971
+
3972
+ // src/server/mcp/response.ts
3973
+ function mcpSuccess(data) {
3974
+ return {
3975
+ content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
3976
+ };
3977
+ }
3978
+ function mcpError(code, message, details) {
3979
+ return {
3980
+ content: [
3981
+ {
3982
+ type: "text",
3983
+ text: JSON.stringify({ error: true, code, message, ...details && { details } })
3984
+ }
3985
+ ]
3986
+ };
3987
+ }
3988
+ function noDocumentError() {
3989
+ return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
3990
+ }
3991
+ function getErrorMessage(err) {
3992
+ return err instanceof Error ? err.message : String(err);
3993
+ }
3994
+ function withErrorBoundary(toolName, handler) {
3995
+ return async (args) => {
3996
+ try {
3997
+ return await handler(args);
3998
+ } catch (err) {
3999
+ console.error(`[Tandem] Tool ${toolName} threw:`, err);
4000
+ return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
4001
+ }
4002
+ };
4003
+ }
4004
+ function escapeRegex(str) {
4005
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4006
+ }
4007
+
4008
+ // src/server/mcp/document.ts
4002
4009
  init_positions2();
4010
+ init_document_model();
4003
4011
  init_document_service();
4004
4012
  init_file_opener();
4005
4013
  function getOutline(fragment) {
@@ -4298,15 +4306,12 @@ function registerDocumentTools(server) {
4298
4306
  withErrorBoundary("tandem_status", async () => {
4299
4307
  const activeId = getActiveDocId();
4300
4308
  const active = activeId ? openDocs2.get(activeId) : null;
4301
- let interruptionMode = INTERRUPTION_MODE_DEFAULT;
4302
- if (activeId) {
4303
- const doc = getOrCreateDocument(activeId);
4304
- const awareness = doc.getMap(Y_MAP_USER_AWARENESS);
4305
- interruptionMode = awareness.get("interruptionMode") ?? INTERRUPTION_MODE_DEFAULT;
4306
- }
4309
+ const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
4310
+ const ctrlAwareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
4311
+ const mode = TandemModeSchema.catch(TANDEM_MODE_DEFAULT).parse(ctrlAwareness.get(Y_MAP_MODE));
4307
4312
  return mcpSuccess({
4308
4313
  running: true,
4309
- interruptionMode,
4314
+ mode,
4310
4315
  activeDocument: active ? { documentId: active.id, filePath: active.filePath, format: active.format } : null,
4311
4316
  openDocuments: Array.from(openDocs2.values()).map((d) => ({
4312
4317
  documentId: d.id,
@@ -4398,12 +4403,56 @@ function registerDocumentTools(server) {
4398
4403
  );
4399
4404
  }
4400
4405
 
4406
+ // src/server/index.ts
4407
+ init_document_model();
4408
+ init_document_service();
4409
+ init_file_opener();
4410
+
4411
+ // src/server/mcp/server.ts
4412
+ import { existsSync } from "fs";
4413
+ import { dirname, join } from "path";
4414
+ import { fileURLToPath } from "url";
4415
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4416
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4417
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4418
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4419
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4420
+ import { randomUUID as randomUUID3 } from "crypto";
4421
+ import { createRequire } from "module";
4422
+
4423
+ // src/server/open-browser.ts
4424
+ import { execFile } from "child_process";
4425
+ function openBrowser(url) {
4426
+ let command;
4427
+ let args;
4428
+ if (process.platform === "win32") {
4429
+ command = "cmd";
4430
+ args = ["/c", "start", "", url];
4431
+ } else if (process.platform === "darwin") {
4432
+ command = "open";
4433
+ args = [url];
4434
+ } else {
4435
+ command = "xdg-open";
4436
+ args = [url];
4437
+ }
4438
+ execFile(command, args, (err) => {
4439
+ if (err) {
4440
+ console.error("[Tandem] Could not open browser automatically.");
4441
+ console.error(`[Tandem] Open this URL manually: ${url}`);
4442
+ }
4443
+ });
4444
+ }
4445
+
4401
4446
  // src/server/mcp/annotations.ts
4402
- init_docx();
4447
+ init_constants();
4403
4448
  init_types2();
4404
- init_positions2();
4405
- init_notifications();
4406
4449
  init_utils();
4450
+ init_queue();
4451
+ init_docx();
4452
+ init_notifications();
4453
+ init_positions2();
4454
+ init_provider();
4455
+ import { z as z3 } from "zod";
4407
4456
  init_positions2();
4408
4457
  function getDocAndAnnotations(documentId) {
4409
4458
  const doc = getCurrentDoc(documentId);
@@ -4467,6 +4516,15 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
4467
4516
  ...extras
4468
4517
  };
4469
4518
  ydoc.transact(() => map.set(id, annotation), MCP_ORIGIN);
4519
+ const snippet = annotation.textSnapshot ? `: "${annotation.textSnapshot.slice(0, 60)}${annotation.textSnapshot.length > 60 ? "\u2026" : ""}"` : "";
4520
+ pushNotification({
4521
+ id: generateNotificationId(),
4522
+ type: "review-pending",
4523
+ severity: "info",
4524
+ message: `New ${type[0].toUpperCase() + type.slice(1)}${snippet}`,
4525
+ dedupKey: `review-pending:${type}`,
4526
+ timestamp: Date.now()
4527
+ });
4470
4528
  return id;
4471
4529
  }
4472
4530
  function collectAnnotations(map) {
@@ -4491,16 +4549,13 @@ function registerAnnotationTools(server) {
4491
4549
  color: HighlightColorSchema.describe("Highlight color"),
4492
4550
  note: z3.string().optional().describe("Optional note for the highlight"),
4493
4551
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4494
- priority: AnnotationPrioritySchema.optional().describe(
4495
- "Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
4496
- ),
4497
4552
  textSnapshot: z3.string().optional().describe(
4498
4553
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4499
4554
  )
4500
4555
  },
4501
4556
  withErrorBoundary(
4502
4557
  "tandem_highlight",
4503
- async ({ from: rawFrom, to: rawTo, color, note, documentId, priority, textSnapshot }) => {
4558
+ async ({ from: rawFrom, to: rawTo, color, note, documentId, textSnapshot }) => {
4504
4559
  const da = getDocAndAnnotations(documentId);
4505
4560
  if (!da) return noDocumentError();
4506
4561
  const from = toFlatOffset(rawFrom);
@@ -4513,7 +4568,6 @@ function registerAnnotationTools(server) {
4513
4568
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4514
4569
  const id = createAnnotation(da.map, da.ydoc, "highlight", result, note || "", {
4515
4570
  color,
4516
- ...priority ? { priority } : {},
4517
4571
  textSnapshot: snap
4518
4572
  });
4519
4573
  return mcpSuccess({ annotationId: id });
@@ -4528,16 +4582,13 @@ function registerAnnotationTools(server) {
4528
4582
  to: z3.number().describe("End position"),
4529
4583
  text: z3.string().describe("Comment text"),
4530
4584
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4531
- priority: AnnotationPrioritySchema.optional().describe(
4532
- "Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
4533
- ),
4534
4585
  textSnapshot: z3.string().optional().describe(
4535
4586
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4536
4587
  )
4537
4588
  },
4538
4589
  withErrorBoundary(
4539
4590
  "tandem_comment",
4540
- async ({ from: rawFrom, to: rawTo, text, documentId, priority, textSnapshot }) => {
4591
+ async ({ from: rawFrom, to: rawTo, text, documentId, textSnapshot }) => {
4541
4592
  const da = getDocAndAnnotations(documentId);
4542
4593
  if (!da) return noDocumentError();
4543
4594
  const from = toFlatOffset(rawFrom);
@@ -4549,7 +4600,6 @@ function registerAnnotationTools(server) {
4549
4600
  }
4550
4601
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4551
4602
  const id = createAnnotation(da.map, da.ydoc, "comment", result, text, {
4552
- ...priority ? { priority } : {},
4553
4603
  textSnapshot: snap
4554
4604
  });
4555
4605
  return mcpSuccess({ annotationId: id });
@@ -4565,16 +4615,13 @@ function registerAnnotationTools(server) {
4565
4615
  newText: z3.string().describe("Suggested replacement text"),
4566
4616
  reason: z3.string().optional().describe("Reason for the suggestion"),
4567
4617
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4568
- priority: AnnotationPrioritySchema.optional().describe(
4569
- "Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
4570
- ),
4571
4618
  textSnapshot: z3.string().optional().describe(
4572
4619
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4573
4620
  )
4574
4621
  },
4575
4622
  withErrorBoundary(
4576
4623
  "tandem_suggest",
4577
- async ({ from: rawFrom, to: rawTo, newText, reason, documentId, priority, textSnapshot }) => {
4624
+ async ({ from: rawFrom, to: rawTo, newText, reason, documentId, textSnapshot }) => {
4578
4625
  const da = getDocAndAnnotations(documentId);
4579
4626
  if (!da) return noDocumentError();
4580
4627
  const from = toFlatOffset(rawFrom);
@@ -4591,7 +4638,7 @@ function registerAnnotationTools(server) {
4591
4638
  "suggestion",
4592
4639
  result,
4593
4640
  JSON.stringify({ newText, reason: reason || "" }),
4594
- { ...priority ? { priority } : {}, textSnapshot: snap }
4641
+ { textSnapshot: snap }
4595
4642
  );
4596
4643
  return mcpSuccess({ annotationId: id });
4597
4644
  }
@@ -4605,16 +4652,13 @@ function registerAnnotationTools(server) {
4605
4652
  to: z3.number().describe("End position"),
4606
4653
  note: z3.string().optional().describe("Reason for flagging"),
4607
4654
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4608
- priority: AnnotationPrioritySchema.optional().describe(
4609
- "Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
4610
- ),
4611
4655
  textSnapshot: z3.string().optional().describe(
4612
4656
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4613
4657
  )
4614
4658
  },
4615
4659
  withErrorBoundary(
4616
4660
  "tandem_flag",
4617
- async ({ from: rawFrom, to: rawTo, note, documentId, priority, textSnapshot }) => {
4661
+ async ({ from: rawFrom, to: rawTo, note, documentId, textSnapshot }) => {
4618
4662
  const da = getDocAndAnnotations(documentId);
4619
4663
  if (!da) return noDocumentError();
4620
4664
  const from = toFlatOffset(rawFrom);
@@ -4626,7 +4670,6 @@ function registerAnnotationTools(server) {
4626
4670
  }
4627
4671
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4628
4672
  const id = createAnnotation(da.map, da.ydoc, "flag", result, note || "", {
4629
- ...priority ? { priority } : {},
4630
4673
  textSnapshot: snap
4631
4674
  });
4632
4675
  return mcpSuccess({ annotationId: id });
@@ -4781,20 +4824,21 @@ function registerAnnotationTools(server) {
4781
4824
 
4782
4825
  // src/server/mcp/api-routes.ts
4783
4826
  init_constants();
4827
+ init_types2();
4828
+ init_notifications();
4829
+ init_provider();
4784
4830
  init_document_model();
4785
- init_file_opener();
4786
4831
  init_document_service();
4787
- init_notifications();
4788
4832
 
4789
4833
  // src/server/mcp/docx-apply.ts
4790
- init_document_service();
4791
4834
  init_constants();
4835
+ init_file_io();
4792
4836
  init_positions2();
4793
4837
  init_document_model();
4794
- init_file_io();
4795
- import { z as z4 } from "zod";
4838
+ init_document_service();
4796
4839
  import fs6 from "fs/promises";
4797
4840
  import path8 from "path";
4841
+ import { z as z4 } from "zod";
4798
4842
  async function applyChangesCore(documentId, author, backupPath) {
4799
4843
  const r = requireDocument(documentId);
4800
4844
  if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
@@ -4968,6 +5012,7 @@ function registerApplyTools(server) {
4968
5012
  }
4969
5013
 
4970
5014
  // src/server/mcp/api-routes.ts
5015
+ init_file_opener();
4971
5016
  function isHostAllowed(host) {
4972
5017
  const reqHost = (host ?? "").split(":")[0];
4973
5018
  return reqHost === "localhost" || reqHost === "127.0.0.1";
@@ -5169,6 +5214,12 @@ function registerApiRoutes(app, largeBody) {
5169
5214
  sendApiError(res, err);
5170
5215
  }
5171
5216
  });
5217
+ app.get("/api/mode", apiMiddleware, (_req, res) => {
5218
+ const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
5219
+ const awareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
5220
+ const mode = TandemModeSchema.catch(TANDEM_MODE_DEFAULT).parse(awareness.get(Y_MAP_MODE));
5221
+ res.json({ mode });
5222
+ });
5172
5223
  app.options("/api/apply-changes", apiMiddleware);
5173
5224
  app.post("/api/apply-changes", apiMiddleware, largeBody, async (req, res) => {
5174
5225
  const { documentId, author, backupPath } = req.body ?? {};
@@ -5198,11 +5249,12 @@ function registerApiRoutes(app, largeBody) {
5198
5249
  }
5199
5250
 
5200
5251
  // src/server/mcp/awareness.ts
5201
- init_provider();
5202
- import { z as z5 } from "zod";
5203
- init_utils();
5204
5252
  init_constants();
5253
+ init_types2();
5254
+ init_utils();
5205
5255
  init_queue();
5256
+ init_provider();
5257
+ import { z as z5 } from "zod";
5206
5258
  var surfacedIds = /* @__PURE__ */ new Set();
5207
5259
  function registerAwarenessTools(server) {
5208
5260
  server.tool(
@@ -5307,7 +5359,8 @@ function registerAwarenessTools(server) {
5307
5359
  const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
5308
5360
  const selection = userAwareness.get("selection");
5309
5361
  const activity = userAwareness.get("activity");
5310
- const interruptionMode = userAwareness.get("interruptionMode") ?? INTERRUPTION_MODE_DEFAULT;
5362
+ const ctrlAwareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
5363
+ const mode = TandemModeSchema.catch(TANDEM_MODE_DEFAULT).parse(ctrlAwareness.get(Y_MAP_MODE));
5311
5364
  const hasSelection = selection && selection.from !== selection.to;
5312
5365
  const selectedText = hasSelection ? safeSlice2(fullText, selection.from, selection.to) : null;
5313
5366
  const parts = [];
@@ -5335,7 +5388,7 @@ function registerAwarenessTools(server) {
5335
5388
  return mcpSuccess({
5336
5389
  summary,
5337
5390
  hasNew,
5338
- interruptionMode,
5391
+ mode,
5339
5392
  userActions,
5340
5393
  userResponses,
5341
5394
  chatMessages,
@@ -5875,60 +5928,11 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
5875
5928
  });
5876
5929
  }
5877
5930
 
5878
- // src/server/index.ts
5879
- init_provider();
5880
- init_constants();
5881
- init_manager();
5882
- init_platform();
5883
-
5884
- // src/server/version-check.ts
5885
- import fs7 from "fs/promises";
5886
- import path9 from "path";
5887
- async function checkVersionChange(currentVersion, versionFilePath) {
5888
- let storedVersion = null;
5889
- try {
5890
- storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
5891
- } catch (err) {
5892
- if (err.code !== "ENOENT") {
5893
- console.error("[Tandem] Failed to read last-seen-version:", err);
5894
- }
5895
- }
5896
- const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
5897
- if (result !== "current") {
5898
- await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
5899
- await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
5900
- }
5901
- return result;
5902
- }
5903
-
5904
- // src/server/error-filter.ts
5905
- function isKnownHocuspocusError(err) {
5906
- if (!(err instanceof Error)) return false;
5907
- if ("code" in err) {
5908
- const code = err.code;
5909
- if (typeof code === "string" && code.startsWith("WS_ERR_")) {
5910
- return true;
5911
- }
5912
- }
5913
- const msg = err.message;
5914
- if (msg.startsWith("WebSocket is not open")) return true;
5915
- if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
5916
- if (msg.startsWith("Received a message with an unknown type:")) return true;
5917
- return false;
5918
- }
5919
-
5920
- // src/server/index.ts
5921
- init_queue();
5922
- init_document_service();
5923
- init_file_watcher();
5924
- init_file_opener();
5925
- init_document_model();
5926
-
5927
5931
  // src/server/mcp/tutorial-annotations.ts
5928
5932
  init_constants();
5933
+ init_types2();
5929
5934
  init_queue();
5930
5935
  init_positions2();
5931
- init_types2();
5932
5936
  init_document_model();
5933
5937
  var TUTORIAL_ANNOTATIONS = [
5934
5938
  {
@@ -6004,6 +6008,31 @@ function injectTutorialAnnotations(doc) {
6004
6008
  }
6005
6009
 
6006
6010
  // src/server/index.ts
6011
+ init_platform();
6012
+ init_manager();
6013
+
6014
+ // src/server/version-check.ts
6015
+ import fs7 from "fs/promises";
6016
+ import path9 from "path";
6017
+ async function checkVersionChange(currentVersion, versionFilePath) {
6018
+ let storedVersion = null;
6019
+ try {
6020
+ storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
6021
+ } catch (err) {
6022
+ if (err.code !== "ENOENT") {
6023
+ console.error("[Tandem] Failed to read last-seen-version:", err);
6024
+ }
6025
+ }
6026
+ const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
6027
+ if (result !== "current") {
6028
+ await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
6029
+ await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
6030
+ }
6031
+ return result;
6032
+ }
6033
+
6034
+ // src/server/index.ts
6035
+ init_provider();
6007
6036
  var isProduction = process.env.TANDEM_OPEN_BROWSER === "1";
6008
6037
  var SUPPRESSED_PATTERNS = [/^\[mammoth\]/, /Invalid access/i, /^\s*add yjs type/i];
6009
6038
  var originalStderrWrite = process.stderr.write.bind(process.stderr);