tandem-editor 0.3.0 → 0.3.2

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, TANDEM_MODE_DEFAULT, SELECTION_DWELL_DEFAULT_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_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";
@@ -22,6 +22,8 @@ var init_constants = __esm({
22
22
  SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
23
23
  TANDEM_MODE_DEFAULT = "tandem";
24
24
  SELECTION_DWELL_DEFAULT_MS = 1e3;
25
+ SELECTION_DWELL_MIN_MS = 500;
26
+ SELECTION_DWELL_MAX_MS = 3e3;
25
27
  CHARS_PER_PAGE = 3e3;
26
28
  LARGE_FILE_PAGE_THRESHOLD = 50;
27
29
  VERY_LARGE_FILE_PAGE_THRESHOLD = 100;
@@ -30,6 +32,7 @@ var init_constants = __esm({
30
32
  Y_MAP_AWARENESS = "awareness";
31
33
  Y_MAP_USER_AWARENESS = "userAwareness";
32
34
  Y_MAP_MODE = "mode";
35
+ Y_MAP_DWELL_MS = "selectionDwellMs";
33
36
  Y_MAP_CHAT = "chat";
34
37
  Y_MAP_DOCUMENT_META = "documentMeta";
35
38
  Y_MAP_SAVED_AT_VERSION = "savedAtVersion";
@@ -41,504 +44,146 @@ var init_constants = __esm({
41
44
  }
42
45
  });
43
46
 
44
- // src/server/yjs/provider.ts
45
- import { Hocuspocus } from "@hocuspocus/server";
46
- import * as Y from "yjs";
47
- function setDocLifecycleCallbacks(swapped, unloaded) {
48
- onDocSwapped = swapped;
49
- onDocUnloaded = unloaded;
50
- }
51
- function setShouldKeepDocument(fn) {
52
- shouldKeepDocument = fn;
53
- }
54
- function getDocument(name) {
55
- return documents.get(name);
56
- }
57
- function getOrCreateDocument(name) {
58
- let doc = documents.get(name);
59
- if (!doc) {
60
- doc = new Y.Doc();
61
- documents.set(name, doc);
62
- }
63
- return doc;
64
- }
65
- async function startHocuspocus(port) {
66
- hocuspocusInstance = new Hocuspocus({
67
- port,
68
- address: "127.0.0.1",
69
- quiet: true,
70
- // stdout is the MCP wire — suppress the startup banner
71
- async onConnect({ request, documentName }) {
72
- const origin = request?.headers?.origin;
73
- if (!origin) {
74
- console.error("[Hocuspocus] Rejected connection: missing Origin header");
75
- throw new Error("Connection rejected: missing origin header");
76
- }
77
- const url = new URL(origin);
78
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
79
- console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
80
- throw new Error("Connection rejected: invalid origin");
81
- }
82
- console.error(`[Hocuspocus] Client connected to: ${documentName}`);
83
- },
84
- async onDisconnect({ documentName }) {
85
- console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
86
- },
87
- async onLoadDocument({ document, documentName }) {
88
- console.error(`[Hocuspocus] Loading document: ${documentName}`);
89
- const existing = documents.get(documentName);
90
- if (existing && existing !== document) {
91
- const update = Y.encodeStateAsUpdate(existing);
92
- Y.applyUpdate(document, update);
93
- existing.destroy();
94
- console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
95
- }
96
- documents.set(documentName, document);
97
- if (onDocSwapped) {
98
- onDocSwapped(documentName, document);
99
- } else {
100
- console.error(
101
- `[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
102
- );
103
- }
104
- return document;
105
- },
106
- async afterUnloadDocument({ documentName }) {
107
- if (shouldKeepDocument?.(documentName)) {
108
- console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
109
- return;
110
- }
111
- if (documents.has(documentName)) {
112
- onDocUnloaded?.(documentName);
113
- documents.delete(documentName);
114
- console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
115
- }
47
+ // src/shared/sanitize.ts
48
+ function sanitizeAnnotation(input) {
49
+ const ann = input;
50
+ const base = {
51
+ id: ann.id,
52
+ author: ann.author,
53
+ range: ann.range,
54
+ content: ann.content,
55
+ status: ann.status,
56
+ timestamp: ann.timestamp,
57
+ ...ann.relRange !== void 0 ? { relRange: ann.relRange } : {},
58
+ ...ann.textSnapshot !== void 0 ? { textSnapshot: ann.textSnapshot } : {},
59
+ ...ann.editedAt !== void 0 ? { editedAt: ann.editedAt } : {}
60
+ };
61
+ if (ann.type === "suggestion") {
62
+ let suggestedText;
63
+ let content;
64
+ try {
65
+ const parsed = JSON.parse(ann.content);
66
+ suggestedText = parsed.newText;
67
+ content = parsed.reason ?? "";
68
+ } catch {
69
+ console.warn(
70
+ `[sanitizeAnnotation] Malformed JSON in legacy suggestion ${ann.id}, treating as plain comment`
71
+ );
72
+ content = ann.content;
116
73
  }
117
- });
118
- await hocuspocusInstance.listen();
119
- const internal = hocuspocusInstance.server?.httpServer;
120
- if (internal) {
121
- internal.on("error", (err) => {
122
- console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
123
- });
74
+ return { ...base, type: "comment", content, suggestedText };
124
75
  }
125
- return hocuspocusInstance;
76
+ if (ann.type === "question") {
77
+ return { ...base, type: "comment", directedAt: "claude" };
78
+ }
79
+ if (ann.type === "highlight") {
80
+ return {
81
+ ...base,
82
+ type: "highlight",
83
+ color: ann.color
84
+ };
85
+ }
86
+ if (ann.type === "flag") {
87
+ return { ...base, type: "flag" };
88
+ }
89
+ if (ann.type === "comment") {
90
+ return {
91
+ ...base,
92
+ type: "comment",
93
+ ...ann.suggestedText !== void 0 ? { suggestedText: ann.suggestedText } : {},
94
+ ...ann.directedAt !== void 0 ? { directedAt: ann.directedAt } : {}
95
+ };
96
+ }
97
+ console.warn(
98
+ `[sanitizeAnnotation] Unknown type "${ann.type}" for ${ann.id}, coercing to "comment"`
99
+ );
100
+ return { ...base, type: "comment" };
126
101
  }
127
- var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
128
- var init_provider = __esm({
129
- "src/server/yjs/provider.ts"() {
102
+ var init_sanitize = __esm({
103
+ "src/shared/sanitize.ts"() {
130
104
  "use strict";
131
- hocuspocusInstance = null;
132
- documents = /* @__PURE__ */ new Map();
133
- shouldKeepDocument = null;
134
- onDocSwapped = null;
135
- onDocUnloaded = null;
136
105
  }
137
106
  });
138
107
 
139
- // src/server/platform.ts
140
- import { execSync } from "child_process";
141
- import net from "net";
142
- import path from "path";
143
- import envPaths from "env-paths";
144
- function freePort(port) {
145
- try {
146
- if (process.platform === "win32") {
147
- freePortWindows(port);
148
- } else {
149
- freePortUnix(port);
150
- }
151
- } catch (err) {
152
- console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
108
+ // src/shared/positions/types.ts
109
+ var toFlatOffset, toSerializedRelPos;
110
+ var init_types = __esm({
111
+ "src/shared/positions/types.ts"() {
112
+ "use strict";
113
+ toFlatOffset = (n) => n;
114
+ toSerializedRelPos = (json) => json;
153
115
  }
154
- }
155
- async function waitForPort(port, timeoutMs = 5e3) {
156
- const start = Date.now();
157
- while (Date.now() - start < timeoutMs) {
158
- if (await tryBind(port)) return;
159
- await new Promise((r) => setTimeout(r, 200));
116
+ });
117
+
118
+ // src/shared/types.ts
119
+ import { z } from "zod";
120
+ var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
121
+ var init_types2 = __esm({
122
+ "src/shared/types.ts"() {
123
+ "use strict";
124
+ init_types();
125
+ AnnotationTypeSchema = z.enum(["highlight", "comment", "flag"]);
126
+ AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
127
+ HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
128
+ SeveritySchema = z.enum(["info", "warning", "error", "success"]);
129
+ TandemModeSchema = z.enum(["solo", "tandem"]);
130
+ AuthorSchema = z.enum(["user", "claude", "import"]);
131
+ AnnotationActionSchema = z.enum(["accept", "dismiss"]);
132
+ ExportFormatSchema = z.enum(["markdown", "json"]);
133
+ DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
134
+ ToolErrorCodeSchema = z.enum([
135
+ "RANGE_GONE",
136
+ "RANGE_MOVED",
137
+ "FILE_LOCKED",
138
+ "FILE_NOT_FOUND",
139
+ "NO_DOCUMENT",
140
+ "INVALID_RANGE",
141
+ "FORMAT_ERROR",
142
+ "PERMISSION_DENIED"
143
+ ]);
160
144
  }
161
- throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
162
- }
163
- function tryBind(port) {
164
- return new Promise((resolve, reject) => {
165
- const srv = net.createServer();
166
- srv.once("error", (err) => {
167
- srv.close(() => {
168
- if (err.code === "EADDRINUSE") {
169
- resolve(false);
170
- } else {
171
- reject(err);
172
- }
173
- });
174
- });
175
- srv.listen(port, "127.0.0.1", () => {
176
- srv.close(() => resolve(true));
177
- });
178
- });
145
+ });
146
+
147
+ // src/shared/utils.ts
148
+ function generateId(prefix) {
149
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
179
150
  }
180
- function parseLsofPids(output) {
181
- return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
151
+ function generateAnnotationId() {
152
+ return generateId("ann");
182
153
  }
183
- function parseSsPid(output) {
184
- const match = output.match(/pid=(\d+)/);
185
- return match ? parseInt(match[1], 10) : null;
154
+ function generateMessageId() {
155
+ return generateId("msg");
186
156
  }
187
- function freePortWindows(port) {
188
- const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
189
- encoding: "utf-8",
190
- stdio: ["pipe", "pipe", "ignore"]
191
- });
192
- const pid = out.trim().split(/\s+/).at(-1);
193
- if (pid && /^\d+$/.test(pid)) {
194
- execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
195
- console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
196
- }
157
+ function generateEventId() {
158
+ return generateId("evt");
197
159
  }
198
- function freePortUnix(port) {
199
- let pids = [];
200
- try {
201
- const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
202
- encoding: "utf-8",
203
- stdio: ["pipe", "pipe", "ignore"]
204
- });
205
- pids = parseLsofPids(out);
206
- } catch {
207
- try {
208
- const out = execSync(`ss -tlnp sport = :${port}`, {
209
- encoding: "utf-8",
210
- stdio: ["pipe", "pipe", "ignore"]
211
- });
212
- const pid = parseSsPid(out);
213
- if (pid) pids = [pid];
214
- } catch {
215
- }
216
- }
217
- for (const pid of pids) {
218
- try {
219
- process.kill(pid, "SIGKILL");
220
- console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
221
- } catch {
222
- }
223
- }
160
+ function generateNotificationId() {
161
+ return generateId("ntf");
224
162
  }
225
- var paths, SESSION_DIR, LAST_SEEN_VERSION_FILE;
226
- var init_platform = __esm({
227
- "src/server/platform.ts"() {
163
+ var init_utils = __esm({
164
+ "src/shared/utils.ts"() {
228
165
  "use strict";
229
- paths = envPaths("tandem", { suffix: "" });
230
- SESSION_DIR = path.join(paths.data, "sessions");
231
- LAST_SEEN_VERSION_FILE = path.join(paths.data, "last-seen-version");
232
166
  }
233
167
  });
234
168
 
235
- // src/server/session/manager.ts
236
- import fs from "fs/promises";
237
- import path2 from "path";
238
- import * as Y2 from "yjs";
239
- async function atomicWrite(sessionPath, content) {
240
- const tmpPath = `${sessionPath}.tmp`;
241
- await fs.writeFile(tmpPath, content, "utf-8");
242
- for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
243
- try {
244
- await fs.rename(tmpPath, sessionPath);
245
- return;
246
- } catch (err) {
247
- const code = err.code;
248
- if ((code === "EPERM" || code === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
249
- await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
250
- continue;
251
- }
252
- await fs.unlink(tmpPath).catch(() => {
253
- });
254
- throw err;
255
- }
256
- }
169
+ // src/shared/offsets.ts
170
+ function headingPrefixLength(level) {
171
+ if (!level) return 0;
172
+ return level + 1;
257
173
  }
258
- function sessionKey(filePath) {
259
- return encodeURIComponent(filePath.replace(/\\/g, "/"));
174
+ function headingPrefix(level) {
175
+ return "#".repeat(level) + " ";
260
176
  }
261
- async function saveSession(filePath, format, doc) {
262
- const key = sessionKey(filePath);
263
- let sourceFileMtime = 0;
264
- if (!filePath.startsWith("upload://")) {
265
- try {
266
- const stat = await fs.stat(filePath);
267
- sourceFileMtime = stat.mtimeMs;
268
- } catch {
269
- }
270
- }
271
- const state = Y2.encodeStateAsUpdate(doc);
272
- const ydocState = Buffer.from(state).toString("base64");
273
- const data = {
274
- filePath,
275
- format,
276
- ydocState,
277
- sourceFileMtime,
278
- lastAccessed: Date.now()
279
- };
280
- if (!sessionDirReady) {
281
- await fs.mkdir(SESSION_DIR, { recursive: true });
282
- sessionDirReady = true;
283
- }
284
- const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
285
- await atomicWrite(sessionPath, JSON.stringify(data));
286
- }
287
- async function loadSession(filePath) {
288
- const key = sessionKey(filePath);
289
- const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
290
- try {
291
- const content = await fs.readFile(sessionPath, "utf-8");
292
- return JSON.parse(content);
293
- } catch (err) {
294
- const code = err.code;
295
- if (code === "ENOENT") return null;
296
- if (err instanceof SyntaxError) {
297
- console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
298
- await fs.unlink(sessionPath).catch((unlinkErr) => {
299
- console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
300
- });
301
- return null;
302
- }
303
- console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
304
- return null;
305
- }
306
- }
307
- function restoreYDoc(doc, session) {
308
- const state = Buffer.from(session.ydocState, "base64");
309
- Y2.applyUpdate(doc, new Uint8Array(state));
310
- }
311
- async function sourceFileChanged(session) {
312
- if (session.filePath.startsWith("upload://")) return false;
313
- try {
314
- const stat = await fs.stat(session.filePath);
315
- return stat.mtimeMs !== session.sourceFileMtime;
316
- } catch {
317
- return true;
318
- }
319
- }
320
- async function deleteSession(filePath) {
321
- const key = sessionKey(filePath);
322
- const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
323
- try {
324
- await fs.unlink(sessionPath);
325
- } catch (err) {
326
- const code = err.code;
327
- if (code !== "ENOENT") {
328
- console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
329
- }
330
- }
331
- }
332
- async function saveCtrlSession(doc) {
333
- if (!sessionDirReady) {
334
- await fs.mkdir(SESSION_DIR, { recursive: true });
335
- sessionDirReady = true;
336
- }
337
- const chatMap = doc.getMap(Y_MAP_CHAT);
338
- const entries = [];
339
- chatMap.forEach((value, key) => {
340
- const msg = value;
341
- entries.push({ id: key, timestamp: msg.timestamp });
342
- });
343
- if (entries.length > 200) {
344
- entries.sort((a, b) => a.timestamp - b.timestamp);
345
- const toDelete = entries.slice(0, entries.length - 200);
346
- doc.transact(() => {
347
- for (const entry of toDelete) {
348
- chatMap.delete(entry.id);
349
- }
350
- }, MCP_ORIGIN);
351
- }
352
- const state = Y2.encodeStateAsUpdate(doc);
353
- const ydocState = Buffer.from(state).toString("base64");
354
- const data = { ydocState, lastAccessed: Date.now() };
355
- const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
356
- await atomicWrite(sessionPath, JSON.stringify(data));
357
- }
358
- async function loadCtrlSession() {
359
- const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
360
- try {
361
- const content = await fs.readFile(sessionPath, "utf-8");
362
- const data = JSON.parse(content);
363
- return data.ydocState ?? null;
364
- } catch (err) {
365
- const code = err.code;
366
- if (code === "ENOENT") return null;
367
- if (err instanceof SyntaxError) {
368
- console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
369
- await fs.unlink(sessionPath).catch((unlinkErr) => {
370
- console.error(
371
- `[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
372
- unlinkErr
373
- );
374
- });
375
- return null;
376
- }
377
- console.error(`[Tandem] Failed to read ctrl session:`, err);
378
- return null;
379
- }
380
- }
381
- function restoreCtrlDoc(doc, base64State) {
382
- const state = Buffer.from(base64State, "base64");
383
- Y2.applyUpdate(doc, new Uint8Array(state));
384
- }
385
- async function listSessionFilePaths() {
386
- try {
387
- await fs.mkdir(SESSION_DIR, { recursive: true });
388
- const files = await fs.readdir(SESSION_DIR);
389
- const results = [];
390
- for (const file of files) {
391
- if (!file.endsWith(".json")) continue;
392
- if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
393
- try {
394
- const raw = await fs.readFile(path2.join(SESSION_DIR, file), "utf-8");
395
- const data = JSON.parse(raw);
396
- if (!data.filePath || data.filePath.startsWith("upload://")) continue;
397
- results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
398
- } catch (err) {
399
- console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
400
- }
401
- }
402
- results.sort((a, b) => b.lastAccessed - a.lastAccessed);
403
- return results;
404
- } catch (err) {
405
- console.error("[Tandem] Failed to read session directory:", err);
406
- return [];
407
- }
408
- }
409
- async function cleanupSessions() {
410
- let cleaned = 0;
411
- let files;
412
- try {
413
- files = await fs.readdir(SESSION_DIR);
414
- } catch (err) {
415
- if (err.code === "ENOENT") return 0;
416
- console.error("[Tandem] Failed to read session directory:", err);
417
- return 0;
418
- }
419
- const now = Date.now();
420
- for (const file of files) {
421
- try {
422
- const filePath = path2.join(SESSION_DIR, file);
423
- const stat = await fs.stat(filePath);
424
- if (now - stat.mtimeMs > SESSION_MAX_AGE) {
425
- await fs.unlink(filePath);
426
- cleaned++;
427
- }
428
- } catch (err) {
429
- console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
430
- }
431
- }
432
- return cleaned;
433
- }
434
- function isAutoSaveRunning() {
435
- return autoSaveTimer !== null;
436
- }
437
- function startAutoSave(callback) {
438
- stopAutoSave();
439
- autoSaveCallback = callback;
440
- autoSaveTimer = setInterval(async () => {
441
- try {
442
- await autoSaveCallback?.();
443
- } catch (err) {
444
- console.error("[Tandem] Auto-save failed:", err);
445
- }
446
- }, AUTO_SAVE_INTERVAL);
447
- }
448
- function stopAutoSave() {
449
- if (autoSaveTimer) {
450
- clearInterval(autoSaveTimer);
451
- autoSaveTimer = null;
452
- }
453
- autoSaveCallback = null;
454
- }
455
- var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
456
- var init_manager = __esm({
457
- "src/server/session/manager.ts"() {
458
- "use strict";
459
- init_platform();
460
- init_constants();
461
- init_queue();
462
- AUTO_SAVE_INTERVAL = 60 * 1e3;
463
- RENAME_MAX_RETRIES = 3;
464
- RENAME_RETRY_BASE_MS = 50;
465
- sessionDirReady = false;
466
- CTRL_SESSION_KEY = CTRL_ROOM;
467
- autoSaveTimer = null;
468
- autoSaveCallback = null;
469
- }
470
- });
471
-
472
- // src/server/file-watcher.ts
473
- import fs2 from "fs";
474
- function watchFile(filePath, onChanged) {
475
- if (watched.has(filePath)) return;
476
- let watcher;
477
- try {
478
- watcher = fs2.watch(filePath, (eventType) => {
479
- if (eventType !== "change") return;
480
- const entry = watched.get(filePath);
481
- if (!entry) return;
482
- if (entry.suppressed) {
483
- entry.suppressed = false;
484
- return;
485
- }
486
- if (entry.timer !== null) {
487
- clearTimeout(entry.timer);
488
- }
489
- entry.timer = setTimeout(() => {
490
- entry.timer = null;
491
- onChanged(filePath).catch((err) => {
492
- console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
493
- });
494
- }, 500);
495
- });
496
- } catch (err) {
497
- console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
498
- return;
499
- }
500
- watcher.on("error", (err) => {
501
- console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
502
- unwatchFile(filePath);
503
- });
504
- watched.set(filePath, { watcher, timer: null, suppressed: false });
505
- console.error(`[FileWatcher] Watching ${filePath}`);
506
- }
507
- function suppressNextChange(filePath) {
508
- const entry = watched.get(filePath);
509
- if (entry) {
510
- entry.suppressed = true;
511
- }
512
- }
513
- function unwatchFile(filePath) {
514
- const entry = watched.get(filePath);
515
- if (!entry) return;
516
- if (entry.timer !== null) {
517
- clearTimeout(entry.timer);
518
- }
519
- try {
520
- entry.watcher.close();
521
- } catch (err) {
522
- console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
523
- }
524
- watched.delete(filePath);
525
- console.error(`[FileWatcher] Unwatched ${filePath}`);
526
- }
527
- function unwatchAll() {
528
- for (const filePath of [...watched.keys()]) {
529
- unwatchFile(filePath);
530
- }
531
- }
532
- var watched;
533
- var init_file_watcher = __esm({
534
- "src/server/file-watcher.ts"() {
177
+ var FLAT_SEPARATOR;
178
+ var init_offsets = __esm({
179
+ "src/shared/offsets.ts"() {
535
180
  "use strict";
536
- watched = /* @__PURE__ */ new Map();
181
+ FLAT_SEPARATOR = "\n";
537
182
  }
538
183
  });
539
184
 
540
185
  // src/server/file-io/mdast-ydoc.ts
541
- import * as Y3 from "yjs";
186
+ import * as Y from "yjs";
542
187
  function mdastToYDoc(doc, tree) {
543
188
  const fragment = doc.getXmlFragment("default");
544
189
  if (fragment.length > 0) {
@@ -563,22 +208,22 @@ function mdastToYDoc(doc, tree) {
563
208
  function blockToYxml(node, deferred) {
564
209
  switch (node.type) {
565
210
  case "heading": {
566
- const el = new Y3.XmlElement("heading");
211
+ const el = new Y.XmlElement("heading");
567
212
  el.setAttribute("level", node.depth);
568
- const text = new Y3.XmlText();
213
+ const text = new Y.XmlText();
569
214
  el.insert(0, [text]);
570
215
  deferred.push({ xmlText: text, nodes: node.children });
571
216
  return [el];
572
217
  }
573
218
  case "paragraph": {
574
- const el = new Y3.XmlElement("paragraph");
575
- const text = new Y3.XmlText();
219
+ const el = new Y.XmlElement("paragraph");
220
+ const text = new Y.XmlText();
576
221
  el.insert(0, [text]);
577
222
  deferred.push({ xmlText: text, nodes: node.children });
578
223
  return [el];
579
224
  }
580
225
  case "blockquote": {
581
- const el = new Y3.XmlElement("blockquote");
226
+ const el = new Y.XmlElement("blockquote");
582
227
  for (const child of node.children) {
583
228
  const childEls = blockToYxml(child, deferred);
584
229
  for (const c of childEls) {
@@ -589,12 +234,12 @@ function blockToYxml(node, deferred) {
589
234
  }
590
235
  case "list": {
591
236
  const nodeName = node.ordered ? "orderedList" : "bulletList";
592
- const el = new Y3.XmlElement(nodeName);
237
+ const el = new Y.XmlElement(nodeName);
593
238
  if (node.ordered && node.start != null && node.start !== 1) {
594
239
  el.setAttribute("start", node.start);
595
240
  }
596
241
  for (const item of node.children) {
597
- const listItem = new Y3.XmlElement("listItem");
242
+ const listItem = new Y.XmlElement("listItem");
598
243
  for (const child of item.children) {
599
244
  const childEls = blockToYxml(child, deferred);
600
245
  for (const c of childEls) {
@@ -606,20 +251,20 @@ function blockToYxml(node, deferred) {
606
251
  return [el];
607
252
  }
608
253
  case "code": {
609
- const el = new Y3.XmlElement("codeBlock");
254
+ const el = new Y.XmlElement("codeBlock");
610
255
  if (node.lang) {
611
256
  el.setAttribute("language", node.lang);
612
257
  }
613
- const text = new Y3.XmlText();
258
+ const text = new Y.XmlText();
614
259
  el.insert(0, [text]);
615
260
  deferred.push({ xmlText: text, plainText: node.value });
616
261
  return [el];
617
262
  }
618
263
  case "thematicBreak": {
619
- return [new Y3.XmlElement("horizontalRule")];
264
+ return [new Y.XmlElement("horizontalRule")];
620
265
  }
621
266
  case "image": {
622
- const el = new Y3.XmlElement("image");
267
+ const el = new Y.XmlElement("image");
623
268
  el.setAttribute("src", node.url);
624
269
  if (node.alt) el.setAttribute("alt", node.alt);
625
270
  if (node.title) el.setAttribute("title", node.title);
@@ -628,8 +273,8 @@ function blockToYxml(node, deferred) {
628
273
  // html blocks, definitions, etc. — wrap as paragraphs to avoid data loss
629
274
  default: {
630
275
  if ("value" in node && typeof node.value === "string") {
631
- const el = new Y3.XmlElement("paragraph");
632
- const text = new Y3.XmlText();
276
+ const el = new Y.XmlElement("paragraph");
277
+ const text = new Y.XmlText();
633
278
  el.insert(0, [text]);
634
279
  deferred.push({ xmlText: text, plainText: node.value });
635
280
  return [el];
@@ -672,7 +317,7 @@ function processInline(xmlText, nodes, marks) {
672
317
  });
673
318
  break;
674
319
  case "break": {
675
- const embed = new Y3.XmlElement("hardBreak");
320
+ const embed = new Y.XmlElement("hardBreak");
676
321
  xmlText.insertEmbed(xmlText.length, embed);
677
322
  break;
678
323
  }
@@ -694,7 +339,7 @@ function yDocToMdast(doc) {
694
339
  const children = [];
695
340
  for (let i = 0; i < fragment.length; i++) {
696
341
  const node = fragment.get(i);
697
- if (node instanceof Y3.XmlElement) {
342
+ if (node instanceof Y.XmlElement) {
698
343
  const mdastNode = yxmlToMdast(node);
699
344
  if (mdastNode) children.push(mdastNode);
700
345
  }
@@ -713,7 +358,7 @@ function yxmlToMdast(el) {
713
358
  const children = [];
714
359
  for (let i = 0; i < el.length; i++) {
715
360
  const child = el.get(i);
716
- if (child instanceof Y3.XmlElement) {
361
+ if (child instanceof Y.XmlElement) {
717
362
  const m = yxmlToMdast(child);
718
363
  if (m) children.push(m);
719
364
  }
@@ -727,11 +372,11 @@ function yxmlToMdast(el) {
727
372
  const listItems = [];
728
373
  for (let i = 0; i < el.length; i++) {
729
374
  const child = el.get(i);
730
- if (child instanceof Y3.XmlElement && child.nodeName === "listItem") {
375
+ if (child instanceof Y.XmlElement && child.nodeName === "listItem") {
731
376
  const itemChildren = [];
732
377
  for (let j = 0; j < child.length; j++) {
733
378
  const grandchild = child.get(j);
734
- if (grandchild instanceof Y3.XmlElement) {
379
+ if (grandchild instanceof Y.XmlElement) {
735
380
  const m = yxmlToMdast(grandchild);
736
381
  if (m) itemChildren.push(m);
737
382
  }
@@ -752,7 +397,7 @@ function yxmlToMdast(el) {
752
397
  let value = "";
753
398
  for (let i = 0; i < el.length; i++) {
754
399
  const child = el.get(i);
755
- if (child instanceof Y3.XmlText) {
400
+ if (child instanceof Y.XmlText) {
756
401
  value += child.toString();
757
402
  }
758
403
  }
@@ -786,11 +431,11 @@ function deltaToPhrasingContent(el) {
786
431
  const result = [];
787
432
  for (let i = 0; i < el.length; i++) {
788
433
  const child = el.get(i);
789
- if (child instanceof Y3.XmlText) {
434
+ if (child instanceof Y.XmlText) {
790
435
  const delta = child.toDelta();
791
436
  for (const op of delta) {
792
437
  if (typeof op.insert !== "string") {
793
- if (op.insert instanceof Y3.XmlElement && op.insert.nodeName === "hardBreak") {
438
+ if (op.insert instanceof Y.XmlElement && op.insert.nodeName === "hardBreak") {
794
439
  result.push({ type: "break" });
795
440
  }
796
441
  continue;
@@ -838,7 +483,7 @@ function deltaToPhrasingContent(el) {
838
483
  }
839
484
  result.push(node);
840
485
  }
841
- } else if (child instanceof Y3.XmlElement) {
486
+ } else if (child instanceof Y.XmlElement) {
842
487
  if (child.nodeName === "hardBreak") {
843
488
  result.push({ type: "break" });
844
489
  }
@@ -855,10 +500,10 @@ var init_mdast_ydoc = __esm({
855
500
  });
856
501
 
857
502
  // src/server/file-io/markdown.ts
858
- import { unified } from "unified";
859
- import remarkParse from "remark-parse";
860
503
  import remarkGfm from "remark-gfm";
504
+ import remarkParse from "remark-parse";
861
505
  import remarkStringify from "remark-stringify";
506
+ import { unified } from "unified";
862
507
  function loadMarkdown(doc, markdown) {
863
508
  const tree = parser.parse(markdown);
864
509
  mdastToYDoc(doc, tree);
@@ -883,27 +528,11 @@ var init_markdown = __esm({
883
528
  }
884
529
  });
885
530
 
886
- // src/shared/offsets.ts
887
- function headingPrefixLength(level) {
888
- if (!level) return 0;
889
- return level + 1;
890
- }
891
- function headingPrefix(level) {
892
- return "#".repeat(level) + " ";
893
- }
894
- var FLAT_SEPARATOR;
895
- var init_offsets = __esm({
896
- "src/shared/offsets.ts"() {
897
- "use strict";
898
- FLAT_SEPARATOR = "\n";
899
- }
900
- });
901
-
902
531
  // src/server/mcp/document-model.ts
903
- import path3 from "path";
904
- import * as Y4 from "yjs";
532
+ import path from "path";
533
+ import * as Y2 from "yjs";
905
534
  function detectFormat(filePath) {
906
- const ext = path3.extname(filePath).toLowerCase();
535
+ const ext = path.extname(filePath).toLowerCase();
907
536
  switch (ext) {
908
537
  case ".md":
909
538
  return "md";
@@ -924,7 +553,7 @@ function docIdFromPath(filePath) {
924
553
  for (let i = 0; i < normalized.length; i++) {
925
554
  hash = (hash << 5) - hash + normalized.charCodeAt(i) | 0;
926
555
  }
927
- const name = path3.basename(normalized, path3.extname(normalized)).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 16);
556
+ const name = path.basename(normalized, path.extname(normalized)).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 16);
928
557
  return `${name}-${Math.abs(hash).toString(36).slice(0, 6)}`;
929
558
  }
930
559
  function populateYDoc(doc, text) {
@@ -936,27 +565,27 @@ function populateYDoc(doc, text) {
936
565
  const lines = text.split("\n");
937
566
  for (const line of lines) {
938
567
  if (line === "") {
939
- const empty = new Y4.XmlElement("paragraph");
940
- empty.insert(0, [new Y4.XmlText("")]);
568
+ const empty = new Y2.XmlElement("paragraph");
569
+ empty.insert(0, [new Y2.XmlText("")]);
941
570
  fragment.insert(fragment.length, [empty]);
942
571
  continue;
943
572
  }
944
573
  let element;
945
574
  if (line.startsWith("### ")) {
946
- element = new Y4.XmlElement("heading");
575
+ element = new Y2.XmlElement("heading");
947
576
  element.setAttribute("level", 3);
948
- element.insert(0, [new Y4.XmlText(line.slice(4))]);
577
+ element.insert(0, [new Y2.XmlText(line.slice(4))]);
949
578
  } else if (line.startsWith("## ")) {
950
- element = new Y4.XmlElement("heading");
579
+ element = new Y2.XmlElement("heading");
951
580
  element.setAttribute("level", 2);
952
- element.insert(0, [new Y4.XmlText(line.slice(3))]);
581
+ element.insert(0, [new Y2.XmlText(line.slice(3))]);
953
582
  } else if (line.startsWith("# ")) {
954
- element = new Y4.XmlElement("heading");
583
+ element = new Y2.XmlElement("heading");
955
584
  element.setAttribute("level", 1);
956
- element.insert(0, [new Y4.XmlText(line.slice(2))]);
585
+ element.insert(0, [new Y2.XmlText(line.slice(2))]);
957
586
  } else {
958
- element = new Y4.XmlElement("paragraph");
959
- element.insert(0, [new Y4.XmlText(line)]);
587
+ element = new Y2.XmlElement("paragraph");
588
+ element.insert(0, [new Y2.XmlText(line)]);
960
589
  }
961
590
  fragment.insert(fragment.length, [element]);
962
591
  }
@@ -965,9 +594,9 @@ function getElementText(element) {
965
594
  const parts = [];
966
595
  for (let i = 0; i < element.length; i++) {
967
596
  const child = element.get(i);
968
- if (child instanceof Y4.XmlText) {
597
+ if (child instanceof Y2.XmlText) {
969
598
  parts.push(child.toString());
970
- } else if (child instanceof Y4.XmlElement) {
599
+ } else if (child instanceof Y2.XmlElement) {
971
600
  parts.push(getElementText(child));
972
601
  }
973
602
  }
@@ -978,7 +607,7 @@ function extractText(doc) {
978
607
  const lines = [];
979
608
  for (let i = 0; i < fragment.length; i++) {
980
609
  const node = fragment.get(i);
981
- if (node instanceof Y4.XmlElement) {
610
+ if (node instanceof Y2.XmlElement) {
982
611
  const text = getElementText(node);
983
612
  if (node.nodeName === "heading") {
984
613
  const level = Number(node.getAttribute("level") ?? 1);
@@ -1003,7 +632,7 @@ function getHeadingPrefixLength(node) {
1003
632
  function findXmlText(element) {
1004
633
  for (let i = 0; i < element.length; i++) {
1005
634
  const child = element.get(i);
1006
- if (child instanceof Y4.XmlText) {
635
+ if (child instanceof Y2.XmlText) {
1007
636
  return child;
1008
637
  }
1009
638
  }
@@ -1011,7 +640,7 @@ function findXmlText(element) {
1011
640
  }
1012
641
  function getOrCreateXmlText(element) {
1013
642
  return findXmlText(element) ?? (() => {
1014
- const textNode = new Y4.XmlText("");
643
+ const textNode = new Y2.XmlText("");
1015
644
  element.insert(0, [textNode]);
1016
645
  return textNode;
1017
646
  })();
@@ -1026,7 +655,7 @@ var init_document_model = __esm({
1026
655
 
1027
656
  // src/server/file-io/docx-html.ts
1028
657
  import * as htmlparser2 from "htmlparser2";
1029
- import * as Y5 from "yjs";
658
+ import * as Y3 from "yjs";
1030
659
  function buildAttrs2(marks) {
1031
660
  const attrs = {};
1032
661
  for (const name of ALL_MARKS2) {
@@ -1063,8 +692,8 @@ function domNodeToYxml(node, deferred) {
1063
692
  if (isText(node)) {
1064
693
  const text = node.data;
1065
694
  if (!text.trim()) return [];
1066
- const el = new Y5.XmlElement("paragraph");
1067
- const xmlText = new Y5.XmlText();
695
+ const el = new Y3.XmlElement("paragraph");
696
+ const xmlText = new Y3.XmlText();
1068
697
  el.insert(0, [xmlText]);
1069
698
  deferred.push({ xmlText, children: [node], marks: {} });
1070
699
  return [el];
@@ -1073,23 +702,23 @@ function domNodeToYxml(node, deferred) {
1073
702
  const tag = node.tagName.toLowerCase();
1074
703
  const headingMatch = tag.match(/^h([1-6])$/);
1075
704
  if (headingMatch) {
1076
- const el = new Y5.XmlElement("heading");
705
+ const el = new Y3.XmlElement("heading");
1077
706
  el.setAttribute("level", parseInt(headingMatch[1]));
1078
- const xmlText = new Y5.XmlText();
707
+ const xmlText = new Y3.XmlText();
1079
708
  el.insert(0, [xmlText]);
1080
709
  deferred.push({ xmlText, children: node.children, marks: {} });
1081
710
  return [el];
1082
711
  }
1083
712
  switch (tag) {
1084
713
  case "p": {
1085
- const el = new Y5.XmlElement("paragraph");
1086
- const xmlText = new Y5.XmlText();
714
+ const el = new Y3.XmlElement("paragraph");
715
+ const xmlText = new Y3.XmlText();
1087
716
  el.insert(0, [xmlText]);
1088
717
  deferred.push({ xmlText, children: node.children, marks: {} });
1089
718
  return [el];
1090
719
  }
1091
720
  case "blockquote": {
1092
- const el = new Y5.XmlElement("blockquote");
721
+ const el = new Y3.XmlElement("blockquote");
1093
722
  const blockChildren = collectBlockChildren(node.children, deferred);
1094
723
  for (const child of blockChildren) {
1095
724
  el.insert(el.length, [child]);
@@ -1097,7 +726,7 @@ function domNodeToYxml(node, deferred) {
1097
726
  return [el];
1098
727
  }
1099
728
  case "ul": {
1100
- const el = new Y5.XmlElement("bulletList");
729
+ const el = new Y3.XmlElement("bulletList");
1101
730
  for (const child of node.children) {
1102
731
  if (isElement(child) && child.tagName.toLowerCase() === "li") {
1103
732
  el.insert(el.length, [buildListItem(child, deferred)]);
@@ -1106,7 +735,7 @@ function domNodeToYxml(node, deferred) {
1106
735
  return [el];
1107
736
  }
1108
737
  case "ol": {
1109
- const el = new Y5.XmlElement("orderedList");
738
+ const el = new Y3.XmlElement("orderedList");
1110
739
  const start = parseInt(node.attribs.start || "1");
1111
740
  if (start !== 1) {
1112
741
  el.setAttribute("start", start);
@@ -1119,7 +748,7 @@ function domNodeToYxml(node, deferred) {
1119
748
  return [el];
1120
749
  }
1121
750
  case "table": {
1122
- const el = new Y5.XmlElement("table");
751
+ const el = new Y3.XmlElement("table");
1123
752
  const rows = collectTableRows(node);
1124
753
  for (const row of rows) {
1125
754
  el.insert(el.length, [buildTableRow(row, deferred)]);
@@ -1127,25 +756,25 @@ function domNodeToYxml(node, deferred) {
1127
756
  return [el];
1128
757
  }
1129
758
  case "pre": {
1130
- const el = new Y5.XmlElement("codeBlock");
1131
- const xmlText = new Y5.XmlText();
759
+ const el = new Y3.XmlElement("codeBlock");
760
+ const xmlText = new Y3.XmlText();
1132
761
  el.insert(0, [xmlText]);
1133
762
  deferred.push({ xmlText, children: node.children, marks: {} });
1134
763
  return [el];
1135
764
  }
1136
765
  case "img": {
1137
- const el = new Y5.XmlElement("image");
766
+ const el = new Y3.XmlElement("image");
1138
767
  el.setAttribute("src", node.attribs.src || "");
1139
768
  if (node.attribs.alt) el.setAttribute("alt", node.attribs.alt);
1140
769
  if (node.attribs.title) el.setAttribute("title", node.attribs.title);
1141
770
  return [el];
1142
771
  }
1143
772
  case "hr": {
1144
- return [new Y5.XmlElement("horizontalRule")];
773
+ return [new Y3.XmlElement("horizontalRule")];
1145
774
  }
1146
775
  case "br": {
1147
- const el = new Y5.XmlElement("paragraph");
1148
- el.insert(0, [new Y5.XmlText("")]);
776
+ const el = new Y3.XmlElement("paragraph");
777
+ el.insert(0, [new Y3.XmlText("")]);
1149
778
  return [el];
1150
779
  }
1151
780
  case "div": {
@@ -1163,8 +792,8 @@ function domNodeToYxml(node, deferred) {
1163
792
  }
1164
793
  return results;
1165
794
  }
1166
- const el = new Y5.XmlElement("paragraph");
1167
- const xmlText = new Y5.XmlText();
795
+ const el = new Y3.XmlElement("paragraph");
796
+ const xmlText = new Y3.XmlText();
1168
797
  el.insert(0, [xmlText]);
1169
798
  deferred.push({ xmlText, children: node.children, marks: {} });
1170
799
  return [el];
@@ -1183,8 +812,8 @@ function collectBlockChildren(children, deferred) {
1183
812
  if (inlineBuffer.length === 0) return;
1184
813
  const hasContent = inlineBuffer.some((n) => isText(n) ? n.data.trim().length > 0 : true);
1185
814
  if (hasContent) {
1186
- const el = new Y5.XmlElement("paragraph");
1187
- const xmlText = new Y5.XmlText();
815
+ const el = new Y3.XmlElement("paragraph");
816
+ const xmlText = new Y3.XmlText();
1188
817
  el.insert(0, [xmlText]);
1189
818
  deferred.push({ xmlText, children: inlineBuffer, marks: {} });
1190
819
  result.push(el);
@@ -1201,14 +830,14 @@ function collectBlockChildren(children, deferred) {
1201
830
  }
1202
831
  flushInline();
1203
832
  if (result.length === 0) {
1204
- const el = new Y5.XmlElement("paragraph");
1205
- el.insert(0, [new Y5.XmlText("")]);
833
+ const el = new Y3.XmlElement("paragraph");
834
+ el.insert(0, [new Y3.XmlText("")]);
1206
835
  result.push(el);
1207
836
  }
1208
837
  return result;
1209
838
  }
1210
839
  function buildListItem(li, deferred) {
1211
- const listItem = new Y5.XmlElement("listItem");
840
+ const listItem = new Y3.XmlElement("listItem");
1212
841
  const blockChildren = collectBlockChildren(li.children, deferred);
1213
842
  for (const child of blockChildren) {
1214
843
  listItem.insert(listItem.length, [child]);
@@ -1233,13 +862,13 @@ function collectTableRows(table) {
1233
862
  return rows;
1234
863
  }
1235
864
  function buildTableRow(tr, deferred) {
1236
- const row = new Y5.XmlElement("tableRow");
865
+ const row = new Y3.XmlElement("tableRow");
1237
866
  for (const child of tr.children) {
1238
867
  if (!isElement(child)) continue;
1239
868
  const tag = child.tagName.toLowerCase();
1240
869
  if (tag === "td" || tag === "th") {
1241
870
  const nodeName = tag === "th" ? "tableHeader" : "tableCell";
1242
- const cell = new Y5.XmlElement(nodeName);
871
+ const cell = new Y3.XmlElement(nodeName);
1243
872
  if (child.attribs.colspan && child.attribs.colspan !== "1") {
1244
873
  cell.setAttribute("colspan", parseInt(child.attribs.colspan));
1245
874
  }
@@ -1267,7 +896,7 @@ function processInlineNodes(xmlText, nodes, marks) {
1267
896
  if (!isElement(node)) continue;
1268
897
  const tag = node.tagName.toLowerCase();
1269
898
  if (tag === "br") {
1270
- const embed = new Y5.XmlElement("hardBreak");
899
+ const embed = new Y3.XmlElement("hardBreak");
1271
900
  xmlText.insertEmbed(xmlText.length, embed);
1272
901
  continue;
1273
902
  }
@@ -1341,7 +970,7 @@ var init_docx_html = __esm({
1341
970
 
1342
971
  // src/server/file-io/docx.ts
1343
972
  import mammoth from "mammoth";
1344
- import * as Y6 from "yjs";
973
+ import * as Y4 from "yjs";
1345
974
  async function loadDocx(content) {
1346
975
  const result = await mammoth.convertToHtml({ buffer: content });
1347
976
  for (const msg of result.messages) {
@@ -1357,33 +986,35 @@ function exportAnnotations(doc, annotations) {
1357
986
  const fullText = extractFullText(fragment);
1358
987
  const groups = {};
1359
988
  for (const ann of annotations) {
1360
- const key = ann.type;
989
+ let key;
990
+ if (ann.type === "highlight") key = "highlights";
991
+ else if (ann.suggestedText !== void 0) key = "suggestions";
992
+ else if (ann.directedAt === "claude") key = "questions";
993
+ else if (ann.type === "flag") key = "flags";
994
+ else key = "comments";
1361
995
  if (!groups[key]) groups[key] = [];
1362
- groups[key].push(ann);
996
+ groups[key]?.push(ann);
1363
997
  }
1364
998
  const lines = ["# Document Review", ""];
1365
- const typeLabels = {
1366
- highlight: "Highlights",
1367
- comment: "Comments",
1368
- suggestion: "Suggestions",
1369
- overlay: "Overlays",
1370
- question: "Questions",
1371
- flag: "Flags"
999
+ const groupLabels = {
1000
+ highlights: "Highlights",
1001
+ comments: "Comments",
1002
+ suggestions: "Suggestions",
1003
+ questions: "Questions for Claude",
1004
+ flags: "Flags"
1372
1005
  };
1373
- for (const [type, anns] of Object.entries(groups)) {
1374
- lines.push(`## ${typeLabels[type] || type}`, "");
1006
+ const groupOrder = ["highlights", "comments", "suggestions", "questions", "flags"];
1007
+ for (const key of groupOrder) {
1008
+ const anns = groups[key];
1009
+ if (!anns) continue;
1010
+ lines.push(`## ${groupLabels[key]}`, "");
1375
1011
  for (const ann of anns) {
1376
1012
  const snippet = safeSlice(fullText, ann.range.from, ann.range.to);
1377
1013
  const truncated = snippet.length > 80 ? snippet.slice(0, 77) + "..." : snippet;
1378
1014
  lines.push(`- **"${truncated}"** (${ann.author})`);
1379
- if (ann.type === "suggestion") {
1380
- try {
1381
- const { newText, reason } = JSON.parse(ann.content);
1382
- lines.push(` - Replace with: "${newText}"`);
1383
- if (reason) lines.push(` - Reason: ${reason}`);
1384
- } catch {
1385
- lines.push(` - ${ann.content}`);
1386
- }
1015
+ if (ann.suggestedText !== void 0) {
1016
+ lines.push(` - Replace with: "${ann.suggestedText}"`);
1017
+ if (ann.content) lines.push(` - Reason: ${ann.content}`);
1387
1018
  } else if (ann.content) {
1388
1019
  lines.push(` - ${ann.content}`);
1389
1020
  }
@@ -1399,7 +1030,7 @@ function extractFullText(fragment) {
1399
1030
  const parts = [];
1400
1031
  for (let i = 0; i < fragment.length; i++) {
1401
1032
  const node = fragment.get(i);
1402
- if (node instanceof Y6.XmlElement) {
1033
+ if (node instanceof Y4.XmlElement) {
1403
1034
  parts.push(getElementText(node));
1404
1035
  }
1405
1036
  }
@@ -1418,13 +1049,33 @@ var init_docx = __esm({
1418
1049
  }
1419
1050
  });
1420
1051
 
1421
- // src/shared/positions/types.ts
1422
- var toFlatOffset, toSerializedRelPos;
1423
- var init_types = __esm({
1424
- "src/shared/positions/types.ts"() {
1052
+ // src/server/notifications.ts
1053
+ function pushNotification(notification) {
1054
+ buffer.push(notification);
1055
+ while (buffer.length > NOTIFICATION_BUFFER_SIZE) {
1056
+ buffer.shift();
1057
+ }
1058
+ for (const cb of subscribers) {
1059
+ try {
1060
+ cb(notification);
1061
+ } catch (err) {
1062
+ console.error("[Notifications] Subscriber threw during dispatch:", err);
1063
+ }
1064
+ }
1065
+ }
1066
+ function subscribe(cb) {
1067
+ subscribers.add(cb);
1068
+ return () => {
1069
+ subscribers.delete(cb);
1070
+ };
1071
+ }
1072
+ var buffer, subscribers;
1073
+ var init_notifications = __esm({
1074
+ "src/server/notifications.ts"() {
1425
1075
  "use strict";
1426
- toFlatOffset = (n) => n;
1427
- toSerializedRelPos = (json) => json;
1076
+ init_constants();
1077
+ buffer = [];
1078
+ subscribers = /* @__PURE__ */ new Set();
1428
1079
  }
1429
1080
  });
1430
1081
 
@@ -1437,12 +1088,12 @@ var init_positions = __esm({
1437
1088
  });
1438
1089
 
1439
1090
  // src/server/positions.ts
1440
- import * as Y7 from "yjs";
1091
+ import * as Y5 from "yjs";
1441
1092
  function resolveToElement(fragment, charOffset) {
1442
1093
  let accumulated = 0;
1443
1094
  for (let i = 0; i < fragment.length; i++) {
1444
1095
  const node = fragment.get(i);
1445
- if (!(node instanceof Y7.XmlElement)) continue;
1096
+ if (!(node instanceof Y5.XmlElement)) continue;
1446
1097
  const prefixLen = getHeadingPrefixLength(node);
1447
1098
  const text = getElementText(node);
1448
1099
  const fullLen = prefixLen + text.length;
@@ -1462,7 +1113,7 @@ function resolveToElement(fragment, charOffset) {
1462
1113
  }
1463
1114
  if (fragment.length > 0) {
1464
1115
  const lastNode = fragment.get(fragment.length - 1);
1465
- if (lastNode instanceof Y7.XmlElement) {
1116
+ if (lastNode instanceof Y5.XmlElement) {
1466
1117
  return {
1467
1118
  elementIndex: fragment.length - 1,
1468
1119
  textOffset: getElementText(lastNode).length,
@@ -1477,17 +1128,17 @@ function flatOffsetToRelPos(doc, offset, assoc) {
1477
1128
  const resolved = resolveToElement(fragment, offset);
1478
1129
  if (!resolved || resolved.clampedFromPrefix) return null;
1479
1130
  const node = fragment.get(resolved.elementIndex);
1480
- if (!(node instanceof Y7.XmlElement)) return null;
1131
+ if (!(node instanceof Y5.XmlElement)) return null;
1481
1132
  const xmlText = findXmlText(node);
1482
1133
  if (!xmlText) return null;
1483
- const rpos = Y7.createRelativePositionFromTypeIndex(xmlText, resolved.textOffset, assoc);
1484
- return toSerializedRelPos(Y7.relativePositionToJSON(rpos));
1134
+ const rpos = Y5.createRelativePositionFromTypeIndex(xmlText, resolved.textOffset, assoc);
1135
+ return toSerializedRelPos(Y5.relativePositionToJSON(rpos));
1485
1136
  }
1486
1137
  function relPosToFlatOffset(doc, relPosJson) {
1487
1138
  let absPos;
1488
1139
  try {
1489
- const rpos = Y7.createRelativePositionFromJSON(relPosJson);
1490
- absPos = Y7.createAbsolutePositionFromRelativePosition(rpos, doc);
1140
+ const rpos = Y5.createRelativePositionFromJSON(relPosJson);
1141
+ absPos = Y5.createAbsolutePositionFromRelativePosition(rpos, doc);
1491
1142
  } catch (err) {
1492
1143
  if (!(err instanceof TypeError) && !(err instanceof SyntaxError)) {
1493
1144
  console.error("[positions] relPosToFlatOffset: unexpected error resolving relRange:", err);
@@ -1499,7 +1150,7 @@ function relPosToFlatOffset(doc, relPosJson) {
1499
1150
  let accumulated = 0;
1500
1151
  for (let i = 0; i < fragment.length; i++) {
1501
1152
  const node = fragment.get(i);
1502
- if (!(node instanceof Y7.XmlElement)) continue;
1153
+ if (!(node instanceof Y5.XmlElement)) continue;
1503
1154
  const prefixLen = getHeadingPrefixLength(node);
1504
1155
  const text = getElementText(node);
1505
1156
  const xmlText = findXmlText(node);
@@ -1634,46 +1285,104 @@ function refreshAllRanges(annotations, ydoc, map) {
1634
1285
  var init_positions2 = __esm({
1635
1286
  "src/server/positions.ts"() {
1636
1287
  "use strict";
1637
- init_queue();
1638
1288
  init_positions();
1289
+ init_queue();
1639
1290
  init_document_model();
1640
1291
  }
1641
1292
  });
1642
1293
 
1643
- // src/shared/types.ts
1644
- import { z } from "zod";
1645
- var AnnotationTypeSchema, AnnotationStatusSchema, AnnotationPrioritySchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
1646
- var init_types2 = __esm({
1647
- "src/shared/types.ts"() {
1294
+ // src/server/yjs/provider.ts
1295
+ import { Hocuspocus } from "@hocuspocus/server";
1296
+ import * as Y6 from "yjs";
1297
+ function setDocLifecycleCallbacks(swapped, unloaded) {
1298
+ onDocSwapped = swapped;
1299
+ onDocUnloaded = unloaded;
1300
+ }
1301
+ function setShouldKeepDocument(fn) {
1302
+ shouldKeepDocument = fn;
1303
+ }
1304
+ function getDocument(name) {
1305
+ return documents.get(name);
1306
+ }
1307
+ function getOrCreateDocument(name) {
1308
+ let doc = documents.get(name);
1309
+ if (!doc) {
1310
+ doc = new Y6.Doc();
1311
+ documents.set(name, doc);
1312
+ }
1313
+ return doc;
1314
+ }
1315
+ async function startHocuspocus(port) {
1316
+ hocuspocusInstance = new Hocuspocus({
1317
+ port,
1318
+ address: "127.0.0.1",
1319
+ quiet: true,
1320
+ // stdout is the MCP wire — suppress the startup banner
1321
+ async onConnect({ request, documentName }) {
1322
+ const origin = request?.headers?.origin;
1323
+ if (!origin) {
1324
+ console.error("[Hocuspocus] Rejected connection: missing Origin header");
1325
+ throw new Error("Connection rejected: missing origin header");
1326
+ }
1327
+ const url = new URL(origin);
1328
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
1329
+ console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
1330
+ throw new Error("Connection rejected: invalid origin");
1331
+ }
1332
+ console.error(`[Hocuspocus] Client connected to: ${documentName}`);
1333
+ },
1334
+ async onDisconnect({ documentName }) {
1335
+ console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
1336
+ },
1337
+ async onLoadDocument({ document, documentName }) {
1338
+ console.error(`[Hocuspocus] Loading document: ${documentName}`);
1339
+ const existing = documents.get(documentName);
1340
+ if (existing && existing !== document) {
1341
+ const update = Y6.encodeStateAsUpdate(existing);
1342
+ Y6.applyUpdate(document, update);
1343
+ existing.destroy();
1344
+ console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
1345
+ }
1346
+ documents.set(documentName, document);
1347
+ if (onDocSwapped) {
1348
+ onDocSwapped(documentName, document);
1349
+ } else {
1350
+ console.error(
1351
+ `[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
1352
+ );
1353
+ }
1354
+ return document;
1355
+ },
1356
+ async afterUnloadDocument({ documentName }) {
1357
+ if (shouldKeepDocument?.(documentName)) {
1358
+ console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
1359
+ return;
1360
+ }
1361
+ if (documents.has(documentName)) {
1362
+ onDocUnloaded?.(documentName);
1363
+ documents.delete(documentName);
1364
+ console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
1365
+ }
1366
+ }
1367
+ });
1368
+ await hocuspocusInstance.listen();
1369
+ const internal = hocuspocusInstance.server?.httpServer;
1370
+ if (internal) {
1371
+ internal.on("error", (err) => {
1372
+ console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
1373
+ });
1374
+ }
1375
+ return hocuspocusInstance;
1376
+ }
1377
+ var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
1378
+ var init_provider = __esm({
1379
+ "src/server/yjs/provider.ts"() {
1648
1380
  "use strict";
1649
- init_types();
1650
- AnnotationTypeSchema = z.enum([
1651
- "highlight",
1652
- "comment",
1653
- "suggestion",
1654
- "overlay",
1655
- "question",
1656
- "flag"
1657
- ]);
1658
- AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
1659
- AnnotationPrioritySchema = z.enum(["normal", "urgent"]);
1660
- HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
1661
- SeveritySchema = z.enum(["info", "warning", "error", "success"]);
1662
- TandemModeSchema = z.enum(["solo", "tandem"]);
1663
- AuthorSchema = z.enum(["user", "claude", "import"]);
1664
- AnnotationActionSchema = z.enum(["accept", "dismiss"]);
1665
- ExportFormatSchema = z.enum(["markdown", "json"]);
1666
- DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
1667
- ToolErrorCodeSchema = z.enum([
1668
- "RANGE_GONE",
1669
- "RANGE_MOVED",
1670
- "FILE_LOCKED",
1671
- "FILE_NOT_FOUND",
1672
- "NO_DOCUMENT",
1673
- "INVALID_RANGE",
1674
- "FORMAT_ERROR",
1675
- "PERMISSION_DENIED"
1676
- ]);
1381
+ hocuspocusInstance = null;
1382
+ documents = /* @__PURE__ */ new Map();
1383
+ shouldKeepDocument = null;
1384
+ onDocSwapped = null;
1385
+ onDocUnloaded = null;
1677
1386
  }
1678
1387
  });
1679
1388
 
@@ -1807,8 +1516,8 @@ var init_docx_walker = __esm({
1807
1516
  });
1808
1517
 
1809
1518
  // src/server/file-io/docx-comments.ts
1810
- import JSZip from "jszip";
1811
1519
  import { parseDocument as parseDocument3 } from "htmlparser2";
1520
+ import JSZip from "jszip";
1812
1521
  async function extractDocxComments(buffer3) {
1813
1522
  const zip = await JSZip.loadAsync(buffer3);
1814
1523
  const commentsXml = await zip.file("word/comments.xml")?.async("text");
@@ -1898,11 +1607,9 @@ function injectCommentsAsAnnotations(doc, comments) {
1898
1607
  range: { from: result.range.from, to: result.range.to },
1899
1608
  content,
1900
1609
  status: "pending",
1901
- timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
1610
+ timestamp: comment.date ? new Date(comment.date).getTime() : Date.now(),
1611
+ ...result.fullyAnchored ? { relRange: result.relRange } : {}
1902
1612
  };
1903
- if (result.fullyAnchored) {
1904
- annotation.relRange = result.relRange;
1905
- }
1906
1613
  map.set(id, annotation);
1907
1614
  injected++;
1908
1615
  }
@@ -1916,9 +1623,9 @@ var init_docx_comments = __esm({
1916
1623
  "src/server/file-io/docx-comments.ts"() {
1917
1624
  "use strict";
1918
1625
  init_constants();
1919
- init_positions2();
1920
1626
  init_types2();
1921
1627
  init_queue();
1628
+ init_positions2();
1922
1629
  init_docx_walker();
1923
1630
  }
1924
1631
  });
@@ -2241,9 +1948,9 @@ var init_dist2 = __esm({
2241
1948
  });
2242
1949
 
2243
1950
  // src/server/file-io/docx-apply.ts
2244
- import JSZip2 from "jszip";
2245
- import { parseDocument as parseDocument4 } from "htmlparser2";
2246
1951
  import render from "dom-serializer";
1952
+ import { parseDocument as parseDocument4 } from "htmlparser2";
1953
+ import JSZip2 from "jszip";
2247
1954
  function buildOffsetMap(xml, targetOffsets) {
2248
1955
  const entries = /* @__PURE__ */ new Map();
2249
1956
  const commentParagraphIds = /* @__PURE__ */ new Map();
@@ -2774,23 +2481,23 @@ var init_docx_apply = __esm({
2774
2481
  });
2775
2482
 
2776
2483
  // src/server/file-io/index.ts
2777
- import fs3 from "fs/promises";
2778
- import path4 from "path";
2484
+ import fs from "fs/promises";
2485
+ import path2 from "path";
2779
2486
  function getAdapter(format) {
2780
2487
  return adapters[format] ?? plaintextAdapter;
2781
2488
  }
2782
- async function atomicWrite2(filePath, content) {
2783
- const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2784
- await fs3.writeFile(tempPath, content, "utf-8");
2785
- await fs3.rename(tempPath, filePath);
2489
+ async function atomicWrite(filePath, content) {
2490
+ const tempPath = path2.join(path2.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2491
+ await fs.writeFile(tempPath, content, "utf-8");
2492
+ await fs.rename(tempPath, filePath);
2786
2493
  }
2787
2494
  async function atomicWriteBuffer(filePath, content) {
2788
- const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2789
- await fs3.writeFile(tempPath, content);
2495
+ const tempPath = path2.join(path2.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2496
+ await fs.writeFile(tempPath, content);
2790
2497
  try {
2791
- await fs3.rename(tempPath, filePath);
2498
+ await fs.rename(tempPath, filePath);
2792
2499
  } catch (err) {
2793
- await fs3.unlink(tempPath).catch(() => {
2500
+ await fs.unlink(tempPath).catch(() => {
2794
2501
  });
2795
2502
  throw err;
2796
2503
  }
@@ -2799,10 +2506,10 @@ var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
2799
2506
  var init_file_io = __esm({
2800
2507
  "src/server/file-io/index.ts"() {
2801
2508
  "use strict";
2802
- init_markdown();
2509
+ init_document_model();
2803
2510
  init_docx();
2804
2511
  init_docx_comments();
2805
- init_document_model();
2512
+ init_markdown();
2806
2513
  init_docx_apply();
2807
2514
  markdownAdapter = {
2808
2515
  canSave: true,
@@ -2853,111 +2560,460 @@ var init_file_io = __esm({
2853
2560
  }
2854
2561
  });
2855
2562
 
2856
- // src/server/notifications.ts
2857
- function pushNotification(notification) {
2858
- buffer.push(notification);
2859
- while (buffer.length > NOTIFICATION_BUFFER_SIZE) {
2860
- buffer.shift();
2861
- }
2862
- for (const cb of subscribers) {
2863
- try {
2864
- cb(notification);
2865
- } catch (err) {
2866
- console.error("[Notifications] Subscriber threw during dispatch:", err);
2867
- }
2563
+ // src/server/file-watcher.ts
2564
+ import fs2 from "fs";
2565
+ function watchFile(filePath, onChanged) {
2566
+ if (watched.has(filePath)) return;
2567
+ let watcher;
2568
+ try {
2569
+ watcher = fs2.watch(filePath, (eventType) => {
2570
+ if (eventType !== "change") return;
2571
+ const entry = watched.get(filePath);
2572
+ if (!entry) return;
2573
+ if (entry.suppressed) {
2574
+ entry.suppressed = false;
2575
+ return;
2576
+ }
2577
+ if (entry.timer !== null) {
2578
+ clearTimeout(entry.timer);
2579
+ }
2580
+ entry.timer = setTimeout(() => {
2581
+ entry.timer = null;
2582
+ onChanged(filePath).catch((err) => {
2583
+ console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
2584
+ });
2585
+ }, 500);
2586
+ });
2587
+ } catch (err) {
2588
+ console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
2589
+ return;
2868
2590
  }
2591
+ watcher.on("error", (err) => {
2592
+ console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
2593
+ unwatchFile(filePath);
2594
+ });
2595
+ watched.set(filePath, { watcher, timer: null, suppressed: false });
2596
+ console.error(`[FileWatcher] Watching ${filePath}`);
2869
2597
  }
2870
- function subscribe(cb) {
2871
- subscribers.add(cb);
2872
- return () => {
2873
- subscribers.delete(cb);
2874
- };
2598
+ function suppressNextChange(filePath) {
2599
+ const entry = watched.get(filePath);
2600
+ if (entry) {
2601
+ entry.suppressed = true;
2602
+ }
2875
2603
  }
2876
- var buffer, subscribers;
2877
- var init_notifications = __esm({
2878
- "src/server/notifications.ts"() {
2604
+ function unwatchFile(filePath) {
2605
+ const entry = watched.get(filePath);
2606
+ if (!entry) return;
2607
+ if (entry.timer !== null) {
2608
+ clearTimeout(entry.timer);
2609
+ }
2610
+ try {
2611
+ entry.watcher.close();
2612
+ } catch (err) {
2613
+ console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
2614
+ }
2615
+ watched.delete(filePath);
2616
+ console.error(`[FileWatcher] Unwatched ${filePath}`);
2617
+ }
2618
+ function unwatchAll() {
2619
+ for (const filePath of [...watched.keys()]) {
2620
+ unwatchFile(filePath);
2621
+ }
2622
+ }
2623
+ var watched;
2624
+ var init_file_watcher = __esm({
2625
+ "src/server/file-watcher.ts"() {
2879
2626
  "use strict";
2880
- init_constants();
2881
- buffer = [];
2882
- subscribers = /* @__PURE__ */ new Set();
2627
+ watched = /* @__PURE__ */ new Map();
2883
2628
  }
2884
2629
  });
2885
2630
 
2886
- // src/shared/utils.ts
2887
- function generateId(prefix) {
2888
- return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2631
+ // src/server/platform.ts
2632
+ import { execSync } from "child_process";
2633
+ import envPaths from "env-paths";
2634
+ import net from "net";
2635
+ import path3 from "path";
2636
+ function freePort(port) {
2637
+ try {
2638
+ if (process.platform === "win32") {
2639
+ freePortWindows(port);
2640
+ } else {
2641
+ freePortUnix(port);
2642
+ }
2643
+ } catch (err) {
2644
+ console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
2645
+ }
2889
2646
  }
2890
- function generateAnnotationId() {
2891
- return generateId("ann");
2647
+ async function waitForPort(port, timeoutMs = 5e3) {
2648
+ const start = Date.now();
2649
+ while (Date.now() - start < timeoutMs) {
2650
+ if (await tryBind(port)) return;
2651
+ await new Promise((r) => setTimeout(r, 200));
2652
+ }
2653
+ throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
2892
2654
  }
2893
- function generateMessageId() {
2894
- return generateId("msg");
2655
+ function tryBind(port) {
2656
+ return new Promise((resolve, reject) => {
2657
+ const srv = net.createServer();
2658
+ srv.once("error", (err) => {
2659
+ srv.close(() => {
2660
+ if (err.code === "EADDRINUSE") {
2661
+ resolve(false);
2662
+ } else {
2663
+ reject(err);
2664
+ }
2665
+ });
2666
+ });
2667
+ srv.listen(port, "127.0.0.1", () => {
2668
+ srv.close(() => resolve(true));
2669
+ });
2670
+ });
2895
2671
  }
2896
- function generateEventId() {
2897
- return generateId("evt");
2672
+ function parseLsofPids(output) {
2673
+ return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
2898
2674
  }
2899
- function generateNotificationId() {
2900
- return generateId("ntf");
2675
+ function parseSsPid(output) {
2676
+ const match = output.match(/pid=(\d+)/);
2677
+ return match ? parseInt(match[1], 10) : null;
2901
2678
  }
2902
- var init_utils = __esm({
2903
- "src/shared/utils.ts"() {
2679
+ function freePortWindows(port) {
2680
+ const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
2681
+ encoding: "utf-8",
2682
+ stdio: ["pipe", "pipe", "ignore"]
2683
+ });
2684
+ const pid = out.trim().split(/\s+/).at(-1);
2685
+ if (pid && /^\d+$/.test(pid)) {
2686
+ execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
2687
+ console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
2688
+ }
2689
+ }
2690
+ function freePortUnix(port) {
2691
+ let pids = [];
2692
+ try {
2693
+ const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
2694
+ encoding: "utf-8",
2695
+ stdio: ["pipe", "pipe", "ignore"]
2696
+ });
2697
+ pids = parseLsofPids(out);
2698
+ } catch {
2699
+ try {
2700
+ const out = execSync(`ss -tlnp sport = :${port}`, {
2701
+ encoding: "utf-8",
2702
+ stdio: ["pipe", "pipe", "ignore"]
2703
+ });
2704
+ const pid = parseSsPid(out);
2705
+ if (pid) pids = [pid];
2706
+ } catch {
2707
+ }
2708
+ }
2709
+ for (const pid of pids) {
2710
+ try {
2711
+ process.kill(pid, "SIGKILL");
2712
+ console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
2713
+ } catch {
2714
+ }
2715
+ }
2716
+ }
2717
+ var paths, SESSION_DIR, LAST_SEEN_VERSION_FILE;
2718
+ var init_platform = __esm({
2719
+ "src/server/platform.ts"() {
2904
2720
  "use strict";
2721
+ paths = envPaths("tandem", { suffix: "" });
2722
+ SESSION_DIR = path3.join(paths.data, "sessions");
2723
+ LAST_SEEN_VERSION_FILE = path3.join(paths.data, "last-seen-version");
2905
2724
  }
2906
2725
  });
2907
2726
 
2908
- // src/server/mcp/file-opener.ts
2909
- var file_opener_exports = {};
2910
- __export(file_opener_exports, {
2911
- SUPPORTED_EXTENSIONS: () => SUPPORTED_EXTENSIONS,
2912
- openFileByPath: () => openFileByPath,
2913
- openFileFromContent: () => openFileFromContent
2914
- });
2915
- import fs4 from "fs/promises";
2916
- import fsSync from "fs";
2917
- import path5 from "path";
2918
- import { randomUUID } from "crypto";
2919
- async function openFileByPath(filePath, options) {
2920
- let resolved = path5.resolve(filePath);
2727
+ // src/server/session/manager.ts
2728
+ import fs3 from "fs/promises";
2729
+ import path4 from "path";
2730
+ import * as Y7 from "yjs";
2731
+ async function atomicWrite2(sessionPath, content) {
2732
+ const tmpPath = `${sessionPath}.tmp`;
2733
+ await fs3.writeFile(tmpPath, content, "utf-8");
2734
+ for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
2735
+ try {
2736
+ await fs3.rename(tmpPath, sessionPath);
2737
+ return;
2738
+ } catch (err) {
2739
+ const code = err.code;
2740
+ if ((code === "EPERM" || code === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
2741
+ await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
2742
+ continue;
2743
+ }
2744
+ await fs3.unlink(tmpPath).catch(() => {
2745
+ });
2746
+ throw err;
2747
+ }
2748
+ }
2749
+ }
2750
+ function sessionKey(filePath) {
2751
+ return encodeURIComponent(filePath.replace(/\\/g, "/"));
2752
+ }
2753
+ async function saveSession(filePath, format, doc) {
2754
+ const key = sessionKey(filePath);
2755
+ let sourceFileMtime = 0;
2756
+ if (!filePath.startsWith("upload://")) {
2757
+ try {
2758
+ const stat = await fs3.stat(filePath);
2759
+ sourceFileMtime = stat.mtimeMs;
2760
+ } catch {
2761
+ }
2762
+ }
2763
+ const state = Y7.encodeStateAsUpdate(doc);
2764
+ const ydocState = Buffer.from(state).toString("base64");
2765
+ const data = {
2766
+ filePath,
2767
+ format,
2768
+ ydocState,
2769
+ sourceFileMtime,
2770
+ lastAccessed: Date.now()
2771
+ };
2772
+ if (!sessionDirReady) {
2773
+ await fs3.mkdir(SESSION_DIR, { recursive: true });
2774
+ sessionDirReady = true;
2775
+ }
2776
+ const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
2777
+ await atomicWrite2(sessionPath, JSON.stringify(data));
2778
+ }
2779
+ async function loadSession(filePath) {
2780
+ const key = sessionKey(filePath);
2781
+ const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
2921
2782
  try {
2922
- resolved = fsSync.realpathSync(resolved);
2783
+ const content = await fs3.readFile(sessionPath, "utf-8");
2784
+ return JSON.parse(content);
2923
2785
  } catch (err) {
2924
2786
  const code = err.code;
2925
- if (code !== "ENOENT") {
2926
- console.error(
2927
- `[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
2928
- );
2787
+ if (code === "ENOENT") return null;
2788
+ if (err instanceof SyntaxError) {
2789
+ console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
2790
+ await fs3.unlink(sessionPath).catch((unlinkErr) => {
2791
+ console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
2792
+ });
2793
+ return null;
2929
2794
  }
2930
- resolved = path5.resolve(filePath);
2795
+ console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
2796
+ return null;
2931
2797
  }
2932
- if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
2933
- throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
2934
- code: "INVALID_PATH"
2935
- });
2798
+ }
2799
+ function restoreYDoc(doc, session) {
2800
+ const state = Buffer.from(session.ydocState, "base64");
2801
+ Y7.applyUpdate(doc, new Uint8Array(state));
2802
+ }
2803
+ async function sourceFileChanged(session) {
2804
+ if (session.filePath.startsWith("upload://")) return false;
2805
+ try {
2806
+ const stat = await fs3.stat(session.filePath);
2807
+ return stat.mtimeMs !== session.sourceFileMtime;
2808
+ } catch {
2809
+ return true;
2936
2810
  }
2937
- const ext = path5.extname(resolved).toLowerCase();
2938
- if (!SUPPORTED_EXTENSIONS.has(ext)) {
2939
- throw Object.assign(
2940
- new Error(
2941
- `Unsupported file format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`
2942
- ),
2943
- { code: "UNSUPPORTED_FORMAT" }
2944
- );
2811
+ }
2812
+ async function deleteSession(filePath) {
2813
+ const key = sessionKey(filePath);
2814
+ const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
2815
+ try {
2816
+ await fs3.unlink(sessionPath);
2817
+ } catch (err) {
2818
+ const code = err.code;
2819
+ if (code !== "ENOENT") {
2820
+ console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
2821
+ }
2945
2822
  }
2946
- const stat = await fs4.stat(resolved);
2947
- if (stat.size > MAX_FILE_SIZE) {
2948
- throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
2823
+ }
2824
+ async function saveCtrlSession(doc) {
2825
+ if (!sessionDirReady) {
2826
+ await fs3.mkdir(SESSION_DIR, { recursive: true });
2827
+ sessionDirReady = true;
2949
2828
  }
2950
- const format = detectFormat(resolved);
2951
- const isDocx = format === "docx";
2952
- const readOnly = isDocx;
2953
- const id = docIdFromPath(resolved);
2954
- const openDocs2 = getOpenDocs();
2955
- const existing = openDocs2.get(id);
2956
- const forceReload = existing && options?.force === true;
2957
- if (existing && !forceReload) {
2958
- setActiveDocId(id);
2959
- broadcastOpenDocs();
2960
- const doc2 = getOrCreateDocument(id);
2829
+ const chatMap = doc.getMap(Y_MAP_CHAT);
2830
+ const entries = [];
2831
+ chatMap.forEach((value, key) => {
2832
+ const msg = value;
2833
+ entries.push({ id: key, timestamp: msg.timestamp });
2834
+ });
2835
+ if (entries.length > 200) {
2836
+ entries.sort((a, b) => a.timestamp - b.timestamp);
2837
+ const toDelete = entries.slice(0, entries.length - 200);
2838
+ doc.transact(() => {
2839
+ for (const entry of toDelete) {
2840
+ chatMap.delete(entry.id);
2841
+ }
2842
+ }, MCP_ORIGIN);
2843
+ }
2844
+ const state = Y7.encodeStateAsUpdate(doc);
2845
+ const ydocState = Buffer.from(state).toString("base64");
2846
+ const data = { ydocState, lastAccessed: Date.now() };
2847
+ const sessionPath = path4.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
2848
+ await atomicWrite2(sessionPath, JSON.stringify(data));
2849
+ }
2850
+ async function loadCtrlSession() {
2851
+ const sessionPath = path4.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
2852
+ try {
2853
+ const content = await fs3.readFile(sessionPath, "utf-8");
2854
+ const data = JSON.parse(content);
2855
+ return data.ydocState ?? null;
2856
+ } catch (err) {
2857
+ const code = err.code;
2858
+ if (code === "ENOENT") return null;
2859
+ if (err instanceof SyntaxError) {
2860
+ console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
2861
+ await fs3.unlink(sessionPath).catch((unlinkErr) => {
2862
+ console.error(
2863
+ `[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
2864
+ unlinkErr
2865
+ );
2866
+ });
2867
+ return null;
2868
+ }
2869
+ console.error(`[Tandem] Failed to read ctrl session:`, err);
2870
+ return null;
2871
+ }
2872
+ }
2873
+ function restoreCtrlDoc(doc, base64State) {
2874
+ const state = Buffer.from(base64State, "base64");
2875
+ Y7.applyUpdate(doc, new Uint8Array(state));
2876
+ }
2877
+ async function listSessionFilePaths() {
2878
+ try {
2879
+ await fs3.mkdir(SESSION_DIR, { recursive: true });
2880
+ const files = await fs3.readdir(SESSION_DIR);
2881
+ const results = [];
2882
+ for (const file of files) {
2883
+ if (!file.endsWith(".json")) continue;
2884
+ if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
2885
+ try {
2886
+ const raw = await fs3.readFile(path4.join(SESSION_DIR, file), "utf-8");
2887
+ const data = JSON.parse(raw);
2888
+ if (!data.filePath || data.filePath.startsWith("upload://")) continue;
2889
+ results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
2890
+ } catch (err) {
2891
+ console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
2892
+ }
2893
+ }
2894
+ results.sort((a, b) => b.lastAccessed - a.lastAccessed);
2895
+ return results;
2896
+ } catch (err) {
2897
+ console.error("[Tandem] Failed to read session directory:", err);
2898
+ return [];
2899
+ }
2900
+ }
2901
+ async function cleanupSessions() {
2902
+ let cleaned = 0;
2903
+ let files;
2904
+ try {
2905
+ files = await fs3.readdir(SESSION_DIR);
2906
+ } catch (err) {
2907
+ if (err.code === "ENOENT") return 0;
2908
+ console.error("[Tandem] Failed to read session directory:", err);
2909
+ return 0;
2910
+ }
2911
+ const now = Date.now();
2912
+ for (const file of files) {
2913
+ try {
2914
+ const filePath = path4.join(SESSION_DIR, file);
2915
+ const stat = await fs3.stat(filePath);
2916
+ if (now - stat.mtimeMs > SESSION_MAX_AGE) {
2917
+ await fs3.unlink(filePath);
2918
+ cleaned++;
2919
+ }
2920
+ } catch (err) {
2921
+ console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
2922
+ }
2923
+ }
2924
+ return cleaned;
2925
+ }
2926
+ function isAutoSaveRunning() {
2927
+ return autoSaveTimer !== null;
2928
+ }
2929
+ function startAutoSave(callback) {
2930
+ stopAutoSave();
2931
+ autoSaveCallback = callback;
2932
+ autoSaveTimer = setInterval(async () => {
2933
+ try {
2934
+ await autoSaveCallback?.();
2935
+ } catch (err) {
2936
+ console.error("[Tandem] Auto-save failed:", err);
2937
+ }
2938
+ }, AUTO_SAVE_INTERVAL);
2939
+ }
2940
+ function stopAutoSave() {
2941
+ if (autoSaveTimer) {
2942
+ clearInterval(autoSaveTimer);
2943
+ autoSaveTimer = null;
2944
+ }
2945
+ autoSaveCallback = null;
2946
+ }
2947
+ var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
2948
+ var init_manager = __esm({
2949
+ "src/server/session/manager.ts"() {
2950
+ "use strict";
2951
+ init_constants();
2952
+ init_queue();
2953
+ init_platform();
2954
+ AUTO_SAVE_INTERVAL = 60 * 1e3;
2955
+ RENAME_MAX_RETRIES = 3;
2956
+ RENAME_RETRY_BASE_MS = 50;
2957
+ sessionDirReady = false;
2958
+ CTRL_SESSION_KEY = CTRL_ROOM;
2959
+ autoSaveTimer = null;
2960
+ autoSaveCallback = null;
2961
+ }
2962
+ });
2963
+
2964
+ // src/server/mcp/file-opener.ts
2965
+ var file_opener_exports = {};
2966
+ __export(file_opener_exports, {
2967
+ SUPPORTED_EXTENSIONS: () => SUPPORTED_EXTENSIONS,
2968
+ openFileByPath: () => openFileByPath,
2969
+ openFileFromContent: () => openFileFromContent
2970
+ });
2971
+ import { randomUUID } from "crypto";
2972
+ import fsSync from "fs";
2973
+ import fs4 from "fs/promises";
2974
+ import path5 from "path";
2975
+ async function openFileByPath(filePath, options) {
2976
+ let resolved = path5.resolve(filePath);
2977
+ try {
2978
+ resolved = fsSync.realpathSync(resolved);
2979
+ } catch (err) {
2980
+ const code = err.code;
2981
+ if (code !== "ENOENT") {
2982
+ console.error(
2983
+ `[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
2984
+ );
2985
+ }
2986
+ resolved = path5.resolve(filePath);
2987
+ }
2988
+ if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
2989
+ throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
2990
+ code: "INVALID_PATH"
2991
+ });
2992
+ }
2993
+ const ext = path5.extname(resolved).toLowerCase();
2994
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
2995
+ throw Object.assign(
2996
+ new Error(
2997
+ `Unsupported file format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`
2998
+ ),
2999
+ { code: "UNSUPPORTED_FORMAT" }
3000
+ );
3001
+ }
3002
+ const stat = await fs4.stat(resolved);
3003
+ if (stat.size > MAX_FILE_SIZE) {
3004
+ throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
3005
+ }
3006
+ const format = detectFormat(resolved);
3007
+ const isDocx = format === "docx";
3008
+ const readOnly = isDocx;
3009
+ const id = docIdFromPath(resolved);
3010
+ const openDocs2 = getOpenDocs();
3011
+ const existing = openDocs2.get(id);
3012
+ const forceReload = existing && options?.force === true;
3013
+ if (existing && !forceReload) {
3014
+ setActiveDocId(id);
3015
+ broadcastOpenDocs();
3016
+ const doc2 = getOrCreateDocument(id);
2961
3017
  return {
2962
3018
  ...buildResult(doc2, {
2963
3019
  documentId: id,
@@ -3190,7 +3246,7 @@ async function reloadFromDisk(id, filePath, format) {
3190
3246
  }, MCP_ORIGIN);
3191
3247
  const annotationMap = doc.getMap(Y_MAP_ANNOTATIONS);
3192
3248
  const annotations = [];
3193
- annotationMap.forEach((val) => annotations.push(val));
3249
+ annotationMap.forEach((val) => annotations.push(sanitizeAnnotation(val)));
3194
3250
  if (annotations.length > 0) {
3195
3251
  const refreshed = refreshAllRanges(annotations, doc, annotationMap);
3196
3252
  doc.transact(() => {
@@ -3264,19 +3320,20 @@ var reloadInProgress;
3264
3320
  var init_file_opener = __esm({
3265
3321
  "src/server/mcp/file-opener.ts"() {
3266
3322
  "use strict";
3267
- init_provider();
3268
3323
  init_constants();
3324
+ init_utils();
3269
3325
  init_queue();
3326
+ init_docx();
3327
+ init_docx_comments();
3328
+ init_docx_html();
3270
3329
  init_file_io();
3330
+ init_markdown();
3271
3331
  init_file_watcher();
3272
- init_positions2();
3273
3332
  init_notifications();
3274
- init_utils();
3275
- init_markdown();
3276
- init_docx();
3277
- init_docx_html();
3278
- init_docx_comments();
3333
+ init_positions2();
3279
3334
  init_manager();
3335
+ init_provider();
3336
+ init_annotations();
3280
3337
  init_document_model();
3281
3338
  init_document_service();
3282
3339
  reloadInProgress = /* @__PURE__ */ new Set();
@@ -3284,8 +3341,8 @@ var init_file_opener = __esm({
3284
3341
  });
3285
3342
 
3286
3343
  // src/server/mcp/document-service.ts
3287
- import path6 from "path";
3288
3344
  import { randomUUID as randomUUID2 } from "crypto";
3345
+ import path6 from "path";
3289
3346
  function getOpenDocs() {
3290
3347
  return openDocs;
3291
3348
  }
@@ -3445,616 +3502,204 @@ var openDocs, activeDocId;
3445
3502
  var init_document_service = __esm({
3446
3503
  "src/server/mcp/document-service.ts"() {
3447
3504
  "use strict";
3448
- init_provider();
3449
- init_manager();
3450
3505
  init_constants();
3451
3506
  init_queue();
3452
3507
  init_file_watcher();
3508
+ init_manager();
3509
+ init_provider();
3453
3510
  openDocs = /* @__PURE__ */ new Map();
3454
3511
  setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
3455
3512
  activeDocId = null;
3456
3513
  }
3457
3514
  });
3458
3515
 
3459
- // src/server/events/types.ts
3460
- var init_types3 = __esm({
3461
- "src/server/events/types.ts"() {
3462
- "use strict";
3463
- init_utils();
3464
- }
3465
- });
3466
-
3467
- // src/server/events/queue.ts
3468
- function getTrackableId(event) {
3469
- switch (event.type) {
3470
- case "annotation:created":
3471
- case "annotation:accepted":
3472
- case "annotation:dismissed":
3473
- return event.payload.annotationId;
3474
- case "chat:message":
3475
- return event.payload.messageId;
3476
- default:
3477
- return void 0;
3478
- }
3479
- }
3480
- function trackPayloadId(event) {
3481
- const id = getTrackableId(event);
3482
- if (id) emittedPayloadIds.set(id, (emittedPayloadIds.get(id) ?? 0) + 1);
3483
- }
3484
- function untrackPayloadId(event) {
3485
- const id = getTrackableId(event);
3486
- if (!id) return;
3487
- const count = emittedPayloadIds.get(id) ?? 0;
3488
- if (count <= 1) emittedPayloadIds.delete(id);
3489
- else emittedPayloadIds.set(id, count - 1);
3490
- }
3491
- function pushEvent(event) {
3492
- buffer2.push(event);
3493
- trackPayloadId(event);
3494
- while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
3495
- const evicted = buffer2.shift();
3496
- if (evicted) untrackPayloadId(evicted);
3497
- }
3498
- const now = Date.now();
3499
- while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
3500
- const evicted = buffer2.shift();
3501
- if (evicted) untrackPayloadId(evicted);
3502
- }
3503
- for (const cb of subscribers2) {
3516
+ // src/server/mcp/convert.ts
3517
+ import fs5 from "fs/promises";
3518
+ import path7 from "path";
3519
+ async function findAvailablePath(basePath) {
3520
+ const dir = path7.dirname(basePath);
3521
+ const ext = path7.extname(basePath);
3522
+ const name = path7.basename(basePath, ext);
3523
+ const MAX_ATTEMPTS = 1e3;
3524
+ let candidate = basePath;
3525
+ let counter = 0;
3526
+ while (counter <= MAX_ATTEMPTS) {
3504
3527
  try {
3505
- cb(event);
3528
+ await fs5.access(candidate);
3529
+ counter++;
3530
+ candidate = path7.join(dir, `${name}-${counter}${ext}`);
3506
3531
  } catch (err) {
3507
- console.error("[EventQueue] Subscriber threw during event dispatch:", err);
3508
- }
3532
+ const code = err.code;
3533
+ if (code === "ENOENT") return candidate;
3534
+ throw err;
3535
+ }
3509
3536
  }
3537
+ throw Object.assign(new Error("Could not find an available filename after 1000 attempts."), {
3538
+ code: "CONFLICT"
3539
+ });
3510
3540
  }
3511
- function subscribe2(cb) {
3512
- subscribers2.add(cb);
3513
- }
3514
- function unsubscribe(cb) {
3515
- subscribers2.delete(cb);
3516
- }
3517
- function replaySince(lastEventId) {
3518
- const idx = buffer2.findIndex((e) => e.id === lastEventId);
3519
- if (idx === -1) return [...buffer2];
3520
- return buffer2.slice(idx + 1);
3521
- }
3522
- function attachObservers(docName, doc) {
3523
- detachObservers(docName);
3524
- const cleanups = [];
3525
- const annotationsMap = doc.getMap(Y_MAP_ANNOTATIONS);
3526
- const annotationsObs = (event, txn) => {
3527
- if (txn.origin === MCP_ORIGIN) return;
3528
- for (const [key, change] of event.changes.keys) {
3529
- const ann = annotationsMap.get(key);
3530
- if (!ann) continue;
3531
- if (change.action === "add" && ann.author === "user") {
3532
- pushEvent({
3533
- id: generateEventId(),
3534
- type: "annotation:created",
3535
- timestamp: Date.now(),
3536
- documentId: docName,
3537
- payload: {
3538
- annotationId: ann.id,
3539
- annotationType: ann.type,
3540
- content: ann.content,
3541
- textSnippet: ann.textSnapshot ?? ""
3542
- }
3543
- });
3544
- } else if (change.action === "update" && ann.author === "claude") {
3545
- if (ann.status === "accepted") {
3546
- pushEvent({
3547
- id: generateEventId(),
3548
- type: "annotation:accepted",
3549
- timestamp: Date.now(),
3550
- documentId: docName,
3551
- payload: {
3552
- annotationId: ann.id,
3553
- textSnippet: ann.textSnapshot ?? ""
3554
- }
3555
- });
3556
- } else if (ann.status === "dismissed") {
3557
- pushEvent({
3558
- id: generateEventId(),
3559
- type: "annotation:dismissed",
3560
- timestamp: Date.now(),
3561
- documentId: docName,
3562
- payload: {
3563
- annotationId: ann.id,
3564
- textSnippet: ann.textSnapshot ?? ""
3565
- }
3566
- });
3567
- }
3541
+ async function convertToMarkdown(documentId, outputPath) {
3542
+ const docState = getCurrentDoc(documentId);
3543
+ if (!docState) {
3544
+ throw Object.assign(new Error("Document not found."), { code: "FILE_NOT_FOUND" });
3545
+ }
3546
+ if (docState.format !== "docx") {
3547
+ throw Object.assign(new Error("Only .docx documents can be converted to Markdown."), {
3548
+ code: "UNSUPPORTED_FORMAT"
3549
+ });
3550
+ }
3551
+ if (docState.source === "upload") {
3552
+ throw Object.assign(
3553
+ new Error(
3554
+ "Uploaded .docx files cannot be converted \u2014 no disk location to write the .md file."
3555
+ ),
3556
+ { code: "INVALID_PATH" }
3557
+ );
3558
+ }
3559
+ const doc = getOrCreateDocument(docState.id);
3560
+ const markdown = extractMarkdown(doc);
3561
+ if (!markdown.trim()) {
3562
+ throw Object.assign(
3563
+ new Error("Conversion produced empty output \u2014 the .docx may not contain extractable text."),
3564
+ { code: "EMPTY_CONVERSION" }
3565
+ );
3566
+ }
3567
+ const sourceDir = path7.dirname(docState.filePath);
3568
+ let resolvedOutput;
3569
+ if (outputPath) {
3570
+ resolvedOutput = path7.resolve(outputPath);
3571
+ if (resolvedOutput.startsWith("\\\\") || resolvedOutput.startsWith("//")) {
3572
+ throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
3573
+ code: "INVALID_PATH"
3574
+ });
3575
+ }
3576
+ try {
3577
+ const stat = await fs5.stat(resolvedOutput);
3578
+ if (stat.isDirectory()) {
3579
+ const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
3580
+ resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
3568
3581
  }
3582
+ } catch (err) {
3583
+ const code = err.code;
3584
+ if (code !== "ENOENT") throw err;
3569
3585
  }
3586
+ } else {
3587
+ const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
3588
+ resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
3589
+ }
3590
+ resolvedOutput = await findAvailablePath(resolvedOutput);
3591
+ await atomicWrite(resolvedOutput, markdown);
3592
+ try {
3593
+ const openResult = await openFileByPath(resolvedOutput);
3594
+ return {
3595
+ outputPath: resolvedOutput,
3596
+ documentId: openResult.documentId,
3597
+ fileName: openResult.fileName
3598
+ };
3599
+ } catch (err) {
3600
+ throw Object.assign(
3601
+ new Error(
3602
+ `Markdown written to ${resolvedOutput} but failed to open: ${err.message}`
3603
+ ),
3604
+ { code: "OPEN_FAILED" }
3605
+ );
3606
+ }
3607
+ }
3608
+ var init_convert = __esm({
3609
+ "src/server/mcp/convert.ts"() {
3610
+ "use strict";
3611
+ init_file_io();
3612
+ init_provider();
3613
+ init_document_model();
3614
+ init_document_service();
3615
+ init_file_opener();
3616
+ }
3617
+ });
3618
+
3619
+ // src/server/mcp/response.ts
3620
+ function mcpSuccess(data) {
3621
+ return {
3622
+ content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
3570
3623
  };
3571
- annotationsMap.observe(annotationsObs);
3572
- cleanups.push(() => annotationsMap.unobserve(annotationsObs));
3573
- const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
3574
- let selectionDwellTimer = null;
3575
- const awarenessObs = (event, txn) => {
3576
- if (txn.origin === MCP_ORIGIN) return;
3577
- if (event.keysChanged.has("selection")) {
3578
- const selection = userAwareness.get("selection");
3579
- if (selectionDwellTimer) {
3580
- clearTimeout(selectionDwellTimer);
3581
- selectionDwellTimer = null;
3624
+ }
3625
+ function mcpError(code, message, details) {
3626
+ return {
3627
+ content: [
3628
+ {
3629
+ type: "text",
3630
+ text: JSON.stringify({ error: true, code, message, ...details && { details } })
3582
3631
  }
3583
- if (!selection || selection.from === selection.to) return;
3584
- selectionDwellTimer = setTimeout(() => {
3585
- selectionDwellTimer = null;
3586
- pushEvent({
3587
- id: generateEventId(),
3588
- type: "selection:changed",
3589
- timestamp: Date.now(),
3590
- documentId: docName,
3591
- payload: {
3592
- from: selection.from,
3593
- to: selection.to,
3594
- selectedText: selection.selectedText ?? ""
3595
- }
3596
- });
3597
- }, SELECTION_DWELL_DEFAULT_MS);
3598
- }
3632
+ ]
3599
3633
  };
3600
- userAwareness.observe(awarenessObs);
3601
- cleanups.push(() => {
3602
- userAwareness.unobserve(awarenessObs);
3603
- if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
3604
- });
3605
- docObservers.set(docName, cleanups);
3606
- console.error(`[EventQueue] Attached observers for document: ${docName}`);
3607
3634
  }
3608
- function detachObservers(docName) {
3609
- const cleanups = docObservers.get(docName);
3610
- if (cleanups) {
3611
- for (const cleanup of cleanups) cleanup();
3612
- docObservers.delete(docName);
3613
- console.error(`[EventQueue] Detached observers for document: ${docName}`);
3614
- }
3635
+ function noDocumentError() {
3636
+ return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
3615
3637
  }
3616
- function reattachObservers(docName, newDoc) {
3617
- attachObservers(docName, newDoc);
3638
+ function getErrorMessage(err) {
3639
+ return err instanceof Error ? err.message : String(err);
3618
3640
  }
3619
- function attachCtrlObservers() {
3620
- for (const cleanup of ctrlCleanups) cleanup();
3621
- ctrlCleanups = [];
3622
- const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
3623
- const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
3624
- const chatObs = (event, txn) => {
3625
- if (txn.origin === MCP_ORIGIN) return;
3626
- for (const [key, change] of event.changes.keys) {
3627
- if (change.action !== "add") continue;
3628
- const msg = chatMap.get(key);
3629
- if (!msg || msg.author !== "user") continue;
3630
- pushEvent({
3631
- id: generateEventId(),
3632
- type: "chat:message",
3633
- timestamp: Date.now(),
3634
- documentId: msg.documentId,
3635
- payload: {
3636
- messageId: msg.id,
3637
- text: msg.text,
3638
- replyTo: msg.replyTo ?? null,
3639
- anchor: msg.anchor ?? null
3640
- }
3641
- });
3641
+ function withErrorBoundary(toolName, handler) {
3642
+ return async (args) => {
3643
+ try {
3644
+ return await handler(args);
3645
+ } catch (err) {
3646
+ console.error(`[Tandem] Tool ${toolName} threw:`, err);
3647
+ return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
3642
3648
  }
3643
3649
  };
3644
- chatMap.observe(chatObs);
3645
- ctrlCleanups.push(() => chatMap.unobserve(chatObs));
3646
- const metaMap = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
3647
- let lastActiveDocId = null;
3648
- let lastOpenDocIds = /* @__PURE__ */ new Set();
3649
- const metaObs = (event, txn) => {
3650
- if (txn.origin === MCP_ORIGIN) return;
3651
- if (event.keysChanged.has("activeDocumentId")) {
3652
- const activeId = metaMap.get("activeDocumentId");
3653
- if (activeId && activeId !== lastActiveDocId) {
3654
- const openDoc = getOpenDocs().get(activeId);
3655
- pushEvent({
3656
- id: generateEventId(),
3657
- type: "document:switched",
3658
- timestamp: Date.now(),
3659
- documentId: activeId,
3660
- payload: {
3661
- fileName: openDoc?.filePath?.split(/[/\\]/).pop() ?? activeId
3662
- }
3663
- });
3664
- lastActiveDocId = activeId;
3665
- }
3666
- }
3667
- if (event.keysChanged.has("openDocuments")) {
3668
- const docList = metaMap.get("openDocuments") ?? [];
3669
- const currentIds = new Set(docList.map((d) => d.id));
3670
- for (const doc of docList) {
3671
- if (!lastOpenDocIds.has(doc.id)) {
3672
- const openDoc = getOpenDocs().get(doc.id);
3673
- pushEvent({
3674
- id: generateEventId(),
3675
- type: "document:opened",
3676
- timestamp: Date.now(),
3677
- documentId: doc.id,
3678
- payload: {
3679
- fileName: doc.fileName ?? openDoc?.filePath?.split(/[/\\]/).pop() ?? doc.id,
3680
- format: openDoc?.format ?? "unknown"
3681
- }
3682
- });
3683
- }
3684
- }
3685
- for (const oldId of lastOpenDocIds) {
3686
- if (!currentIds.has(oldId)) {
3687
- pushEvent({
3688
- id: generateEventId(),
3689
- type: "document:closed",
3690
- timestamp: Date.now(),
3691
- documentId: oldId,
3692
- payload: {
3693
- fileName: oldId
3694
- }
3695
- });
3696
- }
3697
- }
3698
- lastOpenDocIds = currentIds;
3699
- }
3700
- };
3701
- metaMap.observe(metaObs);
3702
- ctrlCleanups.push(() => metaMap.unobserve(metaObs));
3703
- console.error("[EventQueue] Attached CTRL_ROOM observers (chat + documentMeta)");
3704
3650
  }
3705
- function reattachCtrlObservers() {
3706
- attachCtrlObservers();
3651
+ function escapeRegex(str) {
3652
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3707
3653
  }
3708
- var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
3709
- var init_queue = __esm({
3710
- "src/server/events/queue.ts"() {
3654
+ var init_response = __esm({
3655
+ "src/server/mcp/response.ts"() {
3711
3656
  "use strict";
3712
- init_constants();
3713
- init_document_service();
3714
- init_provider();
3715
- init_types3();
3716
- MCP_ORIGIN = "mcp";
3717
- docObservers = /* @__PURE__ */ new Map();
3718
- emittedPayloadIds = /* @__PURE__ */ new Map();
3719
- buffer2 = [];
3720
- subscribers2 = /* @__PURE__ */ new Set();
3721
- ctrlCleanups = [];
3722
3657
  }
3723
3658
  });
3724
3659
 
3725
- // src/server/mcp/launcher.ts
3726
- var launcher_exports = {};
3727
- __export(launcher_exports, {
3728
- killClaude: () => killClaude,
3729
- launchClaude: () => launchClaude
3730
- });
3731
- import { spawn } from "child_process";
3732
- function launchClaude() {
3733
- if (claudeProcess && !claudeProcess.killed) {
3734
- return { status: "already_running", pid: claudeProcess.pid };
3735
- }
3736
- const claudeCmd = process.env.TANDEM_CLAUDE_CMD || "claude";
3737
- const tandemUrl = `http://localhost:${process.env.TANDEM_MCP_PORT || DEFAULT_MCP_PORT}`;
3738
- const args = [
3739
- "--dangerously-load-development-channels",
3740
- "server:tandem-channel",
3741
- "--append-system-prompt",
3742
- TANDEM_SYSTEM_PROMPT,
3743
- "--name",
3744
- "tandem-reviewer"
3745
- ];
3746
- claudeProcess = spawn(claudeCmd, args, {
3747
- env: { ...process.env, TANDEM_URL: tandemUrl },
3748
- stdio: "pipe",
3749
- detached: true
3750
- });
3751
- if (claudeProcess.stdin?.writable) {
3752
- claudeProcess.stdin.write(
3753
- "A document has been opened in Tandem for review. Call tandem_checkInbox to see what needs attention, then begin reviewing.\n",
3754
- (err) => {
3755
- if (err) console.error("[Launcher] Failed to send initial prompt:", err.message);
3756
- }
3757
- );
3758
- } else {
3759
- console.error("[Launcher] Claude process has no writable stdin \u2014 initial prompt not delivered");
3760
- }
3761
- const pid = claudeProcess.pid;
3762
- claudeProcess.on("error", (err) => {
3763
- if (err.code === "ENOENT") {
3764
- console.error(
3765
- "[Launcher] Claude Code not found. Install with: npm i -g @anthropic-ai/claude-code"
3766
- );
3767
- } else {
3768
- console.error("[Launcher] spawn error:", err);
3769
- }
3770
- claudeProcess = null;
3771
- });
3772
- claudeProcess.on("exit", (code) => {
3773
- console.error(`[Launcher] Claude Code exited with code ${code}`);
3774
- claudeProcess = null;
3775
- });
3776
- claudeProcess.stderr?.on("data", (chunk) => {
3777
- console.error(`[Claude] ${chunk.toString().trimEnd()}`);
3778
- });
3779
- claudeProcess.unref();
3780
- console.error(`[Launcher] Claude Code launched (pid: ${pid})`);
3781
- return { status: "launched", pid };
3782
- }
3783
- function killClaude() {
3784
- if (claudeProcess && !claudeProcess.killed) {
3785
- console.error(`[Launcher] Killing Claude Code (pid: ${claudeProcess.pid})`);
3786
- try {
3787
- claudeProcess.kill("SIGTERM");
3788
- } catch (err) {
3789
- const code = err.code;
3790
- if (code !== "ESRCH") {
3791
- console.error("[Launcher] Failed to kill Claude process:", err);
3792
- }
3660
+ // src/server/mcp/document.ts
3661
+ import * as Y8 from "yjs";
3662
+ import { z as z2 } from "zod";
3663
+ function getOutline(fragment) {
3664
+ const outline = [];
3665
+ for (let i = 0; i < fragment.length; i++) {
3666
+ const node = fragment.get(i);
3667
+ if (node instanceof Y8.XmlElement && node.nodeName === "heading") {
3668
+ const level = Number(node.getAttribute("level") ?? 1);
3669
+ outline.push({ level, text: getElementText(node), index: i });
3793
3670
  }
3794
- claudeProcess = null;
3795
3671
  }
3672
+ return outline;
3796
3673
  }
3797
- var claudeProcess, TANDEM_SYSTEM_PROMPT;
3798
- var init_launcher = __esm({
3799
- "src/server/mcp/launcher.ts"() {
3800
- "use strict";
3801
- init_constants();
3802
- claudeProcess = null;
3803
- TANDEM_SYSTEM_PROMPT = [
3804
- "You are Claude, connected to Tandem \u2014 a collaborative document editor.",
3805
- "You will receive real-time push notifications via the tandem-channel when users",
3806
- "create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
3807
- "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
3808
- "tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
3809
- "Start by calling tandem_checkInbox to see what needs attention."
3810
- ].join(" ");
3811
- }
3812
- });
3813
-
3814
- // src/server/index.ts
3815
- import path10 from "path";
3816
- import { fileURLToPath as fileURLToPath2 } from "url";
3817
-
3818
- // src/server/mcp/server.ts
3819
- import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
3820
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3821
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3822
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3823
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
3824
- import { randomUUID as randomUUID3 } from "crypto";
3825
- import { existsSync } from "fs";
3826
- import { dirname, join } from "path";
3827
- import { fileURLToPath } from "url";
3828
- import { createRequire } from "module";
3829
-
3830
- // src/server/open-browser.ts
3831
- import { execFile } from "child_process";
3832
- function openBrowser(url) {
3833
- let command;
3834
- let args;
3835
- if (process.platform === "win32") {
3836
- command = "cmd";
3837
- args = ["/c", "start", "", url];
3838
- } else if (process.platform === "darwin") {
3839
- command = "open";
3840
- args = [url];
3841
- } else {
3842
- command = "xdg-open";
3843
- args = [url];
3844
- }
3845
- execFile(command, args, (err) => {
3846
- if (err) {
3847
- console.error("[Tandem] Could not open browser automatically.");
3848
- console.error(`[Tandem] Open this URL manually: ${url}`);
3674
+ function getSection(fragment, sectionName) {
3675
+ const lines = [];
3676
+ let inSection = false;
3677
+ let sectionLevel = 0;
3678
+ for (let i = 0; i < fragment.length; i++) {
3679
+ const node = fragment.get(i);
3680
+ if (!(node instanceof Y8.XmlElement)) continue;
3681
+ const text = getElementText(node);
3682
+ if (node.nodeName === "heading") {
3683
+ const level = Number(node.getAttribute("level") ?? 1);
3684
+ if (inSection && level <= sectionLevel) break;
3685
+ if (text.trim().toLowerCase() === sectionName.trim().toLowerCase()) {
3686
+ inSection = true;
3687
+ sectionLevel = level;
3688
+ lines.push(headingPrefix(level) + text);
3689
+ continue;
3690
+ }
3849
3691
  }
3850
- });
3851
- }
3852
-
3853
- // src/server/mcp/annotations.ts
3854
- init_constants();
3855
- init_queue();
3856
- init_provider();
3857
- import { z as z3 } from "zod";
3858
-
3859
- // src/server/mcp/document.ts
3860
- init_provider();
3861
- import { z as z2 } from "zod";
3862
- import * as Y8 from "yjs";
3863
-
3864
- // src/server/mcp/response.ts
3865
- function mcpSuccess(data) {
3866
- return {
3867
- content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
3868
- };
3869
- }
3870
- function mcpError(code, message, details) {
3871
- return {
3872
- content: [
3873
- {
3874
- type: "text",
3875
- text: JSON.stringify({ error: true, code, message, ...details && { details } })
3692
+ if (inSection) {
3693
+ if (node.nodeName === "heading") {
3694
+ const level = Number(node.getAttribute("level") ?? 1);
3695
+ lines.push(headingPrefix(level) + text);
3696
+ } else {
3697
+ lines.push(text);
3876
3698
  }
3877
- ]
3878
- };
3879
- }
3880
- function noDocumentError() {
3881
- return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
3882
- }
3883
- function getErrorMessage(err) {
3884
- return err instanceof Error ? err.message : String(err);
3885
- }
3886
- function withErrorBoundary(toolName, handler) {
3887
- return async (args) => {
3888
- try {
3889
- return await handler(args);
3890
- } catch (err) {
3891
- console.error(`[Tandem] Tool ${toolName} threw:`, err);
3892
- return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
3893
3699
  }
3894
- };
3895
- }
3896
- function escapeRegex(str) {
3897
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3898
- }
3899
-
3900
- // src/server/mcp/document.ts
3901
- init_notifications();
3902
- init_utils();
3903
- init_offsets();
3904
- init_file_io();
3905
- init_file_watcher();
3906
-
3907
- // src/server/mcp/convert.ts
3908
- init_provider();
3909
- init_document_model();
3910
- init_file_io();
3911
- init_file_opener();
3912
- init_document_service();
3913
- import fs5 from "fs/promises";
3914
- import path7 from "path";
3915
- async function findAvailablePath(basePath) {
3916
- const dir = path7.dirname(basePath);
3917
- const ext = path7.extname(basePath);
3918
- const name = path7.basename(basePath, ext);
3919
- const MAX_ATTEMPTS = 1e3;
3920
- let candidate = basePath;
3921
- let counter = 0;
3922
- while (counter <= MAX_ATTEMPTS) {
3923
- try {
3924
- await fs5.access(candidate);
3925
- counter++;
3926
- candidate = path7.join(dir, `${name}-${counter}${ext}`);
3927
- } catch (err) {
3928
- const code = err.code;
3929
- if (code === "ENOENT") return candidate;
3930
- throw err;
3931
- }
3932
- }
3933
- throw Object.assign(new Error("Could not find an available filename after 1000 attempts."), {
3934
- code: "CONFLICT"
3935
- });
3936
- }
3937
- async function convertToMarkdown(documentId, outputPath) {
3938
- const docState = getCurrentDoc(documentId);
3939
- if (!docState) {
3940
- throw Object.assign(new Error("Document not found."), { code: "FILE_NOT_FOUND" });
3941
- }
3942
- if (docState.format !== "docx") {
3943
- throw Object.assign(new Error("Only .docx documents can be converted to Markdown."), {
3944
- code: "UNSUPPORTED_FORMAT"
3945
- });
3946
- }
3947
- if (docState.source === "upload") {
3948
- throw Object.assign(
3949
- new Error(
3950
- "Uploaded .docx files cannot be converted \u2014 no disk location to write the .md file."
3951
- ),
3952
- { code: "INVALID_PATH" }
3953
- );
3954
- }
3955
- const doc = getOrCreateDocument(docState.id);
3956
- const markdown = extractMarkdown(doc);
3957
- if (!markdown.trim()) {
3958
- throw Object.assign(
3959
- new Error("Conversion produced empty output \u2014 the .docx may not contain extractable text."),
3960
- { code: "EMPTY_CONVERSION" }
3961
- );
3962
- }
3963
- const sourceDir = path7.dirname(docState.filePath);
3964
- let resolvedOutput;
3965
- if (outputPath) {
3966
- resolvedOutput = path7.resolve(outputPath);
3967
- if (resolvedOutput.startsWith("\\\\") || resolvedOutput.startsWith("//")) {
3968
- throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
3969
- code: "INVALID_PATH"
3970
- });
3971
- }
3972
- try {
3973
- const stat = await fs5.stat(resolvedOutput);
3974
- if (stat.isDirectory()) {
3975
- const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
3976
- resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
3977
- }
3978
- } catch (err) {
3979
- const code = err.code;
3980
- if (code !== "ENOENT") throw err;
3981
- }
3982
- } else {
3983
- const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
3984
- resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
3985
- }
3986
- resolvedOutput = await findAvailablePath(resolvedOutput);
3987
- await atomicWrite2(resolvedOutput, markdown);
3988
- try {
3989
- const openResult = await openFileByPath(resolvedOutput);
3990
- return {
3991
- outputPath: resolvedOutput,
3992
- documentId: openResult.documentId,
3993
- fileName: openResult.fileName
3994
- };
3995
- } catch (err) {
3996
- throw Object.assign(
3997
- new Error(
3998
- `Markdown written to ${resolvedOutput} but failed to open: ${err.message}`
3999
- ),
4000
- { code: "OPEN_FAILED" }
4001
- );
4002
- }
4003
- }
4004
-
4005
- // src/server/mcp/document.ts
4006
- init_manager();
4007
- init_file_opener();
4008
- init_constants();
4009
- init_types2();
4010
- init_queue();
4011
- init_document_model();
4012
- init_positions2();
4013
- init_document_service();
4014
- init_document_model();
4015
- init_positions2();
4016
- init_document_service();
4017
- init_file_opener();
4018
- function getOutline(fragment) {
4019
- const outline = [];
4020
- for (let i = 0; i < fragment.length; i++) {
4021
- const node = fragment.get(i);
4022
- if (node instanceof Y8.XmlElement && node.nodeName === "heading") {
4023
- const level = Number(node.getAttribute("level") ?? 1);
4024
- outline.push({ level, text: getElementText(node), index: i });
4025
- }
4026
- }
4027
- return outline;
4028
- }
4029
- function getSection(fragment, sectionName) {
4030
- const lines = [];
4031
- let inSection = false;
4032
- let sectionLevel = 0;
4033
- for (let i = 0; i < fragment.length; i++) {
4034
- const node = fragment.get(i);
4035
- if (!(node instanceof Y8.XmlElement)) continue;
4036
- const text = getElementText(node);
4037
- if (node.nodeName === "heading") {
4038
- const level = Number(node.getAttribute("level") ?? 1);
4039
- if (inSection && level <= sectionLevel) break;
4040
- if (text.trim().toLowerCase() === sectionName.trim().toLowerCase()) {
4041
- inSection = true;
4042
- sectionLevel = level;
4043
- lines.push(headingPrefix(level) + text);
4044
- continue;
4045
- }
4046
- }
4047
- if (inSection) {
4048
- if (node.nodeName === "heading") {
4049
- const level = Number(node.getAttribute("level") ?? 1);
4050
- lines.push(headingPrefix(level) + text);
4051
- } else {
4052
- lines.push(text);
4053
- }
4054
- }
4055
- }
4056
- if (!inSection) return { found: false };
4057
- return { found: true, text: lines.join("\n") };
3700
+ }
3701
+ if (!inSection) return { found: false };
3702
+ return { found: true, text: lines.join("\n") };
4058
3703
  }
4059
3704
  function registerDocumentTools(server) {
4060
3705
  const openDocs2 = getOpenDocs();
@@ -4278,7 +3923,7 @@ function registerDocumentTools(server) {
4278
3923
  }
4279
3924
  const output = adapter.save(r.doc);
4280
3925
  suppressNextChange(r.filePath);
4281
- await atomicWrite2(r.filePath, output);
3926
+ await atomicWrite(r.filePath, output);
4282
3927
  await saveSession(r.filePath, format, r.doc);
4283
3928
  const meta = r.doc.getMap(Y_MAP_DOCUMENT_META);
4284
3929
  r.doc.transact(() => meta.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
@@ -4407,14 +4052,34 @@ function registerDocumentTools(server) {
4407
4052
  })
4408
4053
  );
4409
4054
  }
4055
+ var init_document = __esm({
4056
+ "src/server/mcp/document.ts"() {
4057
+ "use strict";
4058
+ init_constants();
4059
+ init_offsets();
4060
+ init_types2();
4061
+ init_utils();
4062
+ init_queue();
4063
+ init_file_io();
4064
+ init_file_watcher();
4065
+ init_notifications();
4066
+ init_positions2();
4067
+ init_manager();
4068
+ init_provider();
4069
+ init_convert();
4070
+ init_document_model();
4071
+ init_document_service();
4072
+ init_file_opener();
4073
+ init_response();
4074
+ init_positions2();
4075
+ init_document_model();
4076
+ init_document_service();
4077
+ init_file_opener();
4078
+ }
4079
+ });
4410
4080
 
4411
4081
  // src/server/mcp/annotations.ts
4412
- init_docx();
4413
- init_types2();
4414
- init_positions2();
4415
- init_notifications();
4416
- init_utils();
4417
- init_positions2();
4082
+ import { z as z3 } from "zod";
4418
4083
  function getDocAndAnnotations(documentId) {
4419
4084
  const doc = getCurrentDoc(documentId);
4420
4085
  if (!doc) return null;
@@ -4458,7 +4123,6 @@ function notifyRangeFailure(result, toolName, documentId) {
4458
4123
  timestamp: Date.now()
4459
4124
  });
4460
4125
  }
4461
- var SNAPSHOT_CAP = 200;
4462
4126
  function captureSnapshot(ydoc, from, to) {
4463
4127
  const text = extractText(ydoc).slice(from, to);
4464
4128
  return text.length > SNAPSHOT_CAP ? text.slice(0, SNAPSHOT_CAP - 3) + "..." : text;
@@ -4478,12 +4142,14 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
4478
4142
  };
4479
4143
  ydoc.transact(() => map.set(id, annotation), MCP_ORIGIN);
4480
4144
  const snippet = annotation.textSnapshot ? `: "${annotation.textSnapshot.slice(0, 60)}${annotation.textSnapshot.length > 60 ? "\u2026" : ""}"` : "";
4145
+ const label = annotation.suggestedText !== void 0 ? "Replacement" : annotation.directedAt === "claude" ? "Question" : type[0].toUpperCase() + type.slice(1);
4146
+ const dedupSuffix = annotation.suggestedText !== void 0 ? "replacement" : annotation.directedAt ?? type;
4481
4147
  pushNotification({
4482
4148
  id: generateNotificationId(),
4483
4149
  type: "review-pending",
4484
4150
  severity: "info",
4485
- message: `New ${type[0].toUpperCase() + type.slice(1)}${snippet}`,
4486
- dedupKey: `review-pending:${type}`,
4151
+ message: `New ${label}${snippet}`,
4152
+ dedupKey: `review-pending:${dedupSuffix}`,
4487
4153
  timestamp: Date.now()
4488
4154
  });
4489
4155
  return id;
@@ -4493,7 +4159,7 @@ function collectAnnotations(map) {
4493
4159
  map.forEach((value, key) => {
4494
4160
  const ann = value;
4495
4161
  if (ann && typeof ann === "object" && typeof ann.id === "string" && typeof ann.type === "string" && typeof ann.status === "string" && ann.range && typeof ann.range.from === "number" && typeof ann.range.to === "number") {
4496
- result.push(ann);
4162
+ result.push(sanitizeAnnotation(ann));
4497
4163
  } else {
4498
4164
  console.warn(`[Tandem] Skipping malformed annotation entry: ${key}`);
4499
4165
  }
@@ -4510,16 +4176,13 @@ function registerAnnotationTools(server) {
4510
4176
  color: HighlightColorSchema.describe("Highlight color"),
4511
4177
  note: z3.string().optional().describe("Optional note for the highlight"),
4512
4178
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4513
- priority: AnnotationPrioritySchema.optional().describe(
4514
- "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."
4515
- ),
4516
4179
  textSnapshot: z3.string().optional().describe(
4517
4180
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4518
4181
  )
4519
4182
  },
4520
4183
  withErrorBoundary(
4521
4184
  "tandem_highlight",
4522
- async ({ from: rawFrom, to: rawTo, color, note, documentId, priority, textSnapshot }) => {
4185
+ async ({ from: rawFrom, to: rawTo, color, note, documentId, textSnapshot }) => {
4523
4186
  const da = getDocAndAnnotations(documentId);
4524
4187
  if (!da) return noDocumentError();
4525
4188
  const from = toFlatOffset(rawFrom);
@@ -4532,7 +4195,6 @@ function registerAnnotationTools(server) {
4532
4195
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4533
4196
  const id = createAnnotation(da.map, da.ydoc, "highlight", result, note || "", {
4534
4197
  color,
4535
- ...priority ? { priority } : {},
4536
4198
  textSnapshot: snap
4537
4199
  });
4538
4200
  return mcpSuccess({ annotationId: id });
@@ -4541,22 +4203,29 @@ function registerAnnotationTools(server) {
4541
4203
  );
4542
4204
  server.tool(
4543
4205
  "tandem_comment",
4544
- "Add a comment to a text range",
4206
+ "Add a comment to a text range. Optionally include suggestedText for a replacement proposal, or directedAt: 'claude' to ask Claude.",
4545
4207
  {
4546
4208
  from: z3.number().describe("Start position"),
4547
4209
  to: z3.number().describe("End position"),
4548
4210
  text: z3.string().describe("Comment text"),
4211
+ suggestedText: z3.string().optional().describe("Optional replacement text \u2014 turns this into a tracked-change suggestion"),
4212
+ directedAt: z3.enum(["claude"]).optional().describe("Set to 'claude' to direct this comment to Claude for response"),
4549
4213
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4550
- priority: AnnotationPrioritySchema.optional().describe(
4551
- "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."
4552
- ),
4553
4214
  textSnapshot: z3.string().optional().describe(
4554
4215
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4555
4216
  )
4556
4217
  },
4557
4218
  withErrorBoundary(
4558
4219
  "tandem_comment",
4559
- async ({ from: rawFrom, to: rawTo, text, documentId, priority, textSnapshot }) => {
4220
+ async ({
4221
+ from: rawFrom,
4222
+ to: rawTo,
4223
+ text,
4224
+ suggestedText,
4225
+ directedAt,
4226
+ documentId,
4227
+ textSnapshot
4228
+ }) => {
4560
4229
  const da = getDocAndAnnotations(documentId);
4561
4230
  if (!da) return noDocumentError();
4562
4231
  const from = toFlatOffset(rawFrom);
@@ -4568,8 +4237,9 @@ function registerAnnotationTools(server) {
4568
4237
  }
4569
4238
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4570
4239
  const id = createAnnotation(da.map, da.ydoc, "comment", result, text, {
4571
- ...priority ? { priority } : {},
4572
- textSnapshot: snap
4240
+ textSnapshot: snap,
4241
+ ...suggestedText !== void 0 ? { suggestedText } : {},
4242
+ ...directedAt !== void 0 ? { directedAt } : {}
4573
4243
  });
4574
4244
  return mcpSuccess({ annotationId: id });
4575
4245
  }
@@ -4577,23 +4247,20 @@ function registerAnnotationTools(server) {
4577
4247
  );
4578
4248
  server.tool(
4579
4249
  "tandem_suggest",
4580
- "Propose a text replacement (tracked change style)",
4250
+ "Propose a text replacement (tracked change style). Legacy shim \u2014 prefer tandem_comment with suggestedText.",
4581
4251
  {
4582
4252
  from: z3.number().describe("Start position"),
4583
4253
  to: z3.number().describe("End position"),
4584
4254
  newText: z3.string().describe("Suggested replacement text"),
4585
4255
  reason: z3.string().optional().describe("Reason for the suggestion"),
4586
4256
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4587
- priority: AnnotationPrioritySchema.optional().describe(
4588
- "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."
4589
- ),
4590
4257
  textSnapshot: z3.string().optional().describe(
4591
4258
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4592
4259
  )
4593
4260
  },
4594
4261
  withErrorBoundary(
4595
4262
  "tandem_suggest",
4596
- async ({ from: rawFrom, to: rawTo, newText, reason, documentId, priority, textSnapshot }) => {
4263
+ async ({ from: rawFrom, to: rawTo, newText, reason, documentId, textSnapshot }) => {
4597
4264
  const da = getDocAndAnnotations(documentId);
4598
4265
  if (!da) return noDocumentError();
4599
4266
  const from = toFlatOffset(rawFrom);
@@ -4604,14 +4271,10 @@ function registerAnnotationTools(server) {
4604
4271
  return rangeFailureToError(result);
4605
4272
  }
4606
4273
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4607
- const id = createAnnotation(
4608
- da.map,
4609
- da.ydoc,
4610
- "suggestion",
4611
- result,
4612
- JSON.stringify({ newText, reason: reason || "" }),
4613
- { ...priority ? { priority } : {}, textSnapshot: snap }
4614
- );
4274
+ const id = createAnnotation(da.map, da.ydoc, "comment", result, reason || "", {
4275
+ textSnapshot: snap,
4276
+ suggestedText: newText
4277
+ });
4615
4278
  return mcpSuccess({ annotationId: id });
4616
4279
  }
4617
4280
  )
@@ -4624,16 +4287,13 @@ function registerAnnotationTools(server) {
4624
4287
  to: z3.number().describe("End position"),
4625
4288
  note: z3.string().optional().describe("Reason for flagging"),
4626
4289
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
4627
- priority: AnnotationPrioritySchema.optional().describe(
4628
- "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."
4629
- ),
4630
4290
  textSnapshot: z3.string().optional().describe(
4631
4291
  "Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
4632
4292
  )
4633
4293
  },
4634
4294
  withErrorBoundary(
4635
4295
  "tandem_flag",
4636
- async ({ from: rawFrom, to: rawTo, note, documentId, priority, textSnapshot }) => {
4296
+ async ({ from: rawFrom, to: rawTo, note, documentId, textSnapshot }) => {
4637
4297
  const da = getDocAndAnnotations(documentId);
4638
4298
  if (!da) return noDocumentError();
4639
4299
  const from = toFlatOffset(rawFrom);
@@ -4645,7 +4305,6 @@ function registerAnnotationTools(server) {
4645
4305
  }
4646
4306
  const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
4647
4307
  const id = createAnnotation(da.map, da.ydoc, "flag", result, note || "", {
4648
- ...priority ? { priority } : {},
4649
4308
  textSnapshot: snap
4650
4309
  });
4651
4310
  return mcpSuccess({ annotationId: id });
@@ -4682,8 +4341,9 @@ function registerAnnotationTools(server) {
4682
4341
  withErrorBoundary("tandem_resolveAnnotation", async ({ id, action, documentId }) => {
4683
4342
  const da = getDocAndAnnotations(documentId);
4684
4343
  if (!da) return noDocumentError();
4685
- const ann = da.map.get(id);
4686
- if (!ann) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
4344
+ const raw = da.map.get(id);
4345
+ if (!raw) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
4346
+ const ann = sanitizeAnnotation(raw);
4687
4347
  const updated = {
4688
4348
  ...ann,
4689
4349
  status: action === "accept" ? "accepted" : "dismissed"
@@ -4709,12 +4369,12 @@ function registerAnnotationTools(server) {
4709
4369
  );
4710
4370
  server.tool(
4711
4371
  "tandem_editAnnotation",
4712
- "Edit the content of an existing annotation. For suggestions, use newText/reason params; for other types, use content.",
4372
+ "Edit the content of an existing annotation. Use newText to update replacement text, reason/content for the comment body.",
4713
4373
  {
4714
4374
  id: z3.string().describe("Annotation ID"),
4715
- content: z3.string().optional().describe("New text for non-suggestion annotations"),
4716
- newText: z3.string().optional().describe("For suggestions: new replacement text"),
4717
- reason: z3.string().optional().describe("For suggestions: new reason"),
4375
+ content: z3.string().optional().describe("New comment text"),
4376
+ newText: z3.string().optional().describe("New replacement text (sets suggestedText)"),
4377
+ reason: z3.string().optional().describe("Alias for content (legacy compat)"),
4718
4378
  documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
4719
4379
  },
4720
4380
  withErrorBoundary(
@@ -4722,99 +4382,555 @@ function registerAnnotationTools(server) {
4722
4382
  async ({ id, content, newText, reason, documentId }) => {
4723
4383
  const da = getDocAndAnnotations(documentId);
4724
4384
  if (!da) return noDocumentError();
4725
- const ann = da.map.get(id);
4726
- if (!ann) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
4385
+ const raw = da.map.get(id);
4386
+ if (!raw) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
4387
+ const ann = sanitizeAnnotation(raw);
4727
4388
  if (ann.status !== "pending") {
4728
4389
  return mcpError("INVALID_RANGE", `Cannot edit a ${ann.status} annotation`);
4729
4390
  }
4730
- let updatedContent;
4731
- if (ann.type === "suggestion") {
4732
- if (content !== void 0 && newText === void 0 && reason === void 0) {
4733
- updatedContent = content;
4734
- } else if (newText !== void 0 || reason !== void 0) {
4735
- let existing;
4736
- try {
4737
- existing = JSON.parse(ann.content);
4738
- } catch {
4739
- console.error(
4740
- `[tandem_editAnnotation] Malformed existing content for suggestion ${id}`
4741
- );
4742
- return mcpError(
4743
- "INVALID_RANGE",
4744
- `Suggestion ${id} has malformed content \u2014 cannot merge fields. Use 'content' to replace entirely.`
4745
- );
4746
- }
4747
- updatedContent = JSON.stringify({
4748
- newText: newText !== void 0 ? newText : existing.newText,
4749
- reason: reason !== void 0 ? reason : existing.reason
4750
- });
4751
- } else {
4752
- return mcpError(
4753
- "INVALID_RANGE",
4754
- "No editable fields provided. Use newText/reason for suggestions, or content."
4755
- );
4756
- }
4757
- } else {
4758
- if (content === void 0) {
4759
- return mcpError(
4760
- "INVALID_RANGE",
4761
- "No editable fields provided. Use content for non-suggestion annotations."
4762
- );
4763
- }
4764
- updatedContent = content;
4391
+ if (content === void 0 && newText === void 0 && reason === void 0) {
4392
+ return mcpError(
4393
+ "INVALID_RANGE",
4394
+ "No editable fields provided. Use content, newText, or reason."
4395
+ );
4396
+ }
4397
+ if (newText !== void 0 && ann.type !== "comment") {
4398
+ return mcpError(
4399
+ "INVALID_RANGE",
4400
+ `Cannot set replacement text on a ${ann.type} annotation. Only comments support suggestedText.`
4401
+ );
4765
4402
  }
4766
- const updated = { ...ann, content: updatedContent, editedAt: Date.now() };
4403
+ const updated = {
4404
+ ...ann,
4405
+ ...content !== void 0 ? { content } : {},
4406
+ ...reason !== void 0 && content === void 0 ? { content: reason } : {},
4407
+ ...newText !== void 0 ? { suggestedText: newText } : {},
4408
+ editedAt: Date.now()
4409
+ };
4767
4410
  da.ydoc.transact(() => da.map.set(id, updated), MCP_ORIGIN);
4768
- return mcpSuccess({ id, content: updatedContent, editedAt: updated.editedAt });
4411
+ return mcpSuccess({
4412
+ id,
4413
+ content: updated.content,
4414
+ suggestedText: updated.suggestedText,
4415
+ editedAt: updated.editedAt
4416
+ });
4417
+ }
4418
+ )
4419
+ );
4420
+ server.tool(
4421
+ "tandem_exportAnnotations",
4422
+ "Export all annotations as a formatted summary. Useful for review reports, especially on read-only .docx files.",
4423
+ {
4424
+ format: ExportFormatSchema.optional().describe("Output format (default: markdown)"),
4425
+ documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
4426
+ },
4427
+ withErrorBoundary("tandem_exportAnnotations", async ({ format, documentId }) => {
4428
+ const da = getDocAndAnnotations(documentId);
4429
+ if (!da) return noDocumentError();
4430
+ const annotations = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
4431
+ const { ydoc } = da;
4432
+ if (format === "json") {
4433
+ const fullText = extractText(ydoc);
4434
+ const enriched = annotations.map((ann) => ({
4435
+ ...ann,
4436
+ textSnippet: fullText.slice(
4437
+ Math.max(0, ann.range.from),
4438
+ Math.min(fullText.length, ann.range.to)
4439
+ )
4440
+ }));
4441
+ return mcpSuccess({ annotations: enriched, count: enriched.length });
4442
+ }
4443
+ const markdown = exportAnnotations(ydoc, annotations);
4444
+ return mcpSuccess({ markdown, count: annotations.length });
4445
+ })
4446
+ );
4447
+ }
4448
+ var SNAPSHOT_CAP;
4449
+ var init_annotations = __esm({
4450
+ "src/server/mcp/annotations.ts"() {
4451
+ "use strict";
4452
+ init_constants();
4453
+ init_sanitize();
4454
+ init_types2();
4455
+ init_utils();
4456
+ init_queue();
4457
+ init_docx();
4458
+ init_notifications();
4459
+ init_positions2();
4460
+ init_provider();
4461
+ init_document();
4462
+ init_response();
4463
+ init_sanitize();
4464
+ init_positions2();
4465
+ SNAPSHOT_CAP = 200;
4466
+ }
4467
+ });
4468
+
4469
+ // src/server/events/types.ts
4470
+ var init_types3 = __esm({
4471
+ "src/server/events/types.ts"() {
4472
+ "use strict";
4473
+ init_utils();
4474
+ }
4475
+ });
4476
+
4477
+ // src/server/events/queue.ts
4478
+ function getDwellMs() {
4479
+ const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
4480
+ const awareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
4481
+ const val = awareness.get(Y_MAP_DWELL_MS);
4482
+ if (val === void 0) return SELECTION_DWELL_DEFAULT_MS;
4483
+ if (typeof val === "number" && val >= SELECTION_DWELL_MIN_MS && val <= SELECTION_DWELL_MAX_MS) {
4484
+ return val;
4485
+ }
4486
+ console.warn(
4487
+ `[EventQueue] Invalid dwell time in CTRL_ROOM awareness (type=${typeof val}, value=${String(val)}); using default ${SELECTION_DWELL_DEFAULT_MS}ms`
4488
+ );
4489
+ return SELECTION_DWELL_DEFAULT_MS;
4490
+ }
4491
+ function getTrackableId(event) {
4492
+ switch (event.type) {
4493
+ case "annotation:created":
4494
+ case "annotation:accepted":
4495
+ case "annotation:dismissed":
4496
+ return event.payload.annotationId;
4497
+ case "chat:message":
4498
+ return event.payload.messageId;
4499
+ default:
4500
+ return void 0;
4501
+ }
4502
+ }
4503
+ function trackPayloadId(event) {
4504
+ const id = getTrackableId(event);
4505
+ if (id) emittedPayloadIds.set(id, (emittedPayloadIds.get(id) ?? 0) + 1);
4506
+ }
4507
+ function untrackPayloadId(event) {
4508
+ const id = getTrackableId(event);
4509
+ if (!id) return;
4510
+ const count = emittedPayloadIds.get(id) ?? 0;
4511
+ if (count <= 1) emittedPayloadIds.delete(id);
4512
+ else emittedPayloadIds.set(id, count - 1);
4513
+ }
4514
+ function pushEvent(event) {
4515
+ buffer2.push(event);
4516
+ trackPayloadId(event);
4517
+ while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
4518
+ const evicted = buffer2.shift();
4519
+ if (evicted) untrackPayloadId(evicted);
4520
+ }
4521
+ const now = Date.now();
4522
+ while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
4523
+ const evicted = buffer2.shift();
4524
+ if (evicted) untrackPayloadId(evicted);
4525
+ }
4526
+ for (const cb of subscribers2) {
4527
+ try {
4528
+ cb(event);
4529
+ } catch (err) {
4530
+ console.error("[EventQueue] Subscriber threw during event dispatch:", err);
4531
+ }
4532
+ }
4533
+ }
4534
+ function subscribe2(cb) {
4535
+ subscribers2.add(cb);
4536
+ }
4537
+ function unsubscribe(cb) {
4538
+ subscribers2.delete(cb);
4539
+ }
4540
+ function replaySince(lastEventId) {
4541
+ const idx = buffer2.findIndex((e) => e.id === lastEventId);
4542
+ if (idx === -1) return [...buffer2];
4543
+ return buffer2.slice(idx + 1);
4544
+ }
4545
+ function attachObservers(docName, doc) {
4546
+ detachObservers(docName);
4547
+ const cleanups = [];
4548
+ const annotationsMap = doc.getMap(Y_MAP_ANNOTATIONS);
4549
+ const annotationsObs = (event, txn) => {
4550
+ if (txn.origin === MCP_ORIGIN) return;
4551
+ for (const [key, change] of event.changes.keys) {
4552
+ const raw = annotationsMap.get(key);
4553
+ if (!raw) continue;
4554
+ let ann;
4555
+ try {
4556
+ ann = sanitizeAnnotation(raw);
4557
+ } catch (err) {
4558
+ console.warn(`[EventQueue] sanitizeAnnotation failed for key=${key}:`, err);
4559
+ continue;
4560
+ }
4561
+ if (change.action === "add" && ann.author === "user") {
4562
+ pushEvent({
4563
+ id: generateEventId(),
4564
+ type: "annotation:created",
4565
+ timestamp: Date.now(),
4566
+ documentId: docName,
4567
+ payload: {
4568
+ annotationId: ann.id,
4569
+ annotationType: ann.type,
4570
+ content: ann.content,
4571
+ textSnippet: ann.textSnapshot ?? "",
4572
+ ...ann.suggestedText !== void 0 ? { hasSuggestedText: true } : {},
4573
+ ...ann.directedAt ? { directedAt: ann.directedAt } : {}
4574
+ }
4575
+ });
4576
+ } else if (change.action === "update" && ann.author === "claude") {
4577
+ if (ann.status === "accepted") {
4578
+ pushEvent({
4579
+ id: generateEventId(),
4580
+ type: "annotation:accepted",
4581
+ timestamp: Date.now(),
4582
+ documentId: docName,
4583
+ payload: {
4584
+ annotationId: ann.id,
4585
+ textSnippet: ann.textSnapshot ?? ""
4586
+ }
4587
+ });
4588
+ } else if (ann.status === "dismissed") {
4589
+ pushEvent({
4590
+ id: generateEventId(),
4591
+ type: "annotation:dismissed",
4592
+ timestamp: Date.now(),
4593
+ documentId: docName,
4594
+ payload: {
4595
+ annotationId: ann.id,
4596
+ textSnippet: ann.textSnapshot ?? ""
4597
+ }
4598
+ });
4599
+ }
4600
+ }
4601
+ }
4602
+ };
4603
+ annotationsMap.observe(annotationsObs);
4604
+ cleanups.push(() => annotationsMap.unobserve(annotationsObs));
4605
+ const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
4606
+ let selectionDwellTimer = null;
4607
+ const awarenessObs = (event, txn) => {
4608
+ if (txn.origin === MCP_ORIGIN) return;
4609
+ if (event.keysChanged.has("selection")) {
4610
+ const selection = userAwareness.get("selection");
4611
+ if (selectionDwellTimer) {
4612
+ clearTimeout(selectionDwellTimer);
4613
+ selectionDwellTimer = null;
4614
+ }
4615
+ if (!selection || selection.from === selection.to) return;
4616
+ selectionDwellTimer = setTimeout(() => {
4617
+ selectionDwellTimer = null;
4618
+ pushEvent({
4619
+ id: generateEventId(),
4620
+ type: "selection:changed",
4621
+ timestamp: Date.now(),
4622
+ documentId: docName,
4623
+ payload: {
4624
+ from: selection.from,
4625
+ to: selection.to,
4626
+ selectedText: selection.selectedText ?? ""
4627
+ }
4628
+ });
4629
+ }, getDwellMs());
4630
+ }
4631
+ };
4632
+ userAwareness.observe(awarenessObs);
4633
+ cleanups.push(() => {
4634
+ userAwareness.unobserve(awarenessObs);
4635
+ if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
4636
+ });
4637
+ docObservers.set(docName, cleanups);
4638
+ console.error(`[EventQueue] Attached observers for document: ${docName}`);
4639
+ }
4640
+ function detachObservers(docName) {
4641
+ const cleanups = docObservers.get(docName);
4642
+ if (cleanups) {
4643
+ for (const cleanup of cleanups) cleanup();
4644
+ docObservers.delete(docName);
4645
+ console.error(`[EventQueue] Detached observers for document: ${docName}`);
4646
+ }
4647
+ }
4648
+ function reattachObservers(docName, newDoc) {
4649
+ attachObservers(docName, newDoc);
4650
+ }
4651
+ function attachCtrlObservers() {
4652
+ for (const cleanup of ctrlCleanups) cleanup();
4653
+ ctrlCleanups = [];
4654
+ const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
4655
+ const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
4656
+ const chatObs = (event, txn) => {
4657
+ if (txn.origin === MCP_ORIGIN) return;
4658
+ for (const [key, change] of event.changes.keys) {
4659
+ if (change.action !== "add") continue;
4660
+ const msg = chatMap.get(key);
4661
+ if (!msg || msg.author !== "user") continue;
4662
+ pushEvent({
4663
+ id: generateEventId(),
4664
+ type: "chat:message",
4665
+ timestamp: Date.now(),
4666
+ documentId: msg.documentId,
4667
+ payload: {
4668
+ messageId: msg.id,
4669
+ text: msg.text,
4670
+ replyTo: msg.replyTo ?? null,
4671
+ anchor: msg.anchor ?? null
4672
+ }
4673
+ });
4674
+ }
4675
+ };
4676
+ chatMap.observe(chatObs);
4677
+ ctrlCleanups.push(() => chatMap.unobserve(chatObs));
4678
+ const metaMap = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
4679
+ let lastActiveDocId = null;
4680
+ let lastOpenDocIds = /* @__PURE__ */ new Set();
4681
+ const metaObs = (event, txn) => {
4682
+ if (txn.origin === MCP_ORIGIN) return;
4683
+ if (event.keysChanged.has("activeDocumentId")) {
4684
+ const activeId = metaMap.get("activeDocumentId");
4685
+ if (activeId && activeId !== lastActiveDocId) {
4686
+ const openDoc = getOpenDocs().get(activeId);
4687
+ pushEvent({
4688
+ id: generateEventId(),
4689
+ type: "document:switched",
4690
+ timestamp: Date.now(),
4691
+ documentId: activeId,
4692
+ payload: {
4693
+ fileName: openDoc?.filePath?.split(/[/\\]/).pop() ?? activeId
4694
+ }
4695
+ });
4696
+ lastActiveDocId = activeId;
4697
+ }
4698
+ }
4699
+ if (event.keysChanged.has("openDocuments")) {
4700
+ const docList = metaMap.get("openDocuments") ?? [];
4701
+ const currentIds = new Set(docList.map((d) => d.id));
4702
+ for (const doc of docList) {
4703
+ if (!lastOpenDocIds.has(doc.id)) {
4704
+ const openDoc = getOpenDocs().get(doc.id);
4705
+ pushEvent({
4706
+ id: generateEventId(),
4707
+ type: "document:opened",
4708
+ timestamp: Date.now(),
4709
+ documentId: doc.id,
4710
+ payload: {
4711
+ fileName: doc.fileName ?? openDoc?.filePath?.split(/[/\\]/).pop() ?? doc.id,
4712
+ format: openDoc?.format ?? "unknown"
4713
+ }
4714
+ });
4715
+ }
4716
+ }
4717
+ for (const oldId of lastOpenDocIds) {
4718
+ if (!currentIds.has(oldId)) {
4719
+ pushEvent({
4720
+ id: generateEventId(),
4721
+ type: "document:closed",
4722
+ timestamp: Date.now(),
4723
+ documentId: oldId,
4724
+ payload: {
4725
+ fileName: oldId
4726
+ }
4727
+ });
4728
+ }
4729
+ }
4730
+ lastOpenDocIds = currentIds;
4731
+ }
4732
+ };
4733
+ metaMap.observe(metaObs);
4734
+ ctrlCleanups.push(() => metaMap.unobserve(metaObs));
4735
+ console.error("[EventQueue] Attached CTRL_ROOM observers (chat + documentMeta)");
4736
+ }
4737
+ function reattachCtrlObservers() {
4738
+ attachCtrlObservers();
4739
+ }
4740
+ var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
4741
+ var init_queue = __esm({
4742
+ "src/server/events/queue.ts"() {
4743
+ "use strict";
4744
+ init_constants();
4745
+ init_annotations();
4746
+ init_document_service();
4747
+ init_provider();
4748
+ init_types3();
4749
+ MCP_ORIGIN = "mcp";
4750
+ docObservers = /* @__PURE__ */ new Map();
4751
+ emittedPayloadIds = /* @__PURE__ */ new Map();
4752
+ buffer2 = [];
4753
+ subscribers2 = /* @__PURE__ */ new Set();
4754
+ ctrlCleanups = [];
4755
+ }
4756
+ });
4757
+
4758
+ // src/server/mcp/launcher.ts
4759
+ var launcher_exports = {};
4760
+ __export(launcher_exports, {
4761
+ killClaude: () => killClaude,
4762
+ launchClaude: () => launchClaude
4763
+ });
4764
+ import { spawn } from "child_process";
4765
+ function launchClaude() {
4766
+ if (claudeProcess && !claudeProcess.killed) {
4767
+ return { status: "already_running", pid: claudeProcess.pid };
4768
+ }
4769
+ const claudeCmd = process.env.TANDEM_CLAUDE_CMD || "claude";
4770
+ const tandemUrl = `http://localhost:${process.env.TANDEM_MCP_PORT || DEFAULT_MCP_PORT}`;
4771
+ const args = [
4772
+ "--dangerously-load-development-channels",
4773
+ "server:tandem-channel",
4774
+ "--append-system-prompt",
4775
+ TANDEM_SYSTEM_PROMPT,
4776
+ "--name",
4777
+ "tandem-reviewer"
4778
+ ];
4779
+ claudeProcess = spawn(claudeCmd, args, {
4780
+ env: { ...process.env, TANDEM_URL: tandemUrl },
4781
+ stdio: "pipe",
4782
+ detached: true
4783
+ });
4784
+ if (claudeProcess.stdin?.writable) {
4785
+ claudeProcess.stdin.write(
4786
+ "A document has been opened in Tandem for review. Call tandem_checkInbox to see what needs attention, then begin reviewing.\n",
4787
+ (err) => {
4788
+ if (err) console.error("[Launcher] Failed to send initial prompt:", err.message);
4769
4789
  }
4770
- )
4771
- );
4772
- server.tool(
4773
- "tandem_exportAnnotations",
4774
- "Export all annotations as a formatted summary. Useful for review reports, especially on read-only .docx files.",
4775
- {
4776
- format: ExportFormatSchema.optional().describe("Output format (default: markdown)"),
4777
- documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
4778
- },
4779
- withErrorBoundary("tandem_exportAnnotations", async ({ format, documentId }) => {
4780
- const da = getDocAndAnnotations(documentId);
4781
- if (!da) return noDocumentError();
4782
- const annotations = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
4783
- const { ydoc } = da;
4784
- if (format === "json") {
4785
- const fullText = extractText(ydoc);
4786
- const enriched = annotations.map((ann) => ({
4787
- ...ann,
4788
- textSnippet: fullText.slice(
4789
- Math.max(0, ann.range.from),
4790
- Math.min(fullText.length, ann.range.to)
4791
- )
4792
- }));
4793
- return mcpSuccess({ annotations: enriched, count: enriched.length });
4790
+ );
4791
+ } else {
4792
+ console.error("[Launcher] Claude process has no writable stdin \u2014 initial prompt not delivered");
4793
+ }
4794
+ const pid = claudeProcess.pid;
4795
+ claudeProcess.on("error", (err) => {
4796
+ if (err.code === "ENOENT") {
4797
+ console.error(
4798
+ "[Launcher] Claude Code not found. Install with: npm i -g @anthropic-ai/claude-code"
4799
+ );
4800
+ } else {
4801
+ console.error("[Launcher] spawn error:", err);
4802
+ }
4803
+ claudeProcess = null;
4804
+ });
4805
+ claudeProcess.on("exit", (code) => {
4806
+ console.error(`[Launcher] Claude Code exited with code ${code}`);
4807
+ claudeProcess = null;
4808
+ });
4809
+ claudeProcess.stderr?.on("data", (chunk) => {
4810
+ console.error(`[Claude] ${chunk.toString().trimEnd()}`);
4811
+ });
4812
+ claudeProcess.unref();
4813
+ console.error(`[Launcher] Claude Code launched (pid: ${pid})`);
4814
+ return { status: "launched", pid };
4815
+ }
4816
+ function killClaude() {
4817
+ if (claudeProcess && !claudeProcess.killed) {
4818
+ console.error(`[Launcher] Killing Claude Code (pid: ${claudeProcess.pid})`);
4819
+ try {
4820
+ claudeProcess.kill("SIGTERM");
4821
+ } catch (err) {
4822
+ const code = err.code;
4823
+ if (code !== "ESRCH") {
4824
+ console.error("[Launcher] Failed to kill Claude process:", err);
4794
4825
  }
4795
- const markdown = exportAnnotations(ydoc, annotations);
4796
- return mcpSuccess({ markdown, count: annotations.length });
4797
- })
4798
- );
4826
+ }
4827
+ claudeProcess = null;
4828
+ }
4829
+ }
4830
+ var claudeProcess, TANDEM_SYSTEM_PROMPT;
4831
+ var init_launcher = __esm({
4832
+ "src/server/mcp/launcher.ts"() {
4833
+ "use strict";
4834
+ init_constants();
4835
+ claudeProcess = null;
4836
+ TANDEM_SYSTEM_PROMPT = [
4837
+ "You are Claude, connected to Tandem \u2014 a collaborative document editor.",
4838
+ "You will receive real-time push notifications via the tandem-channel when users",
4839
+ "create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
4840
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
4841
+ "tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
4842
+ "Start by calling tandem_checkInbox to see what needs attention."
4843
+ ].join(" ");
4844
+ }
4845
+ });
4846
+
4847
+ // src/server/index.ts
4848
+ init_constants();
4849
+ import path10 from "path";
4850
+ import { fileURLToPath as fileURLToPath2 } from "url";
4851
+
4852
+ // src/server/error-filter.ts
4853
+ function isKnownHocuspocusError(err) {
4854
+ if (!(err instanceof Error)) return false;
4855
+ if ("code" in err) {
4856
+ const code = err.code;
4857
+ if (typeof code === "string" && code.startsWith("WS_ERR_")) {
4858
+ return true;
4859
+ }
4860
+ }
4861
+ const msg = err.message;
4862
+ if (msg.startsWith("WebSocket is not open")) return true;
4863
+ if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
4864
+ if (msg.startsWith("Received a message with an unknown type:")) return true;
4865
+ return false;
4866
+ }
4867
+
4868
+ // src/server/index.ts
4869
+ init_queue();
4870
+ init_file_watcher();
4871
+ init_document();
4872
+ init_document_model();
4873
+ init_document_service();
4874
+ init_file_opener();
4875
+
4876
+ // src/server/mcp/server.ts
4877
+ import { existsSync } from "fs";
4878
+ import { dirname, join } from "path";
4879
+ import { fileURLToPath } from "url";
4880
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4881
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4882
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4883
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4884
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4885
+ import { randomUUID as randomUUID3 } from "crypto";
4886
+ import { createRequire } from "module";
4887
+
4888
+ // src/server/open-browser.ts
4889
+ import { execFile } from "child_process";
4890
+ function openBrowser(url) {
4891
+ let command;
4892
+ let args;
4893
+ if (process.platform === "win32") {
4894
+ command = "cmd";
4895
+ args = ["/c", "start", "", url];
4896
+ } else if (process.platform === "darwin") {
4897
+ command = "open";
4898
+ args = [url];
4899
+ } else {
4900
+ command = "xdg-open";
4901
+ args = [url];
4902
+ }
4903
+ execFile(command, args, (err) => {
4904
+ if (err) {
4905
+ console.error("[Tandem] Could not open browser automatically.");
4906
+ console.error(`[Tandem] Open this URL manually: ${url}`);
4907
+ }
4908
+ });
4799
4909
  }
4800
4910
 
4911
+ // src/server/mcp/server.ts
4912
+ init_annotations();
4913
+
4801
4914
  // src/server/mcp/api-routes.ts
4802
4915
  init_constants();
4803
4916
  init_types2();
4917
+ init_notifications();
4918
+ init_provider();
4919
+ init_convert();
4804
4920
  init_document_model();
4805
- init_file_opener();
4806
4921
  init_document_service();
4807
- init_notifications();
4808
4922
 
4809
4923
  // src/server/mcp/docx-apply.ts
4810
- init_document_service();
4811
4924
  init_constants();
4925
+ init_file_io();
4812
4926
  init_positions2();
4927
+ init_annotations();
4813
4928
  init_document_model();
4814
- init_file_io();
4815
- import { z as z4 } from "zod";
4929
+ init_document_service();
4930
+ init_response();
4816
4931
  import fs6 from "fs/promises";
4817
4932
  import path8 from "path";
4933
+ import { z as z4 } from "zod";
4818
4934
  async function applyChangesCore(documentId, author, backupPath) {
4819
4935
  const r = requireDocument(documentId);
4820
4936
  if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
@@ -4844,8 +4960,8 @@ async function applyChangesCore(documentId, author, backupPath) {
4844
4960
  const suggestions = [];
4845
4961
  let pendingCount = 0;
4846
4962
  for (const [, raw] of map) {
4847
- const ann = raw;
4848
- if (ann.type !== "suggestion") continue;
4963
+ const ann = sanitizeAnnotation(raw);
4964
+ if (ann.suggestedText === void 0) continue;
4849
4965
  if (ann.status === "pending") {
4850
4966
  pendingCount++;
4851
4967
  continue;
@@ -4867,13 +4983,7 @@ async function applyChangesCore(documentId, author, backupPath) {
4867
4983
  to = resolvedTo;
4868
4984
  }
4869
4985
  }
4870
- let newText = "";
4871
- try {
4872
- const parsed = JSON.parse(ann.content);
4873
- newText = parsed.newText;
4874
- } catch {
4875
- newText = ann.content;
4876
- }
4986
+ const newText = ann.suggestedText ?? "";
4877
4987
  let importCommentId;
4878
4988
  if (ann.id.startsWith("import-")) {
4879
4989
  const withoutPrefix = ann.id.slice("import-".length);
@@ -4988,7 +5098,7 @@ function registerApplyTools(server) {
4988
5098
  }
4989
5099
 
4990
5100
  // src/server/mcp/api-routes.ts
4991
- init_provider();
5101
+ init_file_opener();
4992
5102
  function isHostAllowed(host) {
4993
5103
  const reqHost = (host ?? "").split(":")[0];
4994
5104
  return reqHost === "localhost" || reqHost === "127.0.0.1";
@@ -5225,12 +5335,15 @@ function registerApiRoutes(app, largeBody) {
5225
5335
  }
5226
5336
 
5227
5337
  // src/server/mcp/awareness.ts
5228
- init_provider();
5229
- import { z as z5 } from "zod";
5338
+ init_constants();
5230
5339
  init_types2();
5231
5340
  init_utils();
5232
- init_constants();
5233
5341
  init_queue();
5342
+ init_provider();
5343
+ init_annotations();
5344
+ init_document();
5345
+ init_response();
5346
+ import { z as z5 } from "zod";
5234
5347
  var surfacedIds = /* @__PURE__ */ new Set();
5235
5348
  function registerAwarenessTools(server) {
5236
5349
  server.tool(
@@ -5584,11 +5697,16 @@ function registerChannelRoutes(app, apiMiddleware2) {
5584
5697
  });
5585
5698
  }
5586
5699
 
5700
+ // src/server/mcp/server.ts
5701
+ init_document();
5702
+
5587
5703
  // src/server/mcp/navigation.ts
5588
5704
  init_constants();
5589
5705
  init_types();
5590
5706
  init_queue();
5591
5707
  init_provider();
5708
+ init_document();
5709
+ init_response();
5592
5710
  import { z as z6 } from "zod";
5593
5711
  function getFullText(docName) {
5594
5712
  const doc = getOrCreateDocument(docName);
@@ -5904,60 +6022,11 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
5904
6022
  });
5905
6023
  }
5906
6024
 
5907
- // src/server/index.ts
5908
- init_provider();
5909
- init_constants();
5910
- init_manager();
5911
- init_platform();
5912
-
5913
- // src/server/version-check.ts
5914
- import fs7 from "fs/promises";
5915
- import path9 from "path";
5916
- async function checkVersionChange(currentVersion, versionFilePath) {
5917
- let storedVersion = null;
5918
- try {
5919
- storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
5920
- } catch (err) {
5921
- if (err.code !== "ENOENT") {
5922
- console.error("[Tandem] Failed to read last-seen-version:", err);
5923
- }
5924
- }
5925
- const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
5926
- if (result !== "current") {
5927
- await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
5928
- await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
5929
- }
5930
- return result;
5931
- }
5932
-
5933
- // src/server/error-filter.ts
5934
- function isKnownHocuspocusError(err) {
5935
- if (!(err instanceof Error)) return false;
5936
- if ("code" in err) {
5937
- const code = err.code;
5938
- if (typeof code === "string" && code.startsWith("WS_ERR_")) {
5939
- return true;
5940
- }
5941
- }
5942
- const msg = err.message;
5943
- if (msg.startsWith("WebSocket is not open")) return true;
5944
- if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
5945
- if (msg.startsWith("Received a message with an unknown type:")) return true;
5946
- return false;
5947
- }
5948
-
5949
- // src/server/index.ts
5950
- init_queue();
5951
- init_document_service();
5952
- init_file_watcher();
5953
- init_file_opener();
5954
- init_document_model();
5955
-
5956
6025
  // src/server/mcp/tutorial-annotations.ts
5957
6026
  init_constants();
6027
+ init_types2();
5958
6028
  init_queue();
5959
6029
  init_positions2();
5960
- init_types2();
5961
6030
  init_document_model();
5962
6031
  var TUTORIAL_ANNOTATIONS = [
5963
6032
  {
@@ -5975,12 +6044,10 @@ var TUTORIAL_ANNOTATIONS = [
5975
6044
  },
5976
6045
  {
5977
6046
  id: `${TUTORIAL_ANNOTATION_PREFIX}suggest-1`,
5978
- type: "suggestion",
6047
+ type: "comment",
5979
6048
  targetText: "simplify onboarding",
5980
- content: JSON.stringify({
5981
- newText: "streamline onboarding",
5982
- reason: "More precise verb choice"
5983
- })
6049
+ content: "More precise verb choice",
6050
+ suggestedText: "streamline onboarding"
5984
6051
  }
5985
6052
  ];
5986
6053
  function injectTutorialAnnotations(doc) {
@@ -6020,8 +6087,9 @@ function injectTutorialAnnotations(doc) {
6020
6087
  content: def.content,
6021
6088
  status: "pending",
6022
6089
  timestamp: Date.now(),
6023
- color: def.color,
6024
- textSnapshot: def.targetText
6090
+ textSnapshot: def.targetText,
6091
+ ...def.color !== void 0 ? { color: def.color } : {},
6092
+ ...def.suggestedText !== void 0 ? { suggestedText: def.suggestedText } : {}
6025
6093
  };
6026
6094
  map.set(def.id, annotation);
6027
6095
  injected++;
@@ -6033,6 +6101,31 @@ function injectTutorialAnnotations(doc) {
6033
6101
  }
6034
6102
 
6035
6103
  // src/server/index.ts
6104
+ init_platform();
6105
+ init_manager();
6106
+
6107
+ // src/server/version-check.ts
6108
+ import fs7 from "fs/promises";
6109
+ import path9 from "path";
6110
+ async function checkVersionChange(currentVersion, versionFilePath) {
6111
+ let storedVersion = null;
6112
+ try {
6113
+ storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
6114
+ } catch (err) {
6115
+ if (err.code !== "ENOENT") {
6116
+ console.error("[Tandem] Failed to read last-seen-version:", err);
6117
+ }
6118
+ }
6119
+ const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
6120
+ if (result !== "current") {
6121
+ await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
6122
+ await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
6123
+ }
6124
+ return result;
6125
+ }
6126
+
6127
+ // src/server/index.ts
6128
+ init_provider();
6036
6129
  var isProduction = process.env.TANDEM_OPEN_BROWSER === "1";
6037
6130
  var SUPPRESSED_PATTERNS = [/^\[mammoth\]/, /Invalid access/i, /^\s*add yjs type/i];
6038
6131
  var originalStderrWrite = process.stderr.write.bind(process.stderr);