tandem-editor 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,7 +40,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
40
40
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
41
41
 
42
42
  // src/shared/constants.ts
43
- 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, TAURI_HOSTNAME;
43
+ 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_ANNOTATION_REPLIES, Y_MAP_SAVED_AT_VERSION, Y_MAP_AUTHORSHIP, NOTIFICATION_BUFFER_SIZE, TUTORIAL_ANNOTATION_PREFIX, CHANNEL_EVENT_BUFFER_SIZE, CHANNEL_EVENT_BUFFER_AGE_MS, CHANNEL_SSE_KEEPALIVE_MS, TAURI_HOSTNAME;
44
44
  var init_constants = __esm({
45
45
  "src/shared/constants.ts"() {
46
46
  "use strict";
@@ -66,7 +66,9 @@ var init_constants = __esm({
66
66
  Y_MAP_DWELL_MS = "selectionDwellMs";
67
67
  Y_MAP_CHAT = "chat";
68
68
  Y_MAP_DOCUMENT_META = "documentMeta";
69
+ Y_MAP_ANNOTATION_REPLIES = "annotationReplies";
69
70
  Y_MAP_SAVED_AT_VERSION = "savedAtVersion";
71
+ Y_MAP_AUTHORSHIP = "authorship";
70
72
  NOTIFICATION_BUFFER_SIZE = 50;
71
73
  TUTORIAL_ANNOTATION_PREFIX = "tutorial-";
72
74
  CHANNEL_EVENT_BUFFER_SIZE = 200;
@@ -4305,9 +4307,15 @@ function generateMessageId() {
4305
4307
  function generateEventId() {
4306
4308
  return generateId("evt");
4307
4309
  }
4310
+ function generateReplyId() {
4311
+ return generateId("rpl");
4312
+ }
4308
4313
  function generateNotificationId() {
4309
4314
  return generateId("ntf");
4310
4315
  }
4316
+ function generateAuthorshipId(author) {
4317
+ return generateId(author);
4318
+ }
4311
4319
  var init_utils = __esm({
4312
4320
  "src/shared/utils.ts"() {
4313
4321
  "use strict";
@@ -32462,7 +32470,7 @@ var require_path_is_absolute = __commonJS({
32462
32470
  var require_files = __commonJS({
32463
32471
  "node_modules/mammoth/lib/docx/files.js"(exports3) {
32464
32472
  "use strict";
32465
- var fs8 = __require("fs");
32473
+ var fs9 = __require("fs");
32466
32474
  var url = __require("url");
32467
32475
  var os2 = __require("os");
32468
32476
  var dirname3 = __require("path").dirname;
@@ -32503,7 +32511,7 @@ var require_files = __commonJS({
32503
32511
  read
32504
32512
  };
32505
32513
  }
32506
- var readFile = promises.promisify(fs8.readFile.bind(fs8));
32514
+ var readFile = promises.promisify(fs9.readFile.bind(fs9));
32507
32515
  function uriToPath(uriString, platform) {
32508
32516
  if (!platform) {
32509
32517
  platform = os2.platform();
@@ -35245,11 +35253,11 @@ var require_options_reader = __commonJS({
35245
35253
  var require_unzip = __commonJS({
35246
35254
  "node_modules/mammoth/lib/unzip.js"(exports3) {
35247
35255
  "use strict";
35248
- var fs8 = __require("fs");
35256
+ var fs9 = __require("fs");
35249
35257
  var promises = require_promises();
35250
35258
  var zipfile = require_zipfile();
35251
35259
  exports3.openZip = openZip;
35252
- var readFile = promises.promisify(fs8.readFile);
35260
+ var readFile = promises.promisify(fs9.readFile);
35253
35261
  function openZip(options) {
35254
35262
  if (options.path) {
35255
35263
  return readFile(options.path).then(zipfile.openArrayBuffer);
@@ -36360,14 +36368,14 @@ var callAll, id, isOneOf;
36360
36368
  var init_function = __esm({
36361
36369
  "node_modules/lib0/function.js"() {
36362
36370
  "use strict";
36363
- callAll = (fs8, args2, i = 0) => {
36371
+ callAll = (fs9, args2, i = 0) => {
36364
36372
  try {
36365
- for (; i < fs8.length; i++) {
36366
- fs8[i](...args2);
36373
+ for (; i < fs9.length; i++) {
36374
+ fs9[i](...args2);
36367
36375
  }
36368
36376
  } finally {
36369
- if (i < fs8.length) {
36370
- callAll(fs8, args2, i + 1);
36377
+ if (i < fs9.length) {
36378
+ callAll(fs9, args2, i + 1);
36371
36379
  }
36372
36380
  }
36373
36381
  };
@@ -38392,15 +38400,15 @@ var init_yjs = __esm({
38392
38400
  sortAndMergeDeleteSet(ds);
38393
38401
  transaction.afterState = getStateVector(transaction.doc.store);
38394
38402
  doc.emit("beforeObserverCalls", [transaction, doc]);
38395
- const fs8 = [];
38403
+ const fs9 = [];
38396
38404
  transaction.changed.forEach(
38397
- (subs, itemtype) => fs8.push(() => {
38405
+ (subs, itemtype) => fs9.push(() => {
38398
38406
  if (itemtype._item === null || !itemtype._item.deleted) {
38399
38407
  itemtype._callObserver(transaction, subs);
38400
38408
  }
38401
38409
  })
38402
38410
  );
38403
- fs8.push(() => {
38411
+ fs9.push(() => {
38404
38412
  transaction.changedParentTypes.forEach((events, type2) => {
38405
38413
  if (type2._dEH.l.length > 0 && (type2._item === null || !type2._item.deleted)) {
38406
38414
  events = events.filter(
@@ -38411,19 +38419,19 @@ var init_yjs = __esm({
38411
38419
  event._path = null;
38412
38420
  });
38413
38421
  events.sort((event1, event2) => event1.path.length - event2.path.length);
38414
- fs8.push(() => {
38422
+ fs9.push(() => {
38415
38423
  callEventHandlerListeners(type2._dEH, events, transaction);
38416
38424
  });
38417
38425
  }
38418
38426
  });
38419
- fs8.push(() => doc.emit("afterTransaction", [transaction, doc]));
38420
- fs8.push(() => {
38427
+ fs9.push(() => doc.emit("afterTransaction", [transaction, doc]));
38428
+ fs9.push(() => {
38421
38429
  if (transaction._needFormattingCleanup) {
38422
38430
  cleanupYTextAfterTransaction(transaction);
38423
38431
  }
38424
38432
  });
38425
38433
  });
38426
- callAll(fs8, []);
38434
+ callAll(fs9, []);
38427
38435
  } finally {
38428
38436
  if (doc.gc) {
38429
38437
  tryGcDeleteSet(ds, store, doc.gcFilter);
@@ -67181,6 +67189,440 @@ var init_provider = __esm({
67181
67189
  }
67182
67190
  });
67183
67191
 
67192
+ // node_modules/is-safe-filename/index.js
67193
+ function isSafeFilename(filename) {
67194
+ if (typeof filename !== "string") {
67195
+ return false;
67196
+ }
67197
+ const trimmed = filename.trim();
67198
+ return trimmed !== "" && trimmed !== "." && trimmed !== ".." && !filename.includes("/") && !filename.includes("\\") && !filename.includes("\0");
67199
+ }
67200
+ function assertSafeFilename(filename) {
67201
+ if (typeof filename !== "string") {
67202
+ throw new TypeError("Expected a string");
67203
+ }
67204
+ if (!isSafeFilename(filename)) {
67205
+ throw new Error(`Unsafe filename: ${JSON.stringify(filename)}`);
67206
+ }
67207
+ }
67208
+ var unsafeFilenameFixtures;
67209
+ var init_is_safe_filename = __esm({
67210
+ "node_modules/is-safe-filename/index.js"() {
67211
+ "use strict";
67212
+ unsafeFilenameFixtures = Object.freeze([
67213
+ "",
67214
+ " ",
67215
+ ".",
67216
+ "..",
67217
+ " .",
67218
+ ". ",
67219
+ " ..",
67220
+ ".. ",
67221
+ "../",
67222
+ "../foo",
67223
+ "foo/../bar",
67224
+ "foo/bar",
67225
+ "foo\\bar",
67226
+ "foo\0bar"
67227
+ ]);
67228
+ }
67229
+ });
67230
+
67231
+ // node_modules/env-paths/index.js
67232
+ import path3 from "path";
67233
+ import os from "os";
67234
+ import process2 from "process";
67235
+ function envPaths(name2, { suffix = "nodejs" } = {}) {
67236
+ assertSafeFilename(name2);
67237
+ if (suffix) {
67238
+ name2 += `-${suffix}`;
67239
+ }
67240
+ assertSafeFilename(name2);
67241
+ if (process2.platform === "darwin") {
67242
+ return macos(name2);
67243
+ }
67244
+ if (process2.platform === "win32") {
67245
+ return windows(name2);
67246
+ }
67247
+ return linux(name2);
67248
+ }
67249
+ var homedir, tmpdir, env2, macos, windows, linux;
67250
+ var init_env_paths = __esm({
67251
+ "node_modules/env-paths/index.js"() {
67252
+ "use strict";
67253
+ init_is_safe_filename();
67254
+ homedir = os.homedir();
67255
+ tmpdir = os.tmpdir();
67256
+ ({ env: env2 } = process2);
67257
+ macos = (name2) => {
67258
+ const library = path3.join(homedir, "Library");
67259
+ return {
67260
+ data: path3.join(library, "Application Support", name2),
67261
+ config: path3.join(library, "Preferences", name2),
67262
+ cache: path3.join(library, "Caches", name2),
67263
+ log: path3.join(library, "Logs", name2),
67264
+ temp: path3.join(tmpdir, name2)
67265
+ };
67266
+ };
67267
+ windows = (name2) => {
67268
+ const appData = env2.APPDATA || path3.join(homedir, "AppData", "Roaming");
67269
+ const localAppData = env2.LOCALAPPDATA || path3.join(homedir, "AppData", "Local");
67270
+ return {
67271
+ // Data/config/cache/log are invented by me as Windows isn't opinionated about this
67272
+ data: path3.join(localAppData, name2, "Data"),
67273
+ config: path3.join(appData, name2, "Config"),
67274
+ cache: path3.join(localAppData, name2, "Cache"),
67275
+ log: path3.join(localAppData, name2, "Log"),
67276
+ temp: path3.join(tmpdir, name2)
67277
+ };
67278
+ };
67279
+ linux = (name2) => {
67280
+ const username = path3.basename(homedir);
67281
+ return {
67282
+ data: path3.join(env2.XDG_DATA_HOME || path3.join(homedir, ".local", "share"), name2),
67283
+ config: path3.join(env2.XDG_CONFIG_HOME || path3.join(homedir, ".config"), name2),
67284
+ cache: path3.join(env2.XDG_CACHE_HOME || path3.join(homedir, ".cache"), name2),
67285
+ // https://wiki.debian.org/XDGBaseDirectorySpecification#state
67286
+ log: path3.join(env2.XDG_STATE_HOME || path3.join(homedir, ".local", "state"), name2),
67287
+ temp: path3.join(tmpdir, username, name2)
67288
+ };
67289
+ };
67290
+ }
67291
+ });
67292
+
67293
+ // src/server/platform.ts
67294
+ import { execSync } from "child_process";
67295
+ import net from "net";
67296
+ import path4 from "path";
67297
+ function freePort(port) {
67298
+ try {
67299
+ if (process.platform === "win32") {
67300
+ freePortWindows(port);
67301
+ } else {
67302
+ freePortUnix(port);
67303
+ }
67304
+ } catch (err) {
67305
+ console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
67306
+ }
67307
+ }
67308
+ async function waitForPort(port, timeoutMs = 5e3) {
67309
+ const start = Date.now();
67310
+ while (Date.now() - start < timeoutMs) {
67311
+ if (await tryBind(port)) return;
67312
+ await new Promise((r) => setTimeout(r, 200));
67313
+ }
67314
+ throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
67315
+ }
67316
+ function tryBind(port) {
67317
+ return new Promise((resolve2, reject2) => {
67318
+ const srv = net.createServer();
67319
+ srv.once("error", (err) => {
67320
+ srv.close(() => {
67321
+ if (err.code === "EADDRINUSE") {
67322
+ resolve2(false);
67323
+ } else {
67324
+ reject2(err);
67325
+ }
67326
+ });
67327
+ });
67328
+ srv.listen(port, "127.0.0.1", () => {
67329
+ srv.close(() => resolve2(true));
67330
+ });
67331
+ });
67332
+ }
67333
+ function parseLsofPids(output) {
67334
+ return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
67335
+ }
67336
+ function parseSsPid(output) {
67337
+ const match = output.match(/pid=(\d+)/);
67338
+ return match ? parseInt(match[1], 10) : null;
67339
+ }
67340
+ function freePortWindows(port) {
67341
+ const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
67342
+ encoding: "utf-8",
67343
+ stdio: ["pipe", "pipe", "ignore"]
67344
+ });
67345
+ const pid = out.trim().split(/\s+/).at(-1);
67346
+ if (pid && /^\d+$/.test(pid)) {
67347
+ execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
67348
+ console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
67349
+ }
67350
+ }
67351
+ function freePortUnix(port) {
67352
+ let pids = [];
67353
+ try {
67354
+ const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
67355
+ encoding: "utf-8",
67356
+ stdio: ["pipe", "pipe", "ignore"]
67357
+ });
67358
+ pids = parseLsofPids(out);
67359
+ } catch {
67360
+ try {
67361
+ const out = execSync(`ss -tlnp sport = :${port}`, {
67362
+ encoding: "utf-8",
67363
+ stdio: ["pipe", "pipe", "ignore"]
67364
+ });
67365
+ const pid = parseSsPid(out);
67366
+ if (pid) pids = [pid];
67367
+ } catch {
67368
+ }
67369
+ }
67370
+ for (const pid of pids) {
67371
+ try {
67372
+ process.kill(pid, "SIGKILL");
67373
+ console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
67374
+ } catch {
67375
+ }
67376
+ }
67377
+ }
67378
+ var paths, SESSION_DIR, LAST_SEEN_VERSION_FILE;
67379
+ var init_platform = __esm({
67380
+ "src/server/platform.ts"() {
67381
+ "use strict";
67382
+ init_env_paths();
67383
+ paths = envPaths("tandem", { suffix: "" });
67384
+ SESSION_DIR = path4.join(paths.data, "sessions");
67385
+ LAST_SEEN_VERSION_FILE = path4.join(paths.data, "last-seen-version");
67386
+ }
67387
+ });
67388
+
67389
+ // src/server/session/manager.ts
67390
+ import fs from "fs/promises";
67391
+ import path5 from "path";
67392
+ async function atomicWrite(sessionPath, content3) {
67393
+ const tmpPath = `${sessionPath}.tmp`;
67394
+ await fs.writeFile(tmpPath, content3, "utf-8");
67395
+ for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
67396
+ try {
67397
+ await fs.rename(tmpPath, sessionPath);
67398
+ return;
67399
+ } catch (err) {
67400
+ const code3 = err.code;
67401
+ if ((code3 === "EPERM" || code3 === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
67402
+ await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
67403
+ continue;
67404
+ }
67405
+ await fs.unlink(tmpPath).catch(() => {
67406
+ });
67407
+ throw err;
67408
+ }
67409
+ }
67410
+ }
67411
+ function sessionKey(filePath) {
67412
+ return encodeURIComponent(filePath.replace(/\\/g, "/"));
67413
+ }
67414
+ async function saveSession(filePath, format, doc) {
67415
+ const key = sessionKey(filePath);
67416
+ let sourceFileMtime = 0;
67417
+ if (!filePath.startsWith("upload://")) {
67418
+ try {
67419
+ const stat = await fs.stat(filePath);
67420
+ sourceFileMtime = stat.mtimeMs;
67421
+ } catch {
67422
+ }
67423
+ }
67424
+ const state = encodeStateAsUpdate(doc);
67425
+ const ydocState = Buffer.from(state).toString("base64");
67426
+ const data = {
67427
+ filePath,
67428
+ format,
67429
+ ydocState,
67430
+ sourceFileMtime,
67431
+ lastAccessed: Date.now()
67432
+ };
67433
+ if (!sessionDirReady) {
67434
+ await fs.mkdir(SESSION_DIR, { recursive: true });
67435
+ sessionDirReady = true;
67436
+ }
67437
+ const sessionPath = path5.join(SESSION_DIR, `${key}.json`);
67438
+ await atomicWrite(sessionPath, JSON.stringify(data));
67439
+ }
67440
+ async function loadSession(filePath) {
67441
+ const key = sessionKey(filePath);
67442
+ const sessionPath = path5.join(SESSION_DIR, `${key}.json`);
67443
+ try {
67444
+ const content3 = await fs.readFile(sessionPath, "utf-8");
67445
+ return JSON.parse(content3);
67446
+ } catch (err) {
67447
+ const code3 = err.code;
67448
+ if (code3 === "ENOENT") return null;
67449
+ if (err instanceof SyntaxError) {
67450
+ console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
67451
+ await fs.unlink(sessionPath).catch((unlinkErr) => {
67452
+ console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
67453
+ });
67454
+ return null;
67455
+ }
67456
+ console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
67457
+ return null;
67458
+ }
67459
+ }
67460
+ function restoreYDoc(doc, session) {
67461
+ const state = Buffer.from(session.ydocState, "base64");
67462
+ applyUpdate(doc, new Uint8Array(state));
67463
+ }
67464
+ async function sourceFileChanged(session) {
67465
+ if (session.filePath.startsWith("upload://")) return false;
67466
+ try {
67467
+ const stat = await fs.stat(session.filePath);
67468
+ return stat.mtimeMs !== session.sourceFileMtime;
67469
+ } catch {
67470
+ return true;
67471
+ }
67472
+ }
67473
+ async function deleteSession(filePath) {
67474
+ const key = sessionKey(filePath);
67475
+ const sessionPath = path5.join(SESSION_DIR, `${key}.json`);
67476
+ try {
67477
+ await fs.unlink(sessionPath);
67478
+ } catch (err) {
67479
+ const code3 = err.code;
67480
+ if (code3 !== "ENOENT") {
67481
+ console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
67482
+ }
67483
+ }
67484
+ }
67485
+ async function saveCtrlSession(doc) {
67486
+ if (!sessionDirReady) {
67487
+ await fs.mkdir(SESSION_DIR, { recursive: true });
67488
+ sessionDirReady = true;
67489
+ }
67490
+ const chatMap = doc.getMap(Y_MAP_CHAT);
67491
+ const entries = [];
67492
+ chatMap.forEach((value, key) => {
67493
+ const msg = value;
67494
+ entries.push({ id: key, timestamp: msg.timestamp });
67495
+ });
67496
+ if (entries.length > 200) {
67497
+ entries.sort((a, b) => a.timestamp - b.timestamp);
67498
+ const toDelete = entries.slice(0, entries.length - 200);
67499
+ doc.transact(() => {
67500
+ for (const entry of toDelete) {
67501
+ chatMap.delete(entry.id);
67502
+ }
67503
+ }, MCP_ORIGIN);
67504
+ }
67505
+ const state = encodeStateAsUpdate(doc);
67506
+ const ydocState = Buffer.from(state).toString("base64");
67507
+ const data = { ydocState, lastAccessed: Date.now() };
67508
+ const sessionPath = path5.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
67509
+ await atomicWrite(sessionPath, JSON.stringify(data));
67510
+ }
67511
+ async function loadCtrlSession() {
67512
+ const sessionPath = path5.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
67513
+ try {
67514
+ const content3 = await fs.readFile(sessionPath, "utf-8");
67515
+ const data = JSON.parse(content3);
67516
+ return data.ydocState ?? null;
67517
+ } catch (err) {
67518
+ const code3 = err.code;
67519
+ if (code3 === "ENOENT") return null;
67520
+ if (err instanceof SyntaxError) {
67521
+ console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
67522
+ await fs.unlink(sessionPath).catch((unlinkErr) => {
67523
+ console.error(
67524
+ `[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
67525
+ unlinkErr
67526
+ );
67527
+ });
67528
+ return null;
67529
+ }
67530
+ console.error(`[Tandem] Failed to read ctrl session:`, err);
67531
+ return null;
67532
+ }
67533
+ }
67534
+ function restoreCtrlDoc(doc, base64State) {
67535
+ const state = Buffer.from(base64State, "base64");
67536
+ applyUpdate(doc, new Uint8Array(state));
67537
+ }
67538
+ async function listSessionFilePaths() {
67539
+ try {
67540
+ await fs.mkdir(SESSION_DIR, { recursive: true });
67541
+ const files2 = await fs.readdir(SESSION_DIR);
67542
+ const results = [];
67543
+ for (const file of files2) {
67544
+ if (!file.endsWith(".json")) continue;
67545
+ if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
67546
+ try {
67547
+ const raw = await fs.readFile(path5.join(SESSION_DIR, file), "utf-8");
67548
+ const data = JSON.parse(raw);
67549
+ if (!data.filePath || data.filePath.startsWith("upload://")) continue;
67550
+ results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
67551
+ } catch (err) {
67552
+ console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
67553
+ }
67554
+ }
67555
+ results.sort((a, b) => b.lastAccessed - a.lastAccessed);
67556
+ return results;
67557
+ } catch (err) {
67558
+ console.error("[Tandem] Failed to read session directory:", err);
67559
+ return [];
67560
+ }
67561
+ }
67562
+ async function cleanupSessions() {
67563
+ let cleaned = 0;
67564
+ let files2;
67565
+ try {
67566
+ files2 = await fs.readdir(SESSION_DIR);
67567
+ } catch (err) {
67568
+ if (err.code === "ENOENT") return 0;
67569
+ console.error("[Tandem] Failed to read session directory:", err);
67570
+ return 0;
67571
+ }
67572
+ const now = Date.now();
67573
+ for (const file of files2) {
67574
+ try {
67575
+ const filePath = path5.join(SESSION_DIR, file);
67576
+ const stat = await fs.stat(filePath);
67577
+ if (now - stat.mtimeMs > SESSION_MAX_AGE) {
67578
+ await fs.unlink(filePath);
67579
+ cleaned++;
67580
+ }
67581
+ } catch (err) {
67582
+ console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
67583
+ }
67584
+ }
67585
+ return cleaned;
67586
+ }
67587
+ function isAutoSaveRunning() {
67588
+ return autoSaveTimer !== null;
67589
+ }
67590
+ function startAutoSave(callback) {
67591
+ stopAutoSave();
67592
+ autoSaveCallback = callback;
67593
+ autoSaveTimer = setInterval(async () => {
67594
+ try {
67595
+ await autoSaveCallback?.();
67596
+ } catch (err) {
67597
+ console.error("[Tandem] Auto-save failed:", err);
67598
+ }
67599
+ }, AUTO_SAVE_INTERVAL);
67600
+ }
67601
+ function stopAutoSave() {
67602
+ if (autoSaveTimer) {
67603
+ clearInterval(autoSaveTimer);
67604
+ autoSaveTimer = null;
67605
+ }
67606
+ autoSaveCallback = null;
67607
+ }
67608
+ var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
67609
+ var init_manager = __esm({
67610
+ "src/server/session/manager.ts"() {
67611
+ "use strict";
67612
+ init_yjs();
67613
+ init_constants();
67614
+ init_queue();
67615
+ init_platform();
67616
+ AUTO_SAVE_INTERVAL = 60 * 1e3;
67617
+ RENAME_MAX_RETRIES = 3;
67618
+ RENAME_RETRY_BASE_MS = 50;
67619
+ sessionDirReady = false;
67620
+ CTRL_SESSION_KEY = CTRL_ROOM;
67621
+ autoSaveTimer = null;
67622
+ autoSaveCallback = null;
67623
+ }
67624
+ });
67625
+
67184
67626
  // src/server/file-io/docx-walker.ts
67185
67627
  function isElement3(node2) {
67186
67628
  return node2.type === "tag";
@@ -67960,23 +68402,23 @@ var init_docx_apply = __esm({
67960
68402
  });
67961
68403
 
67962
68404
  // src/server/file-io/index.ts
67963
- import fs from "fs/promises";
67964
- import path3 from "path";
68405
+ import fs2 from "fs/promises";
68406
+ import path6 from "path";
67965
68407
  function getAdapter(format) {
67966
68408
  return adapters[format] ?? plaintextAdapter;
67967
68409
  }
67968
- async function atomicWrite(filePath, content3) {
67969
- const tempPath = path3.join(path3.dirname(filePath), `.tandem-tmp-${Date.now()}`);
67970
- await fs.writeFile(tempPath, content3, "utf-8");
67971
- await fs.rename(tempPath, filePath);
68410
+ async function atomicWrite2(filePath, content3) {
68411
+ const tempPath = path6.join(path6.dirname(filePath), `.tandem-tmp-${Date.now()}`);
68412
+ await fs2.writeFile(tempPath, content3, "utf-8");
68413
+ await fs2.rename(tempPath, filePath);
67972
68414
  }
67973
68415
  async function atomicWriteBuffer(filePath, content3) {
67974
- const tempPath = path3.join(path3.dirname(filePath), `.tandem-tmp-${Date.now()}`);
67975
- await fs.writeFile(tempPath, content3);
68416
+ const tempPath = path6.join(path6.dirname(filePath), `.tandem-tmp-${Date.now()}`);
68417
+ await fs2.writeFile(tempPath, content3);
67976
68418
  try {
67977
- await fs.rename(tempPath, filePath);
68419
+ await fs2.rename(tempPath, filePath);
67978
68420
  } catch (err) {
67979
- await fs.unlink(tempPath).catch(() => {
68421
+ await fs2.unlink(tempPath).catch(() => {
67980
68422
  });
67981
68423
  throw err;
67982
68424
  }
@@ -68040,12 +68482,12 @@ var init_file_io = __esm({
68040
68482
  });
68041
68483
 
68042
68484
  // src/server/file-watcher.ts
68043
- import fs2 from "fs";
68485
+ import fs3 from "fs";
68044
68486
  function watchFile(filePath, onChanged) {
68045
68487
  if (watched.has(filePath)) return;
68046
68488
  let watcher;
68047
68489
  try {
68048
- watcher = fs2.watch(filePath, (eventType) => {
68490
+ watcher = fs3.watch(filePath, (eventType) => {
68049
68491
  if (eventType !== "change") return;
68050
68492
  const entry = watched.get(filePath);
68051
68493
  if (!entry) return;
@@ -68107,440 +68549,6 @@ var init_file_watcher = __esm({
68107
68549
  }
68108
68550
  });
68109
68551
 
68110
- // node_modules/is-safe-filename/index.js
68111
- function isSafeFilename(filename) {
68112
- if (typeof filename !== "string") {
68113
- return false;
68114
- }
68115
- const trimmed = filename.trim();
68116
- return trimmed !== "" && trimmed !== "." && trimmed !== ".." && !filename.includes("/") && !filename.includes("\\") && !filename.includes("\0");
68117
- }
68118
- function assertSafeFilename(filename) {
68119
- if (typeof filename !== "string") {
68120
- throw new TypeError("Expected a string");
68121
- }
68122
- if (!isSafeFilename(filename)) {
68123
- throw new Error(`Unsafe filename: ${JSON.stringify(filename)}`);
68124
- }
68125
- }
68126
- var unsafeFilenameFixtures;
68127
- var init_is_safe_filename = __esm({
68128
- "node_modules/is-safe-filename/index.js"() {
68129
- "use strict";
68130
- unsafeFilenameFixtures = Object.freeze([
68131
- "",
68132
- " ",
68133
- ".",
68134
- "..",
68135
- " .",
68136
- ". ",
68137
- " ..",
68138
- ".. ",
68139
- "../",
68140
- "../foo",
68141
- "foo/../bar",
68142
- "foo/bar",
68143
- "foo\\bar",
68144
- "foo\0bar"
68145
- ]);
68146
- }
68147
- });
68148
-
68149
- // node_modules/env-paths/index.js
68150
- import path4 from "path";
68151
- import os from "os";
68152
- import process2 from "process";
68153
- function envPaths(name2, { suffix = "nodejs" } = {}) {
68154
- assertSafeFilename(name2);
68155
- if (suffix) {
68156
- name2 += `-${suffix}`;
68157
- }
68158
- assertSafeFilename(name2);
68159
- if (process2.platform === "darwin") {
68160
- return macos(name2);
68161
- }
68162
- if (process2.platform === "win32") {
68163
- return windows(name2);
68164
- }
68165
- return linux(name2);
68166
- }
68167
- var homedir, tmpdir, env2, macos, windows, linux;
68168
- var init_env_paths = __esm({
68169
- "node_modules/env-paths/index.js"() {
68170
- "use strict";
68171
- init_is_safe_filename();
68172
- homedir = os.homedir();
68173
- tmpdir = os.tmpdir();
68174
- ({ env: env2 } = process2);
68175
- macos = (name2) => {
68176
- const library = path4.join(homedir, "Library");
68177
- return {
68178
- data: path4.join(library, "Application Support", name2),
68179
- config: path4.join(library, "Preferences", name2),
68180
- cache: path4.join(library, "Caches", name2),
68181
- log: path4.join(library, "Logs", name2),
68182
- temp: path4.join(tmpdir, name2)
68183
- };
68184
- };
68185
- windows = (name2) => {
68186
- const appData = env2.APPDATA || path4.join(homedir, "AppData", "Roaming");
68187
- const localAppData = env2.LOCALAPPDATA || path4.join(homedir, "AppData", "Local");
68188
- return {
68189
- // Data/config/cache/log are invented by me as Windows isn't opinionated about this
68190
- data: path4.join(localAppData, name2, "Data"),
68191
- config: path4.join(appData, name2, "Config"),
68192
- cache: path4.join(localAppData, name2, "Cache"),
68193
- log: path4.join(localAppData, name2, "Log"),
68194
- temp: path4.join(tmpdir, name2)
68195
- };
68196
- };
68197
- linux = (name2) => {
68198
- const username = path4.basename(homedir);
68199
- return {
68200
- data: path4.join(env2.XDG_DATA_HOME || path4.join(homedir, ".local", "share"), name2),
68201
- config: path4.join(env2.XDG_CONFIG_HOME || path4.join(homedir, ".config"), name2),
68202
- cache: path4.join(env2.XDG_CACHE_HOME || path4.join(homedir, ".cache"), name2),
68203
- // https://wiki.debian.org/XDGBaseDirectorySpecification#state
68204
- log: path4.join(env2.XDG_STATE_HOME || path4.join(homedir, ".local", "state"), name2),
68205
- temp: path4.join(tmpdir, username, name2)
68206
- };
68207
- };
68208
- }
68209
- });
68210
-
68211
- // src/server/platform.ts
68212
- import { execSync } from "child_process";
68213
- import net from "net";
68214
- import path5 from "path";
68215
- function freePort(port) {
68216
- try {
68217
- if (process.platform === "win32") {
68218
- freePortWindows(port);
68219
- } else {
68220
- freePortUnix(port);
68221
- }
68222
- } catch (err) {
68223
- console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
68224
- }
68225
- }
68226
- async function waitForPort(port, timeoutMs = 5e3) {
68227
- const start = Date.now();
68228
- while (Date.now() - start < timeoutMs) {
68229
- if (await tryBind(port)) return;
68230
- await new Promise((r) => setTimeout(r, 200));
68231
- }
68232
- throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
68233
- }
68234
- function tryBind(port) {
68235
- return new Promise((resolve2, reject2) => {
68236
- const srv = net.createServer();
68237
- srv.once("error", (err) => {
68238
- srv.close(() => {
68239
- if (err.code === "EADDRINUSE") {
68240
- resolve2(false);
68241
- } else {
68242
- reject2(err);
68243
- }
68244
- });
68245
- });
68246
- srv.listen(port, "127.0.0.1", () => {
68247
- srv.close(() => resolve2(true));
68248
- });
68249
- });
68250
- }
68251
- function parseLsofPids(output) {
68252
- return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
68253
- }
68254
- function parseSsPid(output) {
68255
- const match = output.match(/pid=(\d+)/);
68256
- return match ? parseInt(match[1], 10) : null;
68257
- }
68258
- function freePortWindows(port) {
68259
- const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
68260
- encoding: "utf-8",
68261
- stdio: ["pipe", "pipe", "ignore"]
68262
- });
68263
- const pid = out.trim().split(/\s+/).at(-1);
68264
- if (pid && /^\d+$/.test(pid)) {
68265
- execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
68266
- console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
68267
- }
68268
- }
68269
- function freePortUnix(port) {
68270
- let pids = [];
68271
- try {
68272
- const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
68273
- encoding: "utf-8",
68274
- stdio: ["pipe", "pipe", "ignore"]
68275
- });
68276
- pids = parseLsofPids(out);
68277
- } catch {
68278
- try {
68279
- const out = execSync(`ss -tlnp sport = :${port}`, {
68280
- encoding: "utf-8",
68281
- stdio: ["pipe", "pipe", "ignore"]
68282
- });
68283
- const pid = parseSsPid(out);
68284
- if (pid) pids = [pid];
68285
- } catch {
68286
- }
68287
- }
68288
- for (const pid of pids) {
68289
- try {
68290
- process.kill(pid, "SIGKILL");
68291
- console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
68292
- } catch {
68293
- }
68294
- }
68295
- }
68296
- var paths, SESSION_DIR, LAST_SEEN_VERSION_FILE;
68297
- var init_platform = __esm({
68298
- "src/server/platform.ts"() {
68299
- "use strict";
68300
- init_env_paths();
68301
- paths = envPaths("tandem", { suffix: "" });
68302
- SESSION_DIR = path5.join(paths.data, "sessions");
68303
- LAST_SEEN_VERSION_FILE = path5.join(paths.data, "last-seen-version");
68304
- }
68305
- });
68306
-
68307
- // src/server/session/manager.ts
68308
- import fs3 from "fs/promises";
68309
- import path6 from "path";
68310
- async function atomicWrite2(sessionPath, content3) {
68311
- const tmpPath = `${sessionPath}.tmp`;
68312
- await fs3.writeFile(tmpPath, content3, "utf-8");
68313
- for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
68314
- try {
68315
- await fs3.rename(tmpPath, sessionPath);
68316
- return;
68317
- } catch (err) {
68318
- const code3 = err.code;
68319
- if ((code3 === "EPERM" || code3 === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
68320
- await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
68321
- continue;
68322
- }
68323
- await fs3.unlink(tmpPath).catch(() => {
68324
- });
68325
- throw err;
68326
- }
68327
- }
68328
- }
68329
- function sessionKey(filePath) {
68330
- return encodeURIComponent(filePath.replace(/\\/g, "/"));
68331
- }
68332
- async function saveSession(filePath, format, doc) {
68333
- const key = sessionKey(filePath);
68334
- let sourceFileMtime = 0;
68335
- if (!filePath.startsWith("upload://")) {
68336
- try {
68337
- const stat = await fs3.stat(filePath);
68338
- sourceFileMtime = stat.mtimeMs;
68339
- } catch {
68340
- }
68341
- }
68342
- const state = encodeStateAsUpdate(doc);
68343
- const ydocState = Buffer.from(state).toString("base64");
68344
- const data = {
68345
- filePath,
68346
- format,
68347
- ydocState,
68348
- sourceFileMtime,
68349
- lastAccessed: Date.now()
68350
- };
68351
- if (!sessionDirReady) {
68352
- await fs3.mkdir(SESSION_DIR, { recursive: true });
68353
- sessionDirReady = true;
68354
- }
68355
- const sessionPath = path6.join(SESSION_DIR, `${key}.json`);
68356
- await atomicWrite2(sessionPath, JSON.stringify(data));
68357
- }
68358
- async function loadSession(filePath) {
68359
- const key = sessionKey(filePath);
68360
- const sessionPath = path6.join(SESSION_DIR, `${key}.json`);
68361
- try {
68362
- const content3 = await fs3.readFile(sessionPath, "utf-8");
68363
- return JSON.parse(content3);
68364
- } catch (err) {
68365
- const code3 = err.code;
68366
- if (code3 === "ENOENT") return null;
68367
- if (err instanceof SyntaxError) {
68368
- console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
68369
- await fs3.unlink(sessionPath).catch((unlinkErr) => {
68370
- console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
68371
- });
68372
- return null;
68373
- }
68374
- console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
68375
- return null;
68376
- }
68377
- }
68378
- function restoreYDoc(doc, session) {
68379
- const state = Buffer.from(session.ydocState, "base64");
68380
- applyUpdate(doc, new Uint8Array(state));
68381
- }
68382
- async function sourceFileChanged(session) {
68383
- if (session.filePath.startsWith("upload://")) return false;
68384
- try {
68385
- const stat = await fs3.stat(session.filePath);
68386
- return stat.mtimeMs !== session.sourceFileMtime;
68387
- } catch {
68388
- return true;
68389
- }
68390
- }
68391
- async function deleteSession(filePath) {
68392
- const key = sessionKey(filePath);
68393
- const sessionPath = path6.join(SESSION_DIR, `${key}.json`);
68394
- try {
68395
- await fs3.unlink(sessionPath);
68396
- } catch (err) {
68397
- const code3 = err.code;
68398
- if (code3 !== "ENOENT") {
68399
- console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
68400
- }
68401
- }
68402
- }
68403
- async function saveCtrlSession(doc) {
68404
- if (!sessionDirReady) {
68405
- await fs3.mkdir(SESSION_DIR, { recursive: true });
68406
- sessionDirReady = true;
68407
- }
68408
- const chatMap = doc.getMap(Y_MAP_CHAT);
68409
- const entries = [];
68410
- chatMap.forEach((value, key) => {
68411
- const msg = value;
68412
- entries.push({ id: key, timestamp: msg.timestamp });
68413
- });
68414
- if (entries.length > 200) {
68415
- entries.sort((a, b) => a.timestamp - b.timestamp);
68416
- const toDelete = entries.slice(0, entries.length - 200);
68417
- doc.transact(() => {
68418
- for (const entry of toDelete) {
68419
- chatMap.delete(entry.id);
68420
- }
68421
- }, MCP_ORIGIN);
68422
- }
68423
- const state = encodeStateAsUpdate(doc);
68424
- const ydocState = Buffer.from(state).toString("base64");
68425
- const data = { ydocState, lastAccessed: Date.now() };
68426
- const sessionPath = path6.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
68427
- await atomicWrite2(sessionPath, JSON.stringify(data));
68428
- }
68429
- async function loadCtrlSession() {
68430
- const sessionPath = path6.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
68431
- try {
68432
- const content3 = await fs3.readFile(sessionPath, "utf-8");
68433
- const data = JSON.parse(content3);
68434
- return data.ydocState ?? null;
68435
- } catch (err) {
68436
- const code3 = err.code;
68437
- if (code3 === "ENOENT") return null;
68438
- if (err instanceof SyntaxError) {
68439
- console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
68440
- await fs3.unlink(sessionPath).catch((unlinkErr) => {
68441
- console.error(
68442
- `[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
68443
- unlinkErr
68444
- );
68445
- });
68446
- return null;
68447
- }
68448
- console.error(`[Tandem] Failed to read ctrl session:`, err);
68449
- return null;
68450
- }
68451
- }
68452
- function restoreCtrlDoc(doc, base64State) {
68453
- const state = Buffer.from(base64State, "base64");
68454
- applyUpdate(doc, new Uint8Array(state));
68455
- }
68456
- async function listSessionFilePaths() {
68457
- try {
68458
- await fs3.mkdir(SESSION_DIR, { recursive: true });
68459
- const files2 = await fs3.readdir(SESSION_DIR);
68460
- const results = [];
68461
- for (const file of files2) {
68462
- if (!file.endsWith(".json")) continue;
68463
- if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
68464
- try {
68465
- const raw = await fs3.readFile(path6.join(SESSION_DIR, file), "utf-8");
68466
- const data = JSON.parse(raw);
68467
- if (!data.filePath || data.filePath.startsWith("upload://")) continue;
68468
- results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
68469
- } catch (err) {
68470
- console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
68471
- }
68472
- }
68473
- results.sort((a, b) => b.lastAccessed - a.lastAccessed);
68474
- return results;
68475
- } catch (err) {
68476
- console.error("[Tandem] Failed to read session directory:", err);
68477
- return [];
68478
- }
68479
- }
68480
- async function cleanupSessions() {
68481
- let cleaned = 0;
68482
- let files2;
68483
- try {
68484
- files2 = await fs3.readdir(SESSION_DIR);
68485
- } catch (err) {
68486
- if (err.code === "ENOENT") return 0;
68487
- console.error("[Tandem] Failed to read session directory:", err);
68488
- return 0;
68489
- }
68490
- const now = Date.now();
68491
- for (const file of files2) {
68492
- try {
68493
- const filePath = path6.join(SESSION_DIR, file);
68494
- const stat = await fs3.stat(filePath);
68495
- if (now - stat.mtimeMs > SESSION_MAX_AGE) {
68496
- await fs3.unlink(filePath);
68497
- cleaned++;
68498
- }
68499
- } catch (err) {
68500
- console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
68501
- }
68502
- }
68503
- return cleaned;
68504
- }
68505
- function isAutoSaveRunning() {
68506
- return autoSaveTimer !== null;
68507
- }
68508
- function startAutoSave(callback) {
68509
- stopAutoSave();
68510
- autoSaveCallback = callback;
68511
- autoSaveTimer = setInterval(async () => {
68512
- try {
68513
- await autoSaveCallback?.();
68514
- } catch (err) {
68515
- console.error("[Tandem] Auto-save failed:", err);
68516
- }
68517
- }, AUTO_SAVE_INTERVAL);
68518
- }
68519
- function stopAutoSave() {
68520
- if (autoSaveTimer) {
68521
- clearInterval(autoSaveTimer);
68522
- autoSaveTimer = null;
68523
- }
68524
- autoSaveCallback = null;
68525
- }
68526
- var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
68527
- var init_manager = __esm({
68528
- "src/server/session/manager.ts"() {
68529
- "use strict";
68530
- init_yjs();
68531
- init_constants();
68532
- init_queue();
68533
- init_platform();
68534
- AUTO_SAVE_INTERVAL = 60 * 1e3;
68535
- RENAME_MAX_RETRIES = 3;
68536
- RENAME_RETRY_BASE_MS = 50;
68537
- sessionDirReady = false;
68538
- CTRL_SESSION_KEY = CTRL_ROOM;
68539
- autoSaveTimer = null;
68540
- autoSaveCallback = null;
68541
- }
68542
- });
68543
-
68544
68552
  // src/server/mcp/file-opener.ts
68545
68553
  var file_opener_exports = {};
68546
68554
  __export(file_opener_exports, {
@@ -68654,7 +68662,7 @@ async function openFileByPath(filePath, options) {
68654
68662
  addDoc(id2, { id: id2, filePath: resolved, format, readOnly, source: "file" });
68655
68663
  setActiveDocId(id2);
68656
68664
  writeDocMeta(doc, id2, fileName, format, readOnly);
68657
- initSavedBaseline(doc);
68665
+ await initSavedBaseline(doc, resolved);
68658
68666
  broadcastOpenDocs();
68659
68667
  ensureAutoSave();
68660
68668
  if (format !== "docx") {
@@ -68697,7 +68705,7 @@ async function openFileFromContent(fileName, content3) {
68697
68705
  addDoc(id2, { id: id2, filePath: syntheticPath, format, readOnly, source: "upload" });
68698
68706
  setActiveDocId(id2);
68699
68707
  writeDocMeta(doc, id2, fileName, format, readOnly);
68700
- initSavedBaseline(doc);
68708
+ await initSavedBaseline(doc);
68701
68709
  broadcastOpenDocs();
68702
68710
  ensureAutoSave();
68703
68711
  return buildResult(doc, {
@@ -68735,6 +68743,8 @@ async function clearAndReload(id2, doc, filePath, format, existing) {
68735
68743
  doc.transact(() => {
68736
68744
  const annotations = doc.getMap(Y_MAP_ANNOTATIONS);
68737
68745
  annotations.forEach((_3, k) => annotations.delete(k));
68746
+ const annotationReplies = doc.getMap(Y_MAP_ANNOTATION_REPLIES);
68747
+ annotationReplies.forEach((_3, k) => annotationReplies.delete(k));
68738
68748
  const awareness = doc.getMap(Y_MAP_AWARENESS);
68739
68749
  awareness.forEach((_3, k) => awareness.delete(k));
68740
68750
  const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
@@ -68769,9 +68779,14 @@ async function clearAndReload(id2, doc, filePath, format, existing) {
68769
68779
  });
68770
68780
  console.error(`[Tandem] clearAndReload: complete for ${id2}`);
68771
68781
  }
68772
- function initSavedBaseline(doc) {
68782
+ async function initSavedBaseline(doc, filePath) {
68783
+ let baseline = Date.now();
68784
+ if (filePath) {
68785
+ const stat = await fs4.stat(filePath).catch(() => null);
68786
+ if (stat) baseline = stat.mtimeMs;
68787
+ }
68773
68788
  const meta2 = doc.getMap(Y_MAP_DOCUMENT_META);
68774
- doc.transact(() => meta2.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
68789
+ doc.transact(() => meta2.set(Y_MAP_SAVED_AT_VERSION, baseline), MCP_ORIGIN);
68775
68790
  }
68776
68791
  function writeDocMeta(doc, id2, fileName, format, readOnly) {
68777
68792
  const meta2 = doc.getMap(Y_MAP_DOCUMENT_META);
@@ -68894,6 +68909,7 @@ function ensureAutoSave() {
68894
68909
  const d = getOrCreateDocument(docId);
68895
68910
  await saveSession(state.filePath, state.format, d);
68896
68911
  }
68912
+ await autoSaveAllToDisk();
68897
68913
  });
68898
68914
  }
68899
68915
  var reloadInProgress;
@@ -68922,6 +68938,7 @@ var init_file_opener = __esm({
68922
68938
 
68923
68939
  // src/server/mcp/document-service.ts
68924
68940
  import { randomUUID as randomUUID3 } from "crypto";
68941
+ import fs5 from "fs/promises";
68925
68942
  import path8 from "path";
68926
68943
  function getOpenDocs() {
68927
68944
  return openDocs;
@@ -68960,6 +68977,86 @@ function requireDocument(documentId) {
68960
68977
  docId: current.id
68961
68978
  };
68962
68979
  }
68980
+ async function saveDocumentToDisk(docId, source = "auto-save") {
68981
+ const docState = openDocs.get(docId);
68982
+ if (!docState) return { status: "skipped", reason: "Document not open" };
68983
+ if (docState.source === "upload") {
68984
+ return { status: "skipped", reason: "Upload-only document" };
68985
+ }
68986
+ if (docState.readOnly) {
68987
+ return { status: "skipped", reason: "Read-only document" };
68988
+ }
68989
+ if (!AUTO_SAVE_FORMATS.has(docState.format)) {
68990
+ return { status: "skipped", reason: `Format '${docState.format}' not eligible for disk save` };
68991
+ }
68992
+ const adapter = getAdapter(docState.format);
68993
+ if (!adapter.canSave) {
68994
+ return { status: "skipped", reason: "Adapter cannot save" };
68995
+ }
68996
+ if (savingDocs.has(docId)) {
68997
+ return { status: "skipped", reason: "Save already in progress" };
68998
+ }
68999
+ savingDocs.add(docId);
69000
+ try {
69001
+ try {
69002
+ const stat = await fs5.stat(docState.filePath);
69003
+ const meta3 = getOrCreateDocument(docId).getMap(Y_MAP_DOCUMENT_META);
69004
+ const lastSavedAt = meta3.get(Y_MAP_SAVED_AT_VERSION);
69005
+ if (lastSavedAt && stat.mtimeMs > lastSavedAt + 1e3) {
69006
+ return { status: "skipped", reason: "File modified externally" };
69007
+ }
69008
+ } catch (err) {
69009
+ const code3 = err.code;
69010
+ if (code3 === "ENOENT") {
69011
+ return { status: "skipped", reason: "Source file no longer exists" };
69012
+ }
69013
+ console.error(`[AutoSave] Unexpected stat error for ${docState.filePath}:`, err);
69014
+ return { status: "skipped", reason: `Cannot verify file state: ${code3}` };
69015
+ }
69016
+ const doc = getOrCreateDocument(docId);
69017
+ const output = adapter.save(doc);
69018
+ if (output == null) {
69019
+ return { status: "skipped", reason: "Adapter returned null" };
69020
+ }
69021
+ suppressNextChange(docState.filePath);
69022
+ await atomicWrite2(docState.filePath, output);
69023
+ await saveSession(docState.filePath, docState.format, doc);
69024
+ const meta2 = doc.getMap(Y_MAP_DOCUMENT_META);
69025
+ doc.transact(() => meta2.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
69026
+ return { status: "saved" };
69027
+ } catch (err) {
69028
+ const msg = err instanceof Error ? err.message : String(err);
69029
+ const errCode = err.code ?? "UNKNOWN";
69030
+ pushNotification({
69031
+ id: generateNotificationId(),
69032
+ type: "save-error",
69033
+ severity: "error",
69034
+ message: `Save failed for ${path8.basename(docState.filePath)}: ${msg}`,
69035
+ toolName: source,
69036
+ errorCode: errCode,
69037
+ documentId: docId,
69038
+ dedupKey: `${source}:${docId}`,
69039
+ timestamp: Date.now()
69040
+ });
69041
+ return { status: "error", reason: msg, errorCode: err.code };
69042
+ } finally {
69043
+ savingDocs.delete(docId);
69044
+ }
69045
+ }
69046
+ async function autoSaveAllToDisk() {
69047
+ for (const [docId, state] of openDocs) {
69048
+ if (state.source === "upload" || state.readOnly) continue;
69049
+ if (!AUTO_SAVE_FORMATS.has(state.format)) continue;
69050
+ try {
69051
+ const result2 = await saveDocumentToDisk(docId);
69052
+ if (result2.status === "saved") {
69053
+ console.error(`[AutoSave] Saved ${path8.basename(state.filePath)} to disk`);
69054
+ }
69055
+ } catch (err) {
69056
+ console.error(`[AutoSave] Unexpected error saving ${state.filePath}:`, err);
69057
+ }
69058
+ }
69059
+ }
68963
69060
  function toDocListEntry(d) {
68964
69061
  return {
68965
69062
  id: d.id,
@@ -69002,13 +69099,13 @@ async function closeDocumentById(id2) {
69002
69099
  }
69003
69100
  const closedPath = docState.filePath;
69004
69101
  unwatchFile(docState.filePath);
69102
+ savingDocs.delete(id2);
69103
+ removeDoc(id2);
69005
69104
  try {
69006
- const doc = getOrCreateDocument(id2);
69007
- await saveSession(docState.filePath, docState.format, doc);
69105
+ await deleteSession(docState.filePath);
69008
69106
  } catch (err) {
69009
- console.error(`[Tandem] Failed to save session before closing ${id2}:`, err);
69107
+ console.error(`[Tandem] Failed to delete session for ${id2}:`, err);
69010
69108
  }
69011
- removeDoc(id2);
69012
69109
  if (getActiveDocId() === id2) {
69013
69110
  const remaining = Array.from(openDocs.keys());
69014
69111
  setActiveDocId(remaining.length > 0 ? remaining[0] : null);
@@ -69078,23 +69175,28 @@ async function restoreOpenDocuments(previousActiveDocId) {
69078
69175
  }
69079
69176
  return restoredCount;
69080
69177
  }
69081
- var openDocs, activeDocId;
69178
+ var openDocs, activeDocId, savingDocs, AUTO_SAVE_FORMATS;
69082
69179
  var init_document_service = __esm({
69083
69180
  "src/server/mcp/document-service.ts"() {
69084
69181
  "use strict";
69085
69182
  init_constants();
69183
+ init_utils();
69086
69184
  init_queue();
69185
+ init_file_io();
69087
69186
  init_file_watcher();
69187
+ init_notifications();
69088
69188
  init_manager();
69089
69189
  init_provider();
69090
69190
  openDocs = /* @__PURE__ */ new Map();
69091
69191
  setShouldKeepDocument((name2) => openDocs.has(name2) || name2 === CTRL_ROOM);
69092
69192
  activeDocId = null;
69193
+ savingDocs = /* @__PURE__ */ new Set();
69194
+ AUTO_SAVE_FORMATS = /* @__PURE__ */ new Set(["md", "txt"]);
69093
69195
  }
69094
69196
  });
69095
69197
 
69096
69198
  // src/server/mcp/convert.ts
69097
- import fs5 from "fs/promises";
69199
+ import fs6 from "fs/promises";
69098
69200
  import path9 from "path";
69099
69201
  async function findAvailablePath(basePath) {
69100
69202
  const dir = path9.dirname(basePath);
@@ -69105,7 +69207,7 @@ async function findAvailablePath(basePath) {
69105
69207
  let counter = 0;
69106
69208
  while (counter <= MAX_ATTEMPTS) {
69107
69209
  try {
69108
- await fs5.access(candidate);
69210
+ await fs6.access(candidate);
69109
69211
  counter++;
69110
69212
  candidate = path9.join(dir, `${name2}-${counter}${ext}`);
69111
69213
  } catch (err) {
@@ -69154,7 +69256,7 @@ async function convertToMarkdown(documentId, outputPath) {
69154
69256
  });
69155
69257
  }
69156
69258
  try {
69157
- const stat = await fs5.stat(resolvedOutput);
69259
+ const stat = await fs6.stat(resolvedOutput);
69158
69260
  if (stat.isDirectory()) {
69159
69261
  const baseName = path9.basename(docState.filePath, path9.extname(docState.filePath));
69160
69262
  resolvedOutput = path9.join(resolvedOutput, `${baseName}.md`);
@@ -69168,7 +69270,7 @@ async function convertToMarkdown(documentId, outputPath) {
69168
69270
  resolvedOutput = path9.join(sourceDir, `${baseName}.md`);
69169
69271
  }
69170
69272
  resolvedOutput = await findAvailablePath(resolvedOutput);
69171
- await atomicWrite(resolvedOutput, markdown);
69273
+ await atomicWrite2(resolvedOutput, markdown);
69172
69274
  try {
69173
69275
  const openResult = await openFileByPath(resolvedOutput);
69174
69276
  return {
@@ -69463,6 +69565,25 @@ function registerDocumentTools(server) {
69463
69565
  }
69464
69566
  }, MCP_ORIGIN);
69465
69567
  }
69568
+ if (newText.length > 0) {
69569
+ const newFrom = from3;
69570
+ const newTo = toFlatOffset(newFrom + newText.length);
69571
+ const anchored = anchoredRange(r.doc, newFrom, newTo);
69572
+ if (anchored.ok) {
69573
+ const authorshipMap = r.doc.getMap(Y_MAP_AUTHORSHIP);
69574
+ const rangeId = generateAuthorshipId("claude");
69575
+ const entry = {
69576
+ id: rangeId,
69577
+ author: "claude",
69578
+ range: anchored.range,
69579
+ relRange: anchored.fullyAnchored ? anchored.relRange : void 0,
69580
+ timestamp: Date.now()
69581
+ };
69582
+ r.doc.transact(() => {
69583
+ authorshipMap.set(rangeId, entry);
69584
+ }, MCP_ORIGIN);
69585
+ }
69586
+ }
69466
69587
  return mcpSuccess({ edited: true, from: from3, to, newTextLength: newText.length });
69467
69588
  }
69468
69589
  )
@@ -69476,55 +69597,44 @@ function registerDocumentTools(server) {
69476
69597
  withErrorBoundary("tandem_save", async ({ documentId }) => {
69477
69598
  const r = requireDocument(documentId);
69478
69599
  if (!r) return noDocumentError();
69479
- try {
69480
- const docState = getCurrentDoc(documentId);
69481
- const format = docState?.format ?? "txt";
69482
- const readOnly = docState?.readOnly ?? false;
69483
- if (docState?.source === "upload") {
69484
- await saveSession(r.filePath, format, r.doc);
69485
- return mcpSuccess({
69486
- saved: true,
69487
- sessionOnly: true,
69488
- filePath: r.filePath,
69489
- message: "Session saved (annotations preserved). This file was uploaded \u2014 no disk path to save to."
69490
- });
69491
- }
69492
- const adapter = getAdapter(format);
69493
- if (readOnly || !adapter.canSave) {
69494
- await saveSession(r.filePath, format, r.doc);
69495
- return mcpSuccess({
69496
- saved: true,
69497
- sessionOnly: true,
69498
- filePath: r.filePath,
69499
- message: "Session saved (annotations preserved). Source file unchanged \u2014 document is read-only."
69500
- });
69501
- }
69502
- const output = adapter.save(r.doc);
69503
- suppressNextChange(r.filePath);
69504
- await atomicWrite(r.filePath, output);
69600
+ const docState = getCurrentDoc(documentId);
69601
+ const format = docState?.format ?? "txt";
69602
+ const readOnly = docState?.readOnly ?? false;
69603
+ if (docState?.source === "upload") {
69505
69604
  await saveSession(r.filePath, format, r.doc);
69506
- const meta2 = r.doc.getMap(Y_MAP_DOCUMENT_META);
69507
- r.doc.transact(() => meta2.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
69605
+ return mcpSuccess({
69606
+ saved: true,
69607
+ sessionOnly: true,
69608
+ filePath: r.filePath,
69609
+ message: "Session saved (annotations preserved). This file was uploaded \u2014 no disk path to save to."
69610
+ });
69611
+ }
69612
+ if (readOnly) {
69613
+ await saveSession(r.filePath, format, r.doc);
69614
+ return mcpSuccess({
69615
+ saved: true,
69616
+ sessionOnly: true,
69617
+ filePath: r.filePath,
69618
+ message: "Session saved (annotations preserved). Source file unchanged \u2014 document is read-only."
69619
+ });
69620
+ }
69621
+ const result2 = await saveDocumentToDisk(r.docId, "mcp");
69622
+ if (result2.status === "saved") {
69508
69623
  return mcpSuccess({ saved: true, filePath: r.filePath });
69509
- } catch (err) {
69510
- const errCode = err.code;
69511
- const msg = getErrorMessage(err);
69512
- pushNotification({
69513
- id: generateNotificationId(),
69514
- type: "save-error",
69515
- severity: "error",
69516
- message: `Save failed: ${msg}`,
69517
- toolName: "tandem_save",
69518
- errorCode: errCode ?? "UNKNOWN",
69519
- documentId: r.docId,
69520
- dedupKey: `save:${r.docId}`,
69521
- timestamp: Date.now()
69624
+ }
69625
+ if (result2.status === "skipped") {
69626
+ await saveSession(r.filePath, format, r.doc);
69627
+ return mcpSuccess({
69628
+ saved: true,
69629
+ sessionOnly: true,
69630
+ filePath: r.filePath,
69631
+ message: `Session saved. Disk save skipped: ${result2.reason}`
69522
69632
  });
69523
- if (errCode === "EACCES" || errCode === "EPERM") {
69524
- return mcpError("FILE_LOCKED", msg);
69525
- }
69526
- return mcpError("FORMAT_ERROR", `Save failed: ${msg}`);
69527
69633
  }
69634
+ if (result2.errorCode === "EACCES" || result2.errorCode === "EPERM") {
69635
+ return mcpError("FILE_LOCKED", result2.reason ?? "Save failed");
69636
+ }
69637
+ return mcpError("FORMAT_ERROR", result2.reason ?? "Save failed");
69528
69638
  })
69529
69639
  );
69530
69640
  server.tool(
@@ -69640,9 +69750,6 @@ var init_document2 = __esm({
69640
69750
  init_types3();
69641
69751
  init_utils();
69642
69752
  init_queue();
69643
- init_file_io();
69644
- init_file_watcher();
69645
- init_notifications();
69646
69753
  init_positions2();
69647
69754
  init_manager();
69648
69755
  init_provider();
@@ -69665,6 +69772,47 @@ function getDocAndAnnotations(documentId) {
69665
69772
  const ydoc = getOrCreateDocument(doc.docName);
69666
69773
  return { ydoc, map: ydoc.getMap(Y_MAP_ANNOTATIONS) };
69667
69774
  }
69775
+ function getRepliesMap(ydoc) {
69776
+ return ydoc.getMap(Y_MAP_ANNOTATION_REPLIES);
69777
+ }
69778
+ function collectRepliesForAnnotation(repliesMap, annotationId) {
69779
+ const replies = [];
69780
+ repliesMap.forEach((value) => {
69781
+ const reply = value;
69782
+ if (reply && typeof reply === "object" && reply.annotationId === annotationId) {
69783
+ replies.push(reply);
69784
+ }
69785
+ });
69786
+ replies.sort((a, b) => a.timestamp - b.timestamp);
69787
+ return replies;
69788
+ }
69789
+ function addReplyToAnnotation(ydoc, annotationsMap, annotationId, text5, author, origin) {
69790
+ const raw = annotationsMap.get(annotationId);
69791
+ if (!raw) return { ok: false, error: `Annotation ${annotationId} not found`, code: "NOT_FOUND" };
69792
+ const ann = sanitizeAnnotation(raw);
69793
+ if (ann.status !== "pending") {
69794
+ return {
69795
+ ok: false,
69796
+ error: `Cannot reply to a ${ann.status} annotation`,
69797
+ code: "ANNOTATION_RESOLVED"
69798
+ };
69799
+ }
69800
+ const replyId = generateReplyId();
69801
+ const reply = {
69802
+ id: replyId,
69803
+ annotationId,
69804
+ author,
69805
+ text: text5,
69806
+ timestamp: Date.now()
69807
+ };
69808
+ const repliesMap = getRepliesMap(ydoc);
69809
+ if (origin) {
69810
+ ydoc.transact(() => repliesMap.set(replyId, reply), origin);
69811
+ } else {
69812
+ ydoc.transact(() => repliesMap.set(replyId, reply));
69813
+ }
69814
+ return { ok: true, replyId };
69815
+ }
69668
69816
  function rangeFailureMessage(result2) {
69669
69817
  if (result2.code === "RANGE_GONE") return "Target text no longer exists in the document.";
69670
69818
  if (result2.code === "RANGE_MOVED") return "Target text has moved.";
@@ -69906,7 +70054,15 @@ function registerAnnotationTools(server) {
69906
70054
  if (author) results = results.filter((a) => a.author === author);
69907
70055
  if (type2) results = results.filter((a) => a.type === type2);
69908
70056
  if (status) results = results.filter((a) => a.status === status);
69909
- return mcpSuccess({ annotations: results, count: results.length });
70057
+ const repliesMap = getRepliesMap(da.ydoc);
70058
+ const annotationsWithReplies = results.map((ann) => ({
70059
+ ...ann,
70060
+ replies: collectRepliesForAnnotation(repliesMap, ann.id)
70061
+ }));
70062
+ return mcpSuccess({
70063
+ annotations: annotationsWithReplies,
70064
+ count: annotationsWithReplies.length
70065
+ });
69910
70066
  })
69911
70067
  );
69912
70068
  server.tool(
@@ -69942,7 +70098,16 @@ function registerAnnotationTools(server) {
69942
70098
  const da = getDocAndAnnotations(documentId);
69943
70099
  if (!da) return noDocumentError();
69944
70100
  if (!da.map.has(id2)) return mcpError("INVALID_RANGE", `Annotation ${id2} not found`);
69945
- da.ydoc.transact(() => da.map.delete(id2), MCP_ORIGIN);
70101
+ da.ydoc.transact(() => {
70102
+ da.map.delete(id2);
70103
+ const repliesMap = getRepliesMap(da.ydoc);
70104
+ const toDelete = [];
70105
+ repliesMap.forEach((value, key) => {
70106
+ const reply = value;
70107
+ if (reply && reply.annotationId === id2) toDelete.push(key);
70108
+ });
70109
+ for (const key of toDelete) repliesMap.delete(key);
70110
+ }, MCP_ORIGIN);
69946
70111
  return mcpSuccess({ removed: true, id: id2 });
69947
70112
  })
69948
70113
  );
@@ -70008,10 +70173,12 @@ function registerAnnotationTools(server) {
70008
70173
  if (!da) return noDocumentError();
70009
70174
  const annotations = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
70010
70175
  const { ydoc } = da;
70176
+ const repliesMap = getRepliesMap(ydoc);
70011
70177
  if (format === "json") {
70012
70178
  const fullText = extractText(ydoc);
70013
70179
  const enriched = annotations.map((ann) => ({
70014
70180
  ...ann,
70181
+ replies: collectRepliesForAnnotation(repliesMap, ann.id),
70015
70182
  textSnippet: fullText.slice(
70016
70183
  Math.max(0, ann.range.from),
70017
70184
  Math.min(fullText.length, ann.range.to)
@@ -70023,6 +70190,32 @@ function registerAnnotationTools(server) {
70023
70190
  return mcpSuccess({ markdown, count: annotations.length });
70024
70191
  })
70025
70192
  );
70193
+ server.tool(
70194
+ "tandem_annotationReply",
70195
+ "Reply to an annotation thread. Only works on pending annotations.",
70196
+ {
70197
+ annotationId: external_exports.string().describe("The annotation ID to reply to"),
70198
+ text: external_exports.string().describe("Reply text"),
70199
+ documentId: external_exports.string().optional().describe("Target document ID (defaults to active document)")
70200
+ },
70201
+ withErrorBoundary("tandem_annotationReply", async ({ annotationId, text: text5, documentId }) => {
70202
+ const da = getDocAndAnnotations(documentId);
70203
+ if (!da) return noDocumentError();
70204
+ const result2 = addReplyToAnnotation(
70205
+ da.ydoc,
70206
+ da.map,
70207
+ annotationId,
70208
+ text5,
70209
+ "claude",
70210
+ MCP_ORIGIN
70211
+ );
70212
+ if (!result2.ok) {
70213
+ const code3 = result2.code === "NOT_FOUND" ? "NOT_FOUND" : result2.code === "ANNOTATION_RESOLVED" ? "ANNOTATION_RESOLVED" : "INVALID_RANGE";
70214
+ return mcpError(code3, result2.error);
70215
+ }
70216
+ return mcpSuccess({ replyId: result2.replyId, annotationId });
70217
+ })
70218
+ );
70026
70219
  }
70027
70220
  var SNAPSHOT_CAP;
70028
70221
  var init_annotations = __esm({
@@ -70074,6 +70267,8 @@ function getTrackableId(event) {
70074
70267
  case "annotation:accepted":
70075
70268
  case "annotation:dismissed":
70076
70269
  return event.payload.annotationId;
70270
+ case "annotation:reply":
70271
+ return event.payload.replyId;
70077
70272
  case "chat:message":
70078
70273
  return event.payload.messageId;
70079
70274
  default:
@@ -70182,6 +70377,32 @@ function attachObservers(docName, doc) {
70182
70377
  };
70183
70378
  annotationsMap.observe(annotationsObs);
70184
70379
  cleanups.push(() => annotationsMap.unobserve(annotationsObs));
70380
+ const repliesMap = doc.getMap(Y_MAP_ANNOTATION_REPLIES);
70381
+ const repliesObs = (event, txn) => {
70382
+ if (txn.origin === MCP_ORIGIN) return;
70383
+ for (const [key, change] of event.changes.keys) {
70384
+ if (change.action !== "add") continue;
70385
+ const reply = repliesMap.get(key);
70386
+ if (!reply || reply.author !== "user") continue;
70387
+ const parentAnn = annotationsMap.get(reply.annotationId);
70388
+ const textSnippet = parentAnn?.textSnapshot ?? "";
70389
+ pushEvent({
70390
+ id: generateEventId(),
70391
+ type: "annotation:reply",
70392
+ timestamp: Date.now(),
70393
+ documentId: docName,
70394
+ payload: {
70395
+ annotationId: reply.annotationId,
70396
+ replyId: reply.id,
70397
+ replyText: reply.text,
70398
+ replyAuthor: reply.author,
70399
+ textSnippet
70400
+ }
70401
+ });
70402
+ }
70403
+ };
70404
+ repliesMap.observe(repliesObs);
70405
+ cleanups.push(() => repliesMap.unobserve(repliesObs));
70185
70406
  const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
70186
70407
  let selectionDwellTimer = null;
70187
70408
  const awarenessObs = (event, txn) => {
@@ -70192,19 +70413,16 @@ function attachObservers(docName, doc) {
70192
70413
  clearTimeout(selectionDwellTimer);
70193
70414
  selectionDwellTimer = null;
70194
70415
  }
70195
- if (!selection || selection.from === selection.to) return;
70416
+ if (!selection || selection.from === selection.to) {
70417
+ selectionBuffer.delete(docName);
70418
+ return;
70419
+ }
70196
70420
  selectionDwellTimer = setTimeout(() => {
70197
70421
  selectionDwellTimer = null;
70198
- pushEvent({
70199
- id: generateEventId(),
70200
- type: "selection:changed",
70201
- timestamp: Date.now(),
70202
- documentId: docName,
70203
- payload: {
70204
- from: selection.from,
70205
- to: selection.to,
70206
- selectedText: selection.selectedText ?? ""
70207
- }
70422
+ selectionBuffer.set(docName, {
70423
+ from: selection.from,
70424
+ to: selection.to,
70425
+ selectedText: selection.selectedText ?? ""
70208
70426
  });
70209
70427
  }, getDwellMs());
70210
70428
  }
@@ -70213,6 +70431,7 @@ function attachObservers(docName, doc) {
70213
70431
  cleanups.push(() => {
70214
70432
  userAwareness.unobserve(awarenessObs);
70215
70433
  if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
70434
+ selectionBuffer.delete(docName);
70216
70435
  });
70217
70436
  docObservers.set(docName, cleanups);
70218
70437
  console.error(`[EventQueue] Attached observers for document: ${docName}`);
@@ -70239,6 +70458,32 @@ function attachCtrlObservers() {
70239
70458
  if (change.action !== "add") continue;
70240
70459
  const msg = chatMap.get(key);
70241
70460
  if (!msg || msg.author !== "user") continue;
70461
+ let selection;
70462
+ if (msg.documentId) {
70463
+ const buffered = selectionBuffer.get(msg.documentId);
70464
+ if (buffered) {
70465
+ selectionBuffer.delete(msg.documentId);
70466
+ try {
70467
+ const doc = getOrCreateDocument(msg.documentId);
70468
+ const validation = validateRange(
70469
+ doc,
70470
+ buffered.from,
70471
+ buffered.to
70472
+ );
70473
+ if (validation.ok) {
70474
+ selection = buffered;
70475
+ } else {
70476
+ selection = { selectedText: buffered.selectedText };
70477
+ }
70478
+ } catch (err) {
70479
+ console.warn(
70480
+ `[EventQueue] Failed to validate buffered selection for doc=${msg.documentId}:`,
70481
+ err
70482
+ );
70483
+ selection = { selectedText: buffered.selectedText };
70484
+ }
70485
+ }
70486
+ }
70242
70487
  pushEvent({
70243
70488
  id: generateEventId(),
70244
70489
  type: "chat:message",
@@ -70248,7 +70493,8 @@ function attachCtrlObservers() {
70248
70493
  messageId: msg.id,
70249
70494
  text: msg.text,
70250
70495
  replyTo: msg.replyTo ?? null,
70251
- anchor: msg.anchor ?? null
70496
+ anchor: msg.anchor ?? null,
70497
+ ...selection ? { selection } : {}
70252
70498
  }
70253
70499
  });
70254
70500
  }
@@ -70317,17 +70563,19 @@ function attachCtrlObservers() {
70317
70563
  function reattachCtrlObservers() {
70318
70564
  attachCtrlObservers();
70319
70565
  }
70320
- var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
70566
+ var MCP_ORIGIN, docObservers, selectionBuffer, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
70321
70567
  var init_queue = __esm({
70322
70568
  "src/server/events/queue.ts"() {
70323
70569
  "use strict";
70324
70570
  init_constants();
70325
70571
  init_annotations();
70326
70572
  init_document_service();
70573
+ init_positions2();
70327
70574
  init_provider();
70328
70575
  init_types4();
70329
70576
  MCP_ORIGIN = "mcp";
70330
70577
  docObservers = /* @__PURE__ */ new Map();
70578
+ selectionBuffer = /* @__PURE__ */ new Map();
70331
70579
  emittedPayloadIds = /* @__PURE__ */ new Map();
70332
70580
  buffer2 = [];
70333
70581
  subscribers2 = /* @__PURE__ */ new Set();
@@ -89060,7 +89308,7 @@ var require_view = __commonJS({
89060
89308
  "use strict";
89061
89309
  var debug = require_src()("express:view");
89062
89310
  var path13 = __require("path");
89063
- var fs8 = __require("fs");
89311
+ var fs9 = __require("fs");
89064
89312
  var dirname3 = path13.dirname;
89065
89313
  var basename = path13.basename;
89066
89314
  var extname = path13.extname;
@@ -89140,7 +89388,7 @@ var require_view = __commonJS({
89140
89388
  function tryStat(path14) {
89141
89389
  debug('stat "%s"', path14);
89142
89390
  try {
89143
- return fs8.statSync(path14);
89391
+ return fs9.statSync(path14);
89144
89392
  } catch (e) {
89145
89393
  return void 0;
89146
89394
  }
@@ -92809,7 +93057,7 @@ var require_send = __commonJS({
92809
93057
  var escapeHtml = require_escape_html();
92810
93058
  var etag = require_etag();
92811
93059
  var fresh = require_fresh();
92812
- var fs8 = __require("fs");
93060
+ var fs9 = __require("fs");
92813
93061
  var mime = require_mime_types();
92814
93062
  var ms = require_ms();
92815
93063
  var onFinished = require_on_finished();
@@ -93091,7 +93339,7 @@ var require_send = __commonJS({
93091
93339
  var i = 0;
93092
93340
  var self2 = this;
93093
93341
  debug('stat "%s"', path14);
93094
- fs8.stat(path14, function onstat(err, stat) {
93342
+ fs9.stat(path14, function onstat(err, stat) {
93095
93343
  var pathEndsWithSep = path14[path14.length - 1] === sep;
93096
93344
  if (err && err.code === "ENOENT" && !extname(path14) && !pathEndsWithSep) {
93097
93345
  return next(err);
@@ -93108,7 +93356,7 @@ var require_send = __commonJS({
93108
93356
  }
93109
93357
  var p = path14 + "." + self2._extensions[i++];
93110
93358
  debug('stat "%s"', p);
93111
- fs8.stat(p, function(err2, stat) {
93359
+ fs9.stat(p, function(err2, stat) {
93112
93360
  if (err2) return next(err2);
93113
93361
  if (stat.isDirectory()) return next();
93114
93362
  self2.emit("file", p, stat);
@@ -93126,7 +93374,7 @@ var require_send = __commonJS({
93126
93374
  }
93127
93375
  var p = join4(path14, self2._index[i]);
93128
93376
  debug('stat "%s"', p);
93129
- fs8.stat(p, function(err2, stat) {
93377
+ fs9.stat(p, function(err2, stat) {
93130
93378
  if (err2) return next(err2);
93131
93379
  if (stat.isDirectory()) return next();
93132
93380
  self2.emit("file", p, stat);
@@ -93138,7 +93386,7 @@ var require_send = __commonJS({
93138
93386
  SendStream.prototype.stream = function stream(path14, options) {
93139
93387
  var self2 = this;
93140
93388
  var res = this.res;
93141
- var stream2 = fs8.createReadStream(path14, options);
93389
+ var stream2 = fs9.createReadStream(path14, options);
93142
93390
  this.emit("stream", stream2);
93143
93391
  stream2.pipe(res);
93144
93392
  function cleanup() {
@@ -100733,12 +100981,12 @@ var require_dist3 = __commonJS({
100733
100981
  throw new Error(`Unknown format "${name2}"`);
100734
100982
  return f;
100735
100983
  };
100736
- function addFormats(ajv, list4, fs8, exportName) {
100984
+ function addFormats(ajv, list4, fs9, exportName) {
100737
100985
  var _a;
100738
100986
  var _b;
100739
100987
  (_a = (_b = ajv.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
100740
100988
  for (const f of list4)
100741
- ajv.addFormat(f, fs8[f]);
100989
+ ajv.addFormat(f, fs9[f]);
100742
100990
  }
100743
100991
  module3.exports = exports3 = formatsPlugin;
100744
100992
  Object.defineProperty(exports3, "__esModule", { value: true });
@@ -112433,7 +112681,7 @@ var SKILL_CONTENT = `---
112433
112681
  name: tandem
112434
112682
  description: >
112435
112683
  Use when tandem_* MCP tools are available, the user asks about Tandem
112436
- document editing, or collaborative document review. Provides workflow
112684
+ document editing, or iterating on text collaboratively. Provides workflow
112437
112685
  guidance, annotation strategy, and tool usage patterns for the Tandem
112438
112686
  collaborative editor.
112439
112687
  ---
@@ -112454,13 +112702,13 @@ These prevent the most common failures. Follow them always.
112454
112702
 
112455
112703
  ## Workflow
112456
112704
 
112457
- Standard review sequence:
112705
+ Standard workflow:
112458
112706
 
112459
112707
  1. \`tandem_status\` \u2014 check for already-open documents (sessions restore automatically)
112460
112708
  2. \`tandem_getOutline\` \u2014 understand document structure
112461
- 3. \`tandem_setStatus("Reviewing [section]...", { focusParagraph: N })\` \u2014 show progress (use \`index\` from outline)
112709
+ 3. \`tandem_setStatus("Working on [section]...", { focusParagraph: N })\` \u2014 show progress (use \`index\` from outline)
112462
112710
  4. \`tandem_getTextContent({ section: "..." })\` \u2014 read one section at a time
112463
- 5. Annotate findings (see annotation guide below)
112711
+ 5. Annotate or edit as needed (see annotation guide below)
112464
112712
  6. \`tandem_checkInbox\` \u2014 check for user messages and actions
112465
112713
  7. Repeat steps 3-6 for each section
112466
112714
  8. \`tandem_save\` \u2014 persist edits to disk when done
@@ -112485,7 +112733,7 @@ Check \`mode\` from \`tandem_status\` or \`tandem_checkInbox\` and adapt:
112485
112733
 
112486
112734
  ## Reacting to Document Events
112487
112735
 
112488
- Selection events can reach you two ways. Over the real-time channel they arrive as notifications with \`meta.respond_via = "tandem_reply"\`. When polling via \`tandem_checkInbox\`, the current selection shows up under \`activity.selectedText\` (no \`meta\` field \u2014 that only exists on channel pushes). Either way, when the user holds a selection, briefly acknowledge what they highlighted via \`tandem_reply\` \u2014 don't annotate unless asked. Use \`tandem_reply\` for any document-context reaction (chat messages, selections, question annotations); reserve terminal output for non-document work the user explicitly requests. In Solo mode, hold reactions until the user sends a chat message.
112736
+ Selections are **not** sent as standalone events. Instead, when the user sends a chat message, any buffered selection is attached as a \`selection\` field on the \`chat:message\` payload. This gives you context about what text the user was looking at when they wrote their message. When polling via \`tandem_checkInbox\`, the current selection shows up under \`activity.selectedText\`. Use \`tandem_reply\` for any document-context reaction (chat messages, question annotations); reserve terminal output for non-document work the user explicitly requests. In Solo mode, hold reactions until the user sends a chat message.
112489
112737
 
112490
112738
  ## Collaboration Etiquette
112491
112739
 
@@ -112623,9 +112871,12 @@ async function installSkill(opts = {}) {
112623
112871
  // src/server/mcp/api-routes.ts
112624
112872
  init_constants();
112625
112873
  init_types3();
112874
+ init_utils();
112626
112875
  init_notifications();
112627
112876
  init_provider();
112877
+ init_annotations();
112628
112878
  init_convert();
112879
+ init_document2();
112629
112880
  init_document_model();
112630
112881
  init_document_service();
112631
112882
 
@@ -112638,7 +112889,7 @@ init_annotations();
112638
112889
  init_document_model();
112639
112890
  init_document_service();
112640
112891
  init_response();
112641
- import fs6 from "fs/promises";
112892
+ import fs7 from "fs/promises";
112642
112893
  import path10 from "path";
112643
112894
  async function applyChangesCore(documentId, author, backupPath) {
112644
112895
  const r = requireDocument(documentId);
@@ -112714,21 +112965,21 @@ async function applyChangesCore(documentId, author, backupPath) {
112714
112965
  throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
112715
112966
  }
112716
112967
  const ydocFlatText = extractText(ydoc);
112717
- const buffer3 = await fs6.readFile(filePath);
112968
+ const buffer3 = await fs7.readFile(filePath);
112718
112969
  const result2 = await applyTrackedChanges(buffer3, suggestions, {
112719
112970
  author: author ?? "Tandem Review",
112720
112971
  ydocFlatText
112721
112972
  });
112722
112973
  let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
112723
112974
  try {
112724
- await fs6.access(resolvedBackup);
112975
+ await fs7.access(resolvedBackup);
112725
112976
  const ext = path10.extname(resolvedBackup);
112726
112977
  const base = resolvedBackup.slice(0, -ext.length);
112727
112978
  resolvedBackup = `${base}-${Date.now()}${ext}`;
112728
112979
  } catch {
112729
112980
  }
112730
- await fs6.copyFile(filePath, resolvedBackup);
112731
- const [origStat, backupStat] = await Promise.all([fs6.stat(filePath), fs6.stat(resolvedBackup)]);
112981
+ await fs7.copyFile(filePath, resolvedBackup);
112982
+ const [origStat, backupStat] = await Promise.all([fs7.stat(filePath), fs7.stat(resolvedBackup)]);
112732
112983
  if (origStat.size !== backupStat.size) {
112733
112984
  throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
112734
112985
  code: "BACKUP_FAILED"
@@ -112783,17 +113034,17 @@ function registerApplyTools(server) {
112783
113034
  const { filePath } = r;
112784
113035
  const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
112785
113036
  try {
112786
- await fs6.access(backupPath);
113037
+ await fs7.access(backupPath);
112787
113038
  } catch (err) {
112788
113039
  if (err.code === "ENOENT") {
112789
113040
  return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
112790
113041
  }
112791
113042
  throw err;
112792
113043
  }
112793
- await fs6.copyFile(backupPath, filePath);
113044
+ await fs7.copyFile(backupPath, filePath);
112794
113045
  const [backupStat, restoredStat] = await Promise.all([
112795
- fs6.stat(backupPath),
112796
- fs6.stat(filePath)
113046
+ fs7.stat(backupPath),
113047
+ fs7.stat(filePath)
112797
113048
  ]);
112798
113049
  if (backupStat.size !== restoredStat.size) {
112799
113050
  throw new Error("Restore verification failed: file sizes do not match.");
@@ -113036,6 +113287,25 @@ function registerApiRoutes(app, largeBody) {
113036
113287
  sendApiError(res, err);
113037
113288
  }
113038
113289
  });
113290
+ app.options("/api/save", apiMiddleware);
113291
+ app.post("/api/save", apiMiddleware, largeBody, async (req, res) => {
113292
+ const { documentId } = req.body ?? {};
113293
+ if (documentId !== void 0 && typeof documentId !== "string") {
113294
+ res.status(400).json({ error: "BAD_REQUEST", message: "documentId must be a string" });
113295
+ return;
113296
+ }
113297
+ const targetId = documentId ?? getActiveDocId();
113298
+ if (!targetId) {
113299
+ res.status(404).json({ error: "NOT_FOUND", message: "No document to save." });
113300
+ return;
113301
+ }
113302
+ try {
113303
+ const result2 = await saveDocumentToDisk(targetId, "manual");
113304
+ res.json({ data: result2 });
113305
+ } catch (err) {
113306
+ sendApiError(res, err);
113307
+ }
113308
+ });
113039
113309
  app.options("/api/upload", apiMiddleware);
113040
113310
  app.post("/api/upload", apiMiddleware, largeBody, async (req, res) => {
113041
113311
  const { fileName, content: content3 } = req.body ?? {};
@@ -113126,6 +113396,40 @@ function registerApiRoutes(app, largeBody) {
113126
113396
  });
113127
113397
  }
113128
113398
  });
113399
+ app.options("/api/annotation-reply", apiMiddleware);
113400
+ app.post("/api/annotation-reply", apiMiddleware, largeBody, (req, res) => {
113401
+ const { annotationId, text: text5, documentId } = req.body ?? {};
113402
+ if (!annotationId || typeof annotationId !== "string") {
113403
+ res.status(400).json({ error: "BAD_REQUEST", message: "annotationId is required" });
113404
+ return;
113405
+ }
113406
+ if (!text5 || typeof text5 !== "string") {
113407
+ res.status(400).json({ error: "BAD_REQUEST", message: "text is required" });
113408
+ return;
113409
+ }
113410
+ const doc = getCurrentDoc(typeof documentId === "string" ? documentId : void 0);
113411
+ if (!doc) {
113412
+ res.status(404).json({ error: "NOT_FOUND", message: "No document open" });
113413
+ return;
113414
+ }
113415
+ const ydoc = getOrCreateDocument(doc.docName);
113416
+ const annotationsMap = ydoc.getMap(Y_MAP_ANNOTATIONS);
113417
+ const result2 = addReplyToAnnotation(ydoc, annotationsMap, annotationId, text5, "user");
113418
+ if (!result2.ok) {
113419
+ const status = result2.code === "ANNOTATION_RESOLVED" ? 409 : 404;
113420
+ pushNotification({
113421
+ id: generateNotificationId(),
113422
+ type: "annotation-error",
113423
+ severity: "error",
113424
+ message: `Reply failed: ${result2.error}`,
113425
+ dedupKey: `reply-error:${annotationId}`,
113426
+ timestamp: Date.now()
113427
+ });
113428
+ res.status(status).json({ error: result2.code, message: result2.error });
113429
+ return;
113430
+ }
113431
+ res.json({ data: { replyId: result2.replyId, annotationId } });
113432
+ });
113129
113433
  }
113130
113434
 
113131
113435
  // src/server/mcp/awareness.ts
@@ -113381,7 +113685,7 @@ function registerChannelRoutes(app, apiMiddleware2) {
113381
113685
  app.get("/api/events", apiMiddleware2, sseHandler);
113382
113686
  app.options("/api/channel-awareness", apiMiddleware2);
113383
113687
  app.post("/api/channel-awareness", apiMiddleware2, (req, res) => {
113384
- const { documentId, status, active, focusParagraph } = req.body ?? {};
113688
+ const { documentId, status, active, focusParagraph, focusOffset } = req.body ?? {};
113385
113689
  if (typeof status !== "string") {
113386
113690
  res.status(400).json({ error: "BAD_REQUEST", message: "status is required" });
113387
113691
  return;
@@ -113394,7 +113698,8 @@ function registerChannelRoutes(app, apiMiddleware2) {
113394
113698
  status,
113395
113699
  timestamp: Date.now(),
113396
113700
  active: active === true,
113397
- focusParagraph: typeof focusParagraph === "number" ? focusParagraph : null
113701
+ focusParagraph: typeof focusParagraph === "number" ? focusParagraph : null,
113702
+ focusOffset: typeof focusOffset === "number" ? focusOffset : null
113398
113703
  };
113399
113704
  doc.transact(() => awarenessMap.set("claude", state), MCP_ORIGIN);
113400
113705
  }
@@ -113602,29 +113907,34 @@ function registerNavigationTools(server) {
113602
113907
  {
113603
113908
  text: external_exports.string().describe("Status text"),
113604
113909
  focusParagraph: external_exports.number().optional().describe("Index of paragraph Claude is focusing on"),
113910
+ focusOffset: external_exports.number().optional().describe("Flat character offset for precise cursor positioning within the document"),
113605
113911
  documentId: external_exports.string().optional().describe("Target document ID (defaults to active document)")
113606
113912
  },
113607
- withErrorBoundary("tandem_setStatus", async ({ text: text5, focusParagraph, documentId }) => {
113608
- const current = getCurrentDoc(documentId);
113609
- if (!current) {
113610
- return mcpSuccess({
113611
- status: text5,
113612
- warning: "No document open \u2014 status not broadcast to editor."
113613
- });
113913
+ withErrorBoundary(
113914
+ "tandem_setStatus",
113915
+ async ({ text: text5, focusParagraph, focusOffset, documentId }) => {
113916
+ const current = getCurrentDoc(documentId);
113917
+ if (!current) {
113918
+ return mcpSuccess({
113919
+ status: text5,
113920
+ warning: "No document open \u2014 status not broadcast to editor."
113921
+ });
113922
+ }
113923
+ const doc = getOrCreateDocument(current.docName);
113924
+ const awarenessMap = doc.getMap(Y_MAP_AWARENESS);
113925
+ doc.transact(
113926
+ () => awarenessMap.set("claude", {
113927
+ status: text5,
113928
+ timestamp: Date.now(),
113929
+ active: true,
113930
+ focusParagraph: focusParagraph ?? null,
113931
+ focusOffset: focusOffset ?? null
113932
+ }),
113933
+ MCP_ORIGIN
113934
+ );
113935
+ return mcpSuccess({ status: text5 });
113614
113936
  }
113615
- const doc = getOrCreateDocument(current.docName);
113616
- const awarenessMap = doc.getMap(Y_MAP_AWARENESS);
113617
- doc.transact(
113618
- () => awarenessMap.set("claude", {
113619
- status: text5,
113620
- timestamp: Date.now(),
113621
- active: true,
113622
- focusParagraph: focusParagraph ?? null
113623
- }),
113624
- MCP_ORIGIN
113625
- );
113626
- return mcpSuccess({ status: text5 });
113627
- })
113937
+ )
113628
113938
  );
113629
113939
  server.tool(
113630
113940
  "tandem_getContext",
@@ -113899,12 +114209,12 @@ init_platform();
113899
114209
  init_manager();
113900
114210
 
113901
114211
  // src/server/version-check.ts
113902
- import fs7 from "fs/promises";
114212
+ import fs8 from "fs/promises";
113903
114213
  import path11 from "path";
113904
114214
  async function checkVersionChange(currentVersion, versionFilePath) {
113905
114215
  let storedVersion = null;
113906
114216
  try {
113907
- storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
114217
+ storedVersion = (await fs8.readFile(versionFilePath, "utf-8")).trim();
113908
114218
  } catch (err) {
113909
114219
  if (err.code !== "ENOENT") {
113910
114220
  console.error("[Tandem] Failed to read last-seen-version:", err);
@@ -113912,8 +114222,8 @@ async function checkVersionChange(currentVersion, versionFilePath) {
113912
114222
  }
113913
114223
  const result2 = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
113914
114224
  if (result2 !== "current") {
113915
- await fs7.mkdir(path11.dirname(versionFilePath), { recursive: true });
113916
- await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
114225
+ await fs8.mkdir(path11.dirname(versionFilePath), { recursive: true });
114226
+ await fs8.writeFile(versionFilePath, currentVersion, "utf-8");
113917
114227
  }
113918
114228
  return result2;
113919
114229
  }