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.
- package/CHANGELOG.md +25 -0
- package/README.md +41 -23
- package/dist/channel/index.js +18 -50
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +6 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-BS8jwldm.js +345 -0
- package/dist/client/assets/webview-0tvvWtyc.js +1 -0
- package/dist/client/index.html +1 -1
- package/dist/server/index.js +898 -588
- package/dist/server/index.js.map +1 -1
- package/package.json +10 -7
- package/sample/welcome.md +3 -3
- package/dist/client/assets/index-D6wQrQ7U.js +0 -308
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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 = (
|
|
36371
|
+
callAll = (fs9, args2, i = 0) => {
|
|
36364
36372
|
try {
|
|
36365
|
-
for (; i <
|
|
36366
|
-
|
|
36373
|
+
for (; i < fs9.length; i++) {
|
|
36374
|
+
fs9[i](...args2);
|
|
36367
36375
|
}
|
|
36368
36376
|
} finally {
|
|
36369
|
-
if (i <
|
|
36370
|
-
callAll(
|
|
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
|
|
38403
|
+
const fs9 = [];
|
|
38396
38404
|
transaction.changed.forEach(
|
|
38397
|
-
(subs, itemtype) =>
|
|
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
|
-
|
|
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
|
-
|
|
38422
|
+
fs9.push(() => {
|
|
38415
38423
|
callEventHandlerListeners(type2._dEH, events, transaction);
|
|
38416
38424
|
});
|
|
38417
38425
|
}
|
|
38418
38426
|
});
|
|
38419
|
-
|
|
38420
|
-
|
|
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(
|
|
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
|
|
67964
|
-
import
|
|
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
|
|
67969
|
-
const tempPath =
|
|
67970
|
-
await
|
|
67971
|
-
await
|
|
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 =
|
|
67975
|
-
await
|
|
68416
|
+
const tempPath = path6.join(path6.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
68417
|
+
await fs2.writeFile(tempPath, content3);
|
|
67976
68418
|
try {
|
|
67977
|
-
await
|
|
68419
|
+
await fs2.rename(tempPath, filePath);
|
|
67978
68420
|
} catch (err) {
|
|
67979
|
-
await
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
69007
|
-
await saveSession(docState.filePath, docState.format, doc);
|
|
69105
|
+
await deleteSession(docState.filePath);
|
|
69008
69106
|
} catch (err) {
|
|
69009
|
-
console.error(`[Tandem] Failed to
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
69480
|
-
|
|
69481
|
-
|
|
69482
|
-
|
|
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
|
-
|
|
69507
|
-
|
|
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
|
-
}
|
|
69510
|
-
|
|
69511
|
-
|
|
69512
|
-
|
|
69513
|
-
|
|
69514
|
-
|
|
69515
|
-
|
|
69516
|
-
message: `
|
|
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
|
-
|
|
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(() =>
|
|
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)
|
|
70416
|
+
if (!selection || selection.from === selection.to) {
|
|
70417
|
+
selectionBuffer.delete(docName);
|
|
70418
|
+
return;
|
|
70419
|
+
}
|
|
70196
70420
|
selectionDwellTimer = setTimeout(() => {
|
|
70197
70421
|
selectionDwellTimer = null;
|
|
70198
|
-
|
|
70199
|
-
|
|
70200
|
-
|
|
70201
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
112731
|
-
const [origStat, backupStat] = await Promise.all([
|
|
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
|
|
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
|
|
113044
|
+
await fs7.copyFile(backupPath, filePath);
|
|
112794
113045
|
const [backupStat, restoredStat] = await Promise.all([
|
|
112795
|
-
|
|
112796
|
-
|
|
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(
|
|
113608
|
-
|
|
113609
|
-
|
|
113610
|
-
|
|
113611
|
-
|
|
113612
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
113916
|
-
await
|
|
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
|
}
|