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.
- package/CHANGELOG.md +35 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-CfGlbY9B.js +297 -0
- package/dist/client/index.html +1 -1
- package/dist/server/index.js +318 -114
- package/dist/server/index.js.map +1 -1
- package/package.json +122 -122
- package/dist/client/assets/index-ChvL-huP.js +0 -297
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
2780
|
+
async function atomicWrite2(filePath, content) {
|
|
2685
2781
|
const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2686
|
-
await
|
|
2687
|
-
await
|
|
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
|
|
2787
|
+
await fs3.writeFile(tempPath, content);
|
|
2692
2788
|
try {
|
|
2693
|
-
await
|
|
2789
|
+
await fs3.rename(tempPath, filePath);
|
|
2694
2790
|
} catch (err) {
|
|
2695
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3490
|
+
buffer2.push(event);
|
|
3270
3491
|
trackPayloadId(event);
|
|
3271
|
-
while (
|
|
3272
|
-
const evicted =
|
|
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 (
|
|
3277
|
-
const evicted =
|
|
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
|
|
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
|
|
3289
|
-
|
|
3509
|
+
function subscribe2(cb) {
|
|
3510
|
+
subscribers2.add(cb);
|
|
3290
3511
|
}
|
|
3291
3512
|
function unsubscribe(cb) {
|
|
3292
|
-
|
|
3513
|
+
subscribers2.delete(cb);
|
|
3293
3514
|
}
|
|
3294
3515
|
function replaySince(lastEventId) {
|
|
3295
|
-
const idx =
|
|
3296
|
-
if (idx === -1) return [...
|
|
3297
|
-
return
|
|
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,
|
|
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
|
-
|
|
3486
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4690
|
-
const [origStat, backupStat] = await Promise.all([
|
|
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
|
|
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
|
|
4954
|
+
await fs6.copyFile(backupPath, filePath);
|
|
4753
4955
|
const [backupStat, restoredStat] = await Promise.all([
|
|
4754
|
-
|
|
4755
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5697
|
-
await
|
|
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) {
|