tandem-editor 0.2.9 → 0.2.11

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.
@@ -92,7 +92,13 @@ async function startHocuspocus(port) {
92
92
  console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
93
93
  }
94
94
  documents.set(documentName, document);
95
- onDocSwapped?.(documentName, document);
95
+ if (onDocSwapped) {
96
+ onDocSwapped(documentName, document);
97
+ } else {
98
+ console.error(
99
+ `[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
100
+ );
101
+ }
96
102
  return document;
97
103
  },
98
104
  async afterUnloadDocument({ documentName }) {
@@ -228,6 +234,25 @@ var init_platform = __esm({
228
234
  import fs from "fs/promises";
229
235
  import path2 from "path";
230
236
  import * as Y2 from "yjs";
237
+ async function atomicWrite(sessionPath, content) {
238
+ const tmpPath = `${sessionPath}.tmp`;
239
+ await fs.writeFile(tmpPath, content, "utf-8");
240
+ for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
241
+ try {
242
+ await fs.rename(tmpPath, sessionPath);
243
+ return;
244
+ } catch (err) {
245
+ const code = err.code;
246
+ if ((code === "EPERM" || code === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
247
+ await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
248
+ continue;
249
+ }
250
+ await fs.unlink(tmpPath).catch(() => {
251
+ });
252
+ throw err;
253
+ }
254
+ }
255
+ }
231
256
  function sessionKey(filePath) {
232
257
  return encodeURIComponent(filePath.replace(/\\/g, "/"));
233
258
  }
@@ -255,15 +280,7 @@ async function saveSession(filePath, format, doc) {
255
280
  sessionDirReady = true;
256
281
  }
257
282
  const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
258
- const tmpPath = `${sessionPath}.tmp`;
259
- await fs.writeFile(tmpPath, JSON.stringify(data), "utf-8");
260
- try {
261
- await fs.rename(tmpPath, sessionPath);
262
- } catch (err) {
263
- await fs.unlink(tmpPath).catch(() => {
264
- });
265
- throw err;
266
- }
283
+ await atomicWrite(sessionPath, JSON.stringify(data));
267
284
  }
268
285
  async function loadSession(filePath) {
269
286
  const key = sessionKey(filePath);
@@ -334,15 +351,7 @@ async function saveCtrlSession(doc) {
334
351
  const ydocState = Buffer.from(state).toString("base64");
335
352
  const data = { ydocState, lastAccessed: Date.now() };
336
353
  const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
337
- const tmpPath = `${sessionPath}.tmp`;
338
- await fs.writeFile(tmpPath, JSON.stringify(data), "utf-8");
339
- try {
340
- await fs.rename(tmpPath, sessionPath);
341
- } catch (err) {
342
- await fs.unlink(tmpPath).catch(() => {
343
- });
344
- throw err;
345
- }
354
+ await atomicWrite(sessionPath, JSON.stringify(data));
346
355
  }
347
356
  async function loadCtrlSession() {
348
357
  const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
@@ -441,7 +450,7 @@ function stopAutoSave() {
441
450
  }
442
451
  autoSaveCallback = null;
443
452
  }
444
- var AUTO_SAVE_INTERVAL, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
453
+ var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
445
454
  var init_manager = __esm({
446
455
  "src/server/session/manager.ts"() {
447
456
  "use strict";
@@ -449,6 +458,8 @@ var init_manager = __esm({
449
458
  init_constants();
450
459
  init_queue();
451
460
  AUTO_SAVE_INTERVAL = 60 * 1e3;
461
+ RENAME_MAX_RETRIES = 3;
462
+ RENAME_RETRY_BASE_MS = 50;
452
463
  sessionDirReady = false;
453
464
  CTRL_SESSION_KEY = CTRL_ROOM;
454
465
  autoSaveTimer = null;
@@ -456,6 +467,74 @@ var init_manager = __esm({
456
467
  }
457
468
  });
458
469
 
470
+ // src/server/file-watcher.ts
471
+ import fs2 from "fs";
472
+ function watchFile(filePath, onChanged) {
473
+ if (watched.has(filePath)) return;
474
+ let watcher;
475
+ try {
476
+ watcher = fs2.watch(filePath, (eventType) => {
477
+ if (eventType !== "change") return;
478
+ const entry = watched.get(filePath);
479
+ if (!entry) return;
480
+ if (entry.suppressed) {
481
+ entry.suppressed = false;
482
+ return;
483
+ }
484
+ if (entry.timer !== null) {
485
+ clearTimeout(entry.timer);
486
+ }
487
+ entry.timer = setTimeout(() => {
488
+ entry.timer = null;
489
+ onChanged(filePath).catch((err) => {
490
+ console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
491
+ });
492
+ }, 500);
493
+ });
494
+ } catch (err) {
495
+ console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
496
+ return;
497
+ }
498
+ watcher.on("error", (err) => {
499
+ console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
500
+ unwatchFile(filePath);
501
+ });
502
+ watched.set(filePath, { watcher, timer: null, suppressed: false });
503
+ console.error(`[FileWatcher] Watching ${filePath}`);
504
+ }
505
+ function suppressNextChange(filePath) {
506
+ const entry = watched.get(filePath);
507
+ if (entry) {
508
+ entry.suppressed = true;
509
+ }
510
+ }
511
+ function unwatchFile(filePath) {
512
+ const entry = watched.get(filePath);
513
+ if (!entry) return;
514
+ if (entry.timer !== null) {
515
+ clearTimeout(entry.timer);
516
+ }
517
+ try {
518
+ entry.watcher.close();
519
+ } catch (err) {
520
+ console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
521
+ }
522
+ watched.delete(filePath);
523
+ console.error(`[FileWatcher] Unwatched ${filePath}`);
524
+ }
525
+ function unwatchAll() {
526
+ for (const filePath of [...watched.keys()]) {
527
+ unwatchFile(filePath);
528
+ }
529
+ }
530
+ var watched;
531
+ var init_file_watcher = __esm({
532
+ "src/server/file-watcher.ts"() {
533
+ "use strict";
534
+ watched = /* @__PURE__ */ new Map();
535
+ }
536
+ });
537
+
459
538
  // src/server/file-io/mdast-ydoc.ts
460
539
  import * as Y3 from "yjs";
461
540
  function mdastToYDoc(doc, tree) {
@@ -1512,7 +1591,24 @@ function refreshRange(ann, ydoc, map) {
1512
1591
  }
1513
1592
  const newFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
1514
1593
  const newTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
1515
- if (newFrom === null || newTo === null) return ann;
1594
+ if (newFrom === null || newTo === null) {
1595
+ if (newFrom !== null || newTo !== null) {
1596
+ console.error(
1597
+ `[positions] refreshRange: partial CRDT resolution for ${ann.id} (from: ${newFrom !== null ? "ok" : "dead"}, to: ${newTo !== null ? "ok" : "dead"})`
1598
+ );
1599
+ }
1600
+ const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);
1601
+ const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);
1602
+ if (fromRel && toRel) {
1603
+ const updated2 = { ...ann, relRange: { fromRel, toRel } };
1604
+ if (map) map.set(ann.id, updated2);
1605
+ return updated2;
1606
+ }
1607
+ const stripped = { ...ann };
1608
+ delete stripped.relRange;
1609
+ if (map) map.set(ann.id, stripped);
1610
+ return stripped;
1611
+ }
1516
1612
  if (newFrom > newTo) {
1517
1613
  console.error(
1518
1614
  `[positions] refreshRange: inverted CRDT range for annotation ${ann.id}: resolved [${newFrom}, ${newTo}] from flat [${ann.range.from}, ${ann.range.to}]`
@@ -2676,23 +2772,23 @@ var init_docx_apply = __esm({
2676
2772
  });
2677
2773
 
2678
2774
  // src/server/file-io/index.ts
2679
- import fs2 from "fs/promises";
2775
+ import fs3 from "fs/promises";
2680
2776
  import path4 from "path";
2681
2777
  function getAdapter(format) {
2682
2778
  return adapters[format] ?? plaintextAdapter;
2683
2779
  }
2684
- async function atomicWrite(filePath, content) {
2780
+ async function atomicWrite2(filePath, content) {
2685
2781
  const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2686
- await fs2.writeFile(tempPath, content, "utf-8");
2687
- await fs2.rename(tempPath, filePath);
2782
+ await fs3.writeFile(tempPath, content, "utf-8");
2783
+ await fs3.rename(tempPath, filePath);
2688
2784
  }
2689
2785
  async function atomicWriteBuffer(filePath, content) {
2690
2786
  const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2691
- await fs2.writeFile(tempPath, content);
2787
+ await fs3.writeFile(tempPath, content);
2692
2788
  try {
2693
- await fs2.rename(tempPath, filePath);
2789
+ await fs3.rename(tempPath, filePath);
2694
2790
  } catch (err) {
2695
- await fs2.unlink(tempPath).catch(() => {
2791
+ await fs3.unlink(tempPath).catch(() => {
2696
2792
  });
2697
2793
  throw err;
2698
2794
  }
@@ -2755,6 +2851,58 @@ var init_file_io = __esm({
2755
2851
  }
2756
2852
  });
2757
2853
 
2854
+ // src/server/notifications.ts
2855
+ function pushNotification(notification) {
2856
+ buffer.push(notification);
2857
+ while (buffer.length > NOTIFICATION_BUFFER_SIZE) {
2858
+ buffer.shift();
2859
+ }
2860
+ for (const cb of subscribers) {
2861
+ try {
2862
+ cb(notification);
2863
+ } catch (err) {
2864
+ console.error("[Notifications] Subscriber threw during dispatch:", err);
2865
+ }
2866
+ }
2867
+ }
2868
+ function subscribe(cb) {
2869
+ subscribers.add(cb);
2870
+ return () => {
2871
+ subscribers.delete(cb);
2872
+ };
2873
+ }
2874
+ var buffer, subscribers;
2875
+ var init_notifications = __esm({
2876
+ "src/server/notifications.ts"() {
2877
+ "use strict";
2878
+ init_constants();
2879
+ buffer = [];
2880
+ subscribers = /* @__PURE__ */ new Set();
2881
+ }
2882
+ });
2883
+
2884
+ // src/shared/utils.ts
2885
+ function generateId(prefix) {
2886
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2887
+ }
2888
+ function generateAnnotationId() {
2889
+ return generateId("ann");
2890
+ }
2891
+ function generateMessageId() {
2892
+ return generateId("msg");
2893
+ }
2894
+ function generateEventId() {
2895
+ return generateId("evt");
2896
+ }
2897
+ function generateNotificationId() {
2898
+ return generateId("ntf");
2899
+ }
2900
+ var init_utils = __esm({
2901
+ "src/shared/utils.ts"() {
2902
+ "use strict";
2903
+ }
2904
+ });
2905
+
2758
2906
  // src/server/mcp/file-opener.ts
2759
2907
  var file_opener_exports = {};
2760
2908
  __export(file_opener_exports, {
@@ -2762,7 +2910,7 @@ __export(file_opener_exports, {
2762
2910
  openFileByPath: () => openFileByPath,
2763
2911
  openFileFromContent: () => openFileFromContent
2764
2912
  });
2765
- import fs3 from "fs/promises";
2913
+ import fs4 from "fs/promises";
2766
2914
  import fsSync from "fs";
2767
2915
  import path5 from "path";
2768
2916
  import { randomUUID } from "crypto";
@@ -2793,7 +2941,7 @@ async function openFileByPath(filePath, options) {
2793
2941
  { code: "UNSUPPORTED_FORMAT" }
2794
2942
  );
2795
2943
  }
2796
- const stat = await fs3.stat(resolved);
2944
+ const stat = await fs4.stat(resolved);
2797
2945
  if (stat.size > MAX_FILE_SIZE) {
2798
2946
  throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
2799
2947
  }
@@ -2862,7 +3010,7 @@ async function openFileByPath(filePath, options) {
2862
3010
  }
2863
3011
  if (!restoredFromSession) {
2864
3012
  const adapter = getAdapter(format);
2865
- const fileContent = isDocx ? await fs3.readFile(resolved) : await fs3.readFile(resolved, "utf-8");
3013
+ const fileContent = isDocx ? await fs4.readFile(resolved) : await fs4.readFile(resolved, "utf-8");
2866
3014
  await adapter.load(doc, fileContent);
2867
3015
  }
2868
3016
  addDoc(id, { id, filePath: resolved, format, readOnly, source: "file" });
@@ -2871,6 +3019,9 @@ async function openFileByPath(filePath, options) {
2871
3019
  initSavedBaseline(doc);
2872
3020
  broadcastOpenDocs();
2873
3021
  ensureAutoSave();
3022
+ if (format !== "docx") {
3023
+ wireFileWatcher(id, resolved, format);
3024
+ }
2874
3025
  return {
2875
3026
  ...buildResult(doc, {
2876
3027
  documentId: id,
@@ -2928,7 +3079,7 @@ async function clearAndReload(id, doc, filePath, format, existing) {
2928
3079
  let preparedComments;
2929
3080
  let preparedContent;
2930
3081
  if (isDocx) {
2931
- const buffer3 = await fs3.readFile(filePath);
3082
+ const buffer3 = await fs4.readFile(filePath);
2932
3083
  [preparedHtml, preparedComments] = await Promise.all([
2933
3084
  loadDocx(buffer3),
2934
3085
  extractDocxComments(buffer3).catch((err) => {
@@ -2940,7 +3091,7 @@ async function clearAndReload(id, doc, filePath, format, existing) {
2940
3091
  })
2941
3092
  ]);
2942
3093
  } else {
2943
- preparedContent = await fs3.readFile(filePath, "utf-8");
3094
+ preparedContent = await fs4.readFile(filePath, "utf-8");
2944
3095
  }
2945
3096
  try {
2946
3097
  doc.transact(() => {
@@ -3014,6 +3165,90 @@ function buildResult(doc, base) {
3014
3165
  ...warnings.length > 0 ? { warnings } : {}
3015
3166
  };
3016
3167
  }
3168
+ async function reloadFromDisk(id, filePath, format) {
3169
+ if (reloadInProgress.has(id)) {
3170
+ console.error(`[FileWatcher] reload already in progress for ${id}, skipping`);
3171
+ return;
3172
+ }
3173
+ reloadInProgress.add(id);
3174
+ try {
3175
+ console.error(`[FileWatcher] reloadFromDisk: reloading ${id} from ${filePath}`);
3176
+ const doc = getOrCreateDocument(id);
3177
+ const fileContent = await fs4.readFile(filePath, "utf-8");
3178
+ doc.transact(() => {
3179
+ const awareness = doc.getMap(Y_MAP_AWARENESS);
3180
+ awareness.forEach((_, k) => awareness.delete(k));
3181
+ const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
3182
+ userAwareness.forEach((_, k) => userAwareness.delete(k));
3183
+ if (format === "md") {
3184
+ loadMarkdown(doc, fileContent);
3185
+ } else {
3186
+ populateYDoc(doc, fileContent);
3187
+ }
3188
+ }, MCP_ORIGIN);
3189
+ const annotationMap = doc.getMap(Y_MAP_ANNOTATIONS);
3190
+ const annotations = [];
3191
+ annotationMap.forEach((val) => annotations.push(val));
3192
+ if (annotations.length > 0) {
3193
+ const refreshed = refreshAllRanges(annotations, doc, annotationMap);
3194
+ doc.transact(() => {
3195
+ for (const ann of refreshed) {
3196
+ if (!ann.textSnapshot) continue;
3197
+ const vr = validateRange(doc, ann.range.from, ann.range.to, {
3198
+ textSnapshot: ann.textSnapshot
3199
+ });
3200
+ if (vr.ok) continue;
3201
+ if (vr.code === "RANGE_MOVED") {
3202
+ const relocated = anchoredRange(doc, vr.resolvedFrom, vr.resolvedTo, ann.textSnapshot);
3203
+ if (relocated.ok) {
3204
+ const updated = {
3205
+ ...ann,
3206
+ range: relocated.range,
3207
+ relRange: relocated.fullyAnchored ? relocated.relRange : void 0
3208
+ };
3209
+ annotationMap.set(ann.id, updated);
3210
+ }
3211
+ }
3212
+ }
3213
+ }, MCP_ORIGIN);
3214
+ }
3215
+ attachObservers(id, doc);
3216
+ console.error(`[FileWatcher] reloadFromDisk: complete for ${id}`);
3217
+ } finally {
3218
+ reloadInProgress.delete(id);
3219
+ }
3220
+ }
3221
+ function wireFileWatcher(id, filePath, format) {
3222
+ try {
3223
+ watchFile(filePath, async () => {
3224
+ try {
3225
+ await reloadFromDisk(id, filePath, format);
3226
+ pushNotification({
3227
+ id: generateNotificationId(),
3228
+ type: "file-reloaded",
3229
+ severity: "info",
3230
+ message: `File changed on disk \u2014 reloaded: ${path5.basename(filePath)}`,
3231
+ documentId: id,
3232
+ dedupKey: `reload:${id}`,
3233
+ timestamp: Date.now()
3234
+ });
3235
+ } catch (err) {
3236
+ console.error(`[FileWatcher] reloadFromDisk failed for ${filePath}:`, err);
3237
+ pushNotification({
3238
+ id: generateNotificationId(),
3239
+ type: "general-error",
3240
+ severity: "warning",
3241
+ message: `Failed to reload ${path5.basename(filePath)} from disk`,
3242
+ documentId: id,
3243
+ dedupKey: `reload-error:${id}`,
3244
+ timestamp: Date.now()
3245
+ });
3246
+ }
3247
+ });
3248
+ } catch (err) {
3249
+ console.error(`[FileWatcher] wireFileWatcher failed for ${filePath}:`, err);
3250
+ }
3251
+ }
3017
3252
  function ensureAutoSave() {
3018
3253
  if (isAutoSaveRunning()) return;
3019
3254
  startAutoSave(async () => {
@@ -3023,6 +3258,7 @@ function ensureAutoSave() {
3023
3258
  }
3024
3259
  });
3025
3260
  }
3261
+ var reloadInProgress;
3026
3262
  var init_file_opener = __esm({
3027
3263
  "src/server/mcp/file-opener.ts"() {
3028
3264
  "use strict";
@@ -3030,6 +3266,10 @@ var init_file_opener = __esm({
3030
3266
  init_constants();
3031
3267
  init_queue();
3032
3268
  init_file_io();
3269
+ init_file_watcher();
3270
+ init_positions2();
3271
+ init_notifications();
3272
+ init_utils();
3033
3273
  init_markdown();
3034
3274
  init_docx();
3035
3275
  init_docx_html();
@@ -3037,6 +3277,7 @@ var init_file_opener = __esm({
3037
3277
  init_manager();
3038
3278
  init_document_model();
3039
3279
  init_document_service();
3280
+ reloadInProgress = /* @__PURE__ */ new Set();
3040
3281
  }
3041
3282
  });
3042
3283
 
@@ -3121,6 +3362,7 @@ async function closeDocumentById(id) {
3121
3362
  return { success: false, error: `Document ${id} not found.` };
3122
3363
  }
3123
3364
  const closedPath = docState.filePath;
3365
+ unwatchFile(docState.filePath);
3124
3366
  try {
3125
3367
  const doc = getOrCreateDocument(id);
3126
3368
  await saveSession(docState.filePath, docState.format, doc);
@@ -3205,34 +3447,13 @@ var init_document_service = __esm({
3205
3447
  init_manager();
3206
3448
  init_constants();
3207
3449
  init_queue();
3450
+ init_file_watcher();
3208
3451
  openDocs = /* @__PURE__ */ new Map();
3209
3452
  setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
3210
3453
  activeDocId = null;
3211
3454
  }
3212
3455
  });
3213
3456
 
3214
- // src/shared/utils.ts
3215
- function generateId(prefix) {
3216
- return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3217
- }
3218
- function generateAnnotationId() {
3219
- return generateId("ann");
3220
- }
3221
- function generateMessageId() {
3222
- return generateId("msg");
3223
- }
3224
- function generateEventId() {
3225
- return generateId("evt");
3226
- }
3227
- function generateNotificationId() {
3228
- return generateId("ntf");
3229
- }
3230
- var init_utils = __esm({
3231
- "src/shared/utils.ts"() {
3232
- "use strict";
3233
- }
3234
- });
3235
-
3236
3457
  // src/server/events/types.ts
3237
3458
  var init_types3 = __esm({
3238
3459
  "src/server/events/types.ts"() {
@@ -3266,18 +3487,18 @@ function untrackPayloadId(event) {
3266
3487
  else emittedPayloadIds.set(id, count - 1);
3267
3488
  }
3268
3489
  function pushEvent(event) {
3269
- buffer.push(event);
3490
+ buffer2.push(event);
3270
3491
  trackPayloadId(event);
3271
- while (buffer.length > CHANNEL_EVENT_BUFFER_SIZE) {
3272
- const evicted = buffer.shift();
3492
+ while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
3493
+ const evicted = buffer2.shift();
3273
3494
  if (evicted) untrackPayloadId(evicted);
3274
3495
  }
3275
3496
  const now = Date.now();
3276
- while (buffer.length > 0 && now - buffer[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
3277
- const evicted = buffer.shift();
3497
+ while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
3498
+ const evicted = buffer2.shift();
3278
3499
  if (evicted) untrackPayloadId(evicted);
3279
3500
  }
3280
- for (const cb of subscribers) {
3501
+ for (const cb of subscribers2) {
3281
3502
  try {
3282
3503
  cb(event);
3283
3504
  } catch (err) {
@@ -3285,16 +3506,16 @@ function pushEvent(event) {
3285
3506
  }
3286
3507
  }
3287
3508
  }
3288
- function subscribe(cb) {
3289
- subscribers.add(cb);
3509
+ function subscribe2(cb) {
3510
+ subscribers2.add(cb);
3290
3511
  }
3291
3512
  function unsubscribe(cb) {
3292
- subscribers.delete(cb);
3513
+ subscribers2.delete(cb);
3293
3514
  }
3294
3515
  function replaySince(lastEventId) {
3295
- const idx = buffer.findIndex((e) => e.id === lastEventId);
3296
- if (idx === -1) return [...buffer];
3297
- return buffer.slice(idx + 1);
3516
+ const idx = buffer2.findIndex((e) => e.id === lastEventId);
3517
+ if (idx === -1) return [...buffer2];
3518
+ return buffer2.slice(idx + 1);
3298
3519
  }
3299
3520
  function attachObservers(docName, doc) {
3300
3521
  detachObservers(docName);
@@ -3471,7 +3692,7 @@ function attachCtrlObservers() {
3471
3692
  function reattachCtrlObservers() {
3472
3693
  attachCtrlObservers();
3473
3694
  }
3474
- var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer, subscribers, ctrlCleanups;
3695
+ var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
3475
3696
  var init_queue = __esm({
3476
3697
  "src/server/events/queue.ts"() {
3477
3698
  "use strict";
@@ -3482,8 +3703,8 @@ var init_queue = __esm({
3482
3703
  MCP_ORIGIN = "mcp";
3483
3704
  docObservers = /* @__PURE__ */ new Map();
3484
3705
  emittedPayloadIds = /* @__PURE__ */ new Map();
3485
- buffer = [];
3486
- subscribers = /* @__PURE__ */ new Set();
3706
+ buffer2 = [];
3707
+ subscribers2 = /* @__PURE__ */ new Set();
3487
3708
  ctrlCleanups = [];
3488
3709
  }
3489
3710
  });
@@ -3663,34 +3884,12 @@ function escapeRegex(str) {
3663
3884
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3664
3885
  }
3665
3886
 
3666
- // src/server/notifications.ts
3667
- init_constants();
3668
- var buffer2 = [];
3669
- var subscribers2 = /* @__PURE__ */ new Set();
3670
- function pushNotification(notification) {
3671
- buffer2.push(notification);
3672
- while (buffer2.length > NOTIFICATION_BUFFER_SIZE) {
3673
- buffer2.shift();
3674
- }
3675
- for (const cb of subscribers2) {
3676
- try {
3677
- cb(notification);
3678
- } catch (err) {
3679
- console.error("[Notifications] Subscriber threw during dispatch:", err);
3680
- }
3681
- }
3682
- }
3683
- function subscribe2(cb) {
3684
- subscribers2.add(cb);
3685
- return () => {
3686
- subscribers2.delete(cb);
3687
- };
3688
- }
3689
-
3690
3887
  // src/server/mcp/document.ts
3888
+ init_notifications();
3691
3889
  init_utils();
3692
3890
  init_offsets();
3693
3891
  init_file_io();
3892
+ init_file_watcher();
3694
3893
 
3695
3894
  // src/server/mcp/convert.ts
3696
3895
  init_provider();
@@ -3698,7 +3897,7 @@ init_document_model();
3698
3897
  init_file_io();
3699
3898
  init_file_opener();
3700
3899
  init_document_service();
3701
- import fs4 from "fs/promises";
3900
+ import fs5 from "fs/promises";
3702
3901
  import path7 from "path";
3703
3902
  async function findAvailablePath(basePath) {
3704
3903
  const dir = path7.dirname(basePath);
@@ -3709,7 +3908,7 @@ async function findAvailablePath(basePath) {
3709
3908
  let counter = 0;
3710
3909
  while (counter <= MAX_ATTEMPTS) {
3711
3910
  try {
3712
- await fs4.access(candidate);
3911
+ await fs5.access(candidate);
3713
3912
  counter++;
3714
3913
  candidate = path7.join(dir, `${name}-${counter}${ext}`);
3715
3914
  } catch (err) {
@@ -3758,7 +3957,7 @@ async function convertToMarkdown(documentId, outputPath) {
3758
3957
  });
3759
3958
  }
3760
3959
  try {
3761
- const stat = await fs4.stat(resolvedOutput);
3960
+ const stat = await fs5.stat(resolvedOutput);
3762
3961
  if (stat.isDirectory()) {
3763
3962
  const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
3764
3963
  resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
@@ -3772,7 +3971,7 @@ async function convertToMarkdown(documentId, outputPath) {
3772
3971
  resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
3773
3972
  }
3774
3973
  resolvedOutput = await findAvailablePath(resolvedOutput);
3775
- await atomicWrite(resolvedOutput, markdown);
3974
+ await atomicWrite2(resolvedOutput, markdown);
3776
3975
  try {
3777
3976
  const openResult = await openFileByPath(resolvedOutput);
3778
3977
  return {
@@ -4065,7 +4264,8 @@ function registerDocumentTools(server) {
4065
4264
  });
4066
4265
  }
4067
4266
  const output = adapter.save(r.doc);
4068
- await atomicWrite(r.filePath, output);
4267
+ suppressNextChange(r.filePath);
4268
+ await atomicWrite2(r.filePath, output);
4069
4269
  await saveSession(r.filePath, format, r.doc);
4070
4270
  const meta = r.doc.getMap(Y_MAP_DOCUMENT_META);
4071
4271
  r.doc.transact(() => meta.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
@@ -4202,6 +4402,7 @@ function registerDocumentTools(server) {
4202
4402
  init_docx();
4203
4403
  init_types2();
4204
4404
  init_positions2();
4405
+ init_notifications();
4205
4406
  init_utils();
4206
4407
  init_positions2();
4207
4408
  function getDocAndAnnotations(documentId) {
@@ -4583,6 +4784,7 @@ init_constants();
4583
4784
  init_document_model();
4584
4785
  init_file_opener();
4585
4786
  init_document_service();
4787
+ init_notifications();
4586
4788
 
4587
4789
  // src/server/mcp/docx-apply.ts
4588
4790
  init_document_service();
@@ -4591,7 +4793,7 @@ init_positions2();
4591
4793
  init_document_model();
4592
4794
  init_file_io();
4593
4795
  import { z as z4 } from "zod";
4594
- import fs5 from "fs/promises";
4796
+ import fs6 from "fs/promises";
4595
4797
  import path8 from "path";
4596
4798
  async function applyChangesCore(documentId, author, backupPath) {
4597
4799
  const r = requireDocument(documentId);
@@ -4673,21 +4875,21 @@ async function applyChangesCore(documentId, author, backupPath) {
4673
4875
  throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
4674
4876
  }
4675
4877
  const ydocFlatText = extractText(ydoc);
4676
- const buffer3 = await fs5.readFile(filePath);
4878
+ const buffer3 = await fs6.readFile(filePath);
4677
4879
  const result = await applyTrackedChanges(buffer3, suggestions, {
4678
4880
  author: author ?? "Tandem Review",
4679
4881
  ydocFlatText
4680
4882
  });
4681
4883
  let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
4682
4884
  try {
4683
- await fs5.access(resolvedBackup);
4885
+ await fs6.access(resolvedBackup);
4684
4886
  const ext = path8.extname(resolvedBackup);
4685
4887
  const base = resolvedBackup.slice(0, -ext.length);
4686
4888
  resolvedBackup = `${base}-${Date.now()}${ext}`;
4687
4889
  } catch {
4688
4890
  }
4689
- await fs5.copyFile(filePath, resolvedBackup);
4690
- const [origStat, backupStat] = await Promise.all([fs5.stat(filePath), fs5.stat(resolvedBackup)]);
4891
+ await fs6.copyFile(filePath, resolvedBackup);
4892
+ const [origStat, backupStat] = await Promise.all([fs6.stat(filePath), fs6.stat(resolvedBackup)]);
4691
4893
  if (origStat.size !== backupStat.size) {
4692
4894
  throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
4693
4895
  code: "BACKUP_FAILED"
@@ -4742,17 +4944,17 @@ function registerApplyTools(server) {
4742
4944
  const { filePath } = r;
4743
4945
  const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
4744
4946
  try {
4745
- await fs5.access(backupPath);
4947
+ await fs6.access(backupPath);
4746
4948
  } catch (err) {
4747
4949
  if (err.code === "ENOENT") {
4748
4950
  return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
4749
4951
  }
4750
4952
  throw err;
4751
4953
  }
4752
- await fs5.copyFile(backupPath, filePath);
4954
+ await fs6.copyFile(backupPath, filePath);
4753
4955
  const [backupStat, restoredStat] = await Promise.all([
4754
- fs5.stat(backupPath),
4755
- fs5.stat(filePath)
4956
+ fs6.stat(backupPath),
4957
+ fs6.stat(filePath)
4756
4958
  ]);
4757
4959
  if (backupStat.size !== restoredStat.size) {
4758
4960
  throw new Error("Restore verification failed: file sizes do not match.");
@@ -4856,7 +5058,7 @@ function notifyStreamHandler(req, res) {
4856
5058
  clearInterval(keepalive);
4857
5059
  unsubscribe2();
4858
5060
  }
4859
- const unsubscribe2 = subscribe2((notification) => {
5061
+ const unsubscribe2 = subscribe((notification) => {
4860
5062
  try {
4861
5063
  res.write(`data: ${JSON.stringify(notification)}
4862
5064
 
@@ -5216,7 +5418,7 @@ function sseHandler(req, res) {
5216
5418
  unsubscribe(onEvent);
5217
5419
  }
5218
5420
  };
5219
- subscribe(onEvent);
5421
+ subscribe2(onEvent);
5220
5422
  keepalive = setInterval(() => {
5221
5423
  res.write(": keepalive\n\n");
5222
5424
  }, CHANNEL_SSE_KEEPALIVE_MS);
@@ -5680,12 +5882,12 @@ init_manager();
5680
5882
  init_platform();
5681
5883
 
5682
5884
  // src/server/version-check.ts
5683
- import fs6 from "fs/promises";
5885
+ import fs7 from "fs/promises";
5684
5886
  import path9 from "path";
5685
5887
  async function checkVersionChange(currentVersion, versionFilePath) {
5686
5888
  let storedVersion = null;
5687
5889
  try {
5688
- storedVersion = (await fs6.readFile(versionFilePath, "utf-8")).trim();
5890
+ storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
5689
5891
  } catch (err) {
5690
5892
  if (err.code !== "ENOENT") {
5691
5893
  console.error("[Tandem] Failed to read last-seen-version:", err);
@@ -5693,8 +5895,8 @@ async function checkVersionChange(currentVersion, versionFilePath) {
5693
5895
  }
5694
5896
  const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
5695
5897
  if (result !== "current") {
5696
- await fs6.mkdir(path9.dirname(versionFilePath), { recursive: true });
5697
- await fs6.writeFile(versionFilePath, currentVersion, "utf-8");
5898
+ await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
5899
+ await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
5698
5900
  }
5699
5901
  return result;
5700
5902
  }
@@ -5718,6 +5920,7 @@ function isKnownHocuspocusError(err) {
5718
5920
  // src/server/index.ts
5719
5921
  init_queue();
5720
5922
  init_document_service();
5923
+ init_file_watcher();
5721
5924
  init_file_opener();
5722
5925
  init_document_model();
5723
5926
 
@@ -5855,6 +6058,7 @@ async function shutdown(signal) {
5855
6058
  isShuttingDown = true;
5856
6059
  console.error(`[Tandem] ${signal} received, saving session...`);
5857
6060
  try {
6061
+ unwatchAll();
5858
6062
  await saveCurrentSession();
5859
6063
  stopAutoSave();
5860
6064
  } catch (err) {