@json-to-office/mcp-server 1.5.0 → 1.8.1

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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/server";
3
3
 
4
4
  // src/lib/version.ts
5
- var SERVER_VERSION = true ? "1.5.0" : "dev-mode";
5
+ var SERVER_VERSION = true ? "1.8.1" : "dev-mode";
6
6
  var SERVER_NAME = "json-to-office";
7
7
  var PACKAGE_NAME = "@json-to-office/mcp-server";
8
8
 
@@ -415,9 +415,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
415
415
  function escapePointerSegment(segment) {
416
416
  return segment.replace(/~/g, "~0").replace(/\//g, "~1");
417
417
  }
418
- function toJsonPointer(path6) {
419
- if (path6 === void 0) return void 0;
420
- const trimmed = path6.trim();
418
+ function toJsonPointer(path7) {
419
+ if (path7 === void 0) return void 0;
420
+ const trimmed = path7.trim();
421
421
  if (ROOT_SENTINELS.has(trimmed)) return "";
422
422
  const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
423
423
  if (segments.length === 0) return "";
@@ -618,11 +618,11 @@ async function probeService(rawUrl, envVar) {
618
618
  }
619
619
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
620
620
  const net = await import("net");
621
- return new Promise((resolve2) => {
621
+ return new Promise((resolve3) => {
622
622
  const socket = net.connect({ host: target.hostname, port });
623
623
  const settle = (detail) => {
624
624
  socket.destroy();
625
- resolve2({
625
+ resolve3({
626
626
  available: detail === void 0,
627
627
  url: target.origin,
628
628
  envVar,
@@ -662,7 +662,7 @@ function register(server, deps) {
662
662
  "jto_info",
663
663
  {
664
664
  title: "Server info",
665
- description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
665
+ description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability and whether workspaces survive a lost connection, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
666
666
  annotations: { readOnlyHint: true, openWorldHint: false },
667
667
  inputSchema: S({
668
668
  type: "object",
@@ -747,9 +747,17 @@ function register(server, deps) {
747
747
  type: "object",
748
748
  properties: {
749
749
  available: { type: "boolean" },
750
- open: { type: "integer" }
750
+ open: { type: "integer" },
751
+ persistent: {
752
+ type: "boolean",
753
+ description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
754
+ },
755
+ root: {
756
+ type: "string",
757
+ description: "Directory the revisions are mirrored to, when persistent."
758
+ }
751
759
  },
752
- required: ["available", "open"],
760
+ required: ["available", "open", "persistent"],
753
761
  additionalProperties: false
754
762
  },
755
763
  output: {
@@ -910,7 +918,11 @@ function register(server, deps) {
910
918
  formats,
911
919
  workspaces: {
912
920
  available: store.available,
913
- open: listed.ok ? listed.records.length : 0
921
+ open: listed.ok ? listed.records.length : 0,
922
+ persistent: deps.workspacePersistence !== void 0,
923
+ ...deps.workspacePersistence && {
924
+ root: deps.workspacePersistence.root
925
+ }
914
926
  },
915
927
  output: {
916
928
  root: deps.outputRoot.path,
@@ -2025,14 +2037,14 @@ function sourceSummary(resolved) {
2025
2037
  revision: resolved.revision
2026
2038
  } : { origin: "inline" };
2027
2039
  }
2028
- function parseDocumentJson(text, path6) {
2040
+ function parseDocumentJson(text, path7) {
2029
2041
  try {
2030
2042
  return { ok: true, document: JSON.parse(text) };
2031
2043
  } catch (error) {
2032
2044
  return failure(
2033
2045
  ERROR_CODES.INVALID_JSON,
2034
2046
  error instanceof Error ? error.message : String(error),
2035
- { ...path6 !== void 0 && { path: path6 } }
2047
+ { ...path7 !== void 0 && { path: path7 } }
2036
2048
  );
2037
2049
  }
2038
2050
  }
@@ -3148,9 +3160,9 @@ async function digestAssets(document, baseDir) {
3148
3160
  [...references].map(async (reference) => {
3149
3161
  const resolved = path4.resolve(root, reference);
3150
3162
  try {
3151
- const stat = await fs4.stat(resolved);
3152
- if (!stat.isFile()) return;
3153
- entries.push(`${resolved}|${stat.size}|${stat.mtimeMs}`);
3163
+ const stat2 = await fs4.stat(resolved);
3164
+ if (!stat2.isFile()) return;
3165
+ entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
3154
3166
  } catch {
3155
3167
  }
3156
3168
  })
@@ -3180,8 +3192,8 @@ function collectAssetReferences(value, into) {
3180
3192
  async function digestThemeFile(themePath) {
3181
3193
  if (!themePath) return "none";
3182
3194
  try {
3183
- const stat = await fs4.stat(themePath);
3184
- return `${path4.resolve(themePath)}|${stat.size}|${stat.mtimeMs}`;
3195
+ const stat2 = await fs4.stat(themePath);
3196
+ return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
3185
3197
  } catch {
3186
3198
  return `${path4.resolve(themePath)}|missing`;
3187
3199
  }
@@ -3253,18 +3265,18 @@ function missingDependencyFailure(dependencies) {
3253
3265
  );
3254
3266
  }
3255
3267
  async function binaryIdentity(binary, args, signal) {
3256
- const [version, stat] = await Promise.all([
3268
+ const [version, stat2] = await Promise.all([
3257
3269
  readVersion(binary, args, signal),
3258
3270
  fs5.stat(binary).catch(() => void 0)
3259
3271
  ]);
3260
3272
  const identity = [
3261
3273
  version ?? "unknown",
3262
- stat ? `${stat.size}:${stat.mtimeMs}` : "nostat"
3274
+ stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
3263
3275
  ].join("|");
3264
3276
  return { identity, ...version !== void 0 && { version } };
3265
3277
  }
3266
3278
  function readVersion(binary, args, signal) {
3267
- return new Promise((resolve2) => {
3279
+ return new Promise((resolve3) => {
3268
3280
  execFile(
3269
3281
  binary,
3270
3282
  args,
@@ -3278,8 +3290,8 @@ function readVersion(binary, args, signal) {
3278
3290
  const text = `${stdout ?? ""}
3279
3291
  ${stderr ?? ""}`.trim();
3280
3292
  const line = text.split("\n")[0]?.trim();
3281
- if (!line && error) return resolve2(void 0);
3282
- resolve2(line || void 0);
3293
+ if (!line && error) return resolve3(void 0);
3294
+ resolve3(line || void 0);
3283
3295
  }
3284
3296
  );
3285
3297
  });
@@ -3324,12 +3336,12 @@ function defaultPreviewCacheDir() {
3324
3336
  }
3325
3337
  var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
3326
3338
  async function inspectPrivateCacheDir(cacheDir) {
3327
- const stat = await fs6.lstat(cacheDir).catch(() => void 0);
3328
- if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return false;
3329
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
3339
+ const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
3340
+ if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
3341
+ if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
3330
3342
  return false;
3331
3343
  }
3332
- if (process.platform !== "win32" && (stat.mode & 63) !== 0) {
3344
+ if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
3333
3345
  try {
3334
3346
  await fs6.chmod(cacheDir, 448);
3335
3347
  } catch {
@@ -3359,9 +3371,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
3359
3371
  for (const name of names) {
3360
3372
  if (!CACHE_ENTRY_NAME.test(name)) continue;
3361
3373
  const file = path5.join(cacheDir, name);
3362
- const stat = await fs6.lstat(file).catch(() => void 0);
3363
- if (!stat?.isFile()) continue;
3364
- entries.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
3374
+ const stat2 = await fs6.lstat(file).catch(() => void 0);
3375
+ if (!stat2?.isFile()) continue;
3376
+ entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
3365
3377
  }
3366
3378
  const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
3367
3379
  const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
@@ -3448,7 +3460,7 @@ function countPdfPages(pdf) {
3448
3460
  return matches ? matches.length : 0;
3449
3461
  }
3450
3462
  function exec(binary, args, timeoutMs, options = {}) {
3451
- return new Promise((resolve2, reject) => {
3463
+ return new Promise((resolve3, reject) => {
3452
3464
  execFile2(
3453
3465
  binary,
3454
3466
  args,
@@ -3459,7 +3471,7 @@ function exec(binary, args, timeoutMs, options = {}) {
3459
3471
  env: options.env ? { ...process.env, ...options.env } : process.env,
3460
3472
  ...options.signal && { signal: options.signal }
3461
3473
  },
3462
- (error) => error ? reject(error) : resolve2()
3474
+ (error) => error ? reject(error) : resolve3()
3463
3475
  );
3464
3476
  });
3465
3477
  }
@@ -3553,7 +3565,7 @@ async function resolvePdfinfo(pdftoppmPath) {
3553
3565
  return status.available ? status.path : void 0;
3554
3566
  }
3555
3567
  async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3556
- return new Promise((resolve2) => {
3568
+ return new Promise((resolve3) => {
3557
3569
  execFile2(
3558
3570
  pdfinfo,
3559
3571
  [pdfPath],
@@ -3564,9 +3576,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3564
3576
  ...signal && { signal }
3565
3577
  },
3566
3578
  (error, stdout) => {
3567
- if (error && !stdout) return resolve2(void 0);
3579
+ if (error && !stdout) return resolve3(void 0);
3568
3580
  const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
3569
- resolve2(match ? Number(match[1]) : void 0);
3581
+ resolve3(match ? Number(match[1]) : void 0);
3570
3582
  }
3571
3583
  );
3572
3584
  });
@@ -4679,9 +4691,9 @@ function compilePatch(operations) {
4679
4691
  }
4680
4692
  );
4681
4693
  }
4682
- const path6 = parsePointer(raw.path);
4683
- if (!path6.ok) {
4684
- return problem(PATCH_ERROR_CODES.INVALID_POINTER, path6.message, index, {
4694
+ const path7 = parsePointer(raw.path);
4695
+ if (!path7.ok) {
4696
+ return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
4685
4697
  context: { op, pointer: raw.path },
4686
4698
  suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
4687
4699
  });
@@ -4689,7 +4701,7 @@ function compilePatch(operations) {
4689
4701
  const entry = {
4690
4702
  op,
4691
4703
  path: raw.path,
4692
- tokens: path6.tokens
4704
+ tokens: path7.tokens
4693
4705
  };
4694
4706
  if (VALUE_OPS.has(op)) {
4695
4707
  if (raw.value === void 0) {
@@ -5039,12 +5051,345 @@ function jsonEqualTokens(left, right) {
5039
5051
  }
5040
5052
 
5041
5053
  // src/workspace/store.ts
5054
+ import { randomBytes as randomBytes3 } from "crypto";
5055
+
5056
+ // src/workspace/persistence.ts
5057
+ import * as fs7 from "fs/promises";
5058
+ import * as path6 from "path";
5042
5059
  import { randomBytes as randomBytes2 } from "crypto";
5060
+ var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
5061
+ var SCHEMA_VERSION = 1;
5062
+ var META_FILE = "meta.json";
5063
+ var REVISION_PREFIX = "rev-";
5064
+ var REVISION_SUFFIX = ".json";
5065
+ var TEMP_SUFFIX = ".tmp";
5066
+ var STALE_TEMP_MS = 60 * 60 * 1e3;
5067
+ var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
5068
+ function isPersistableHandle(handle) {
5069
+ return SAFE_HANDLE.test(handle);
5070
+ }
5071
+ var DEFAULT_PERSISTENCE_LIMITS = {
5072
+ maxWorkspaces: 32,
5073
+ maxRevisionsPerWorkspace: 9,
5074
+ maxEntryBytes: 16 * 1024 * 1024
5075
+ };
5076
+ function createWorkspacePersistence(options = {}) {
5077
+ const env = options.env ?? process.env;
5078
+ const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
5079
+ if (!configured) return void 0;
5080
+ return createWorkspacePersistenceAt(path6.resolve(configured), options);
5081
+ }
5082
+ function createWorkspacePersistenceAt(root, limits = {}) {
5083
+ const bounds = {
5084
+ maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
5085
+ maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
5086
+ maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
5087
+ };
5088
+ let ensured;
5089
+ const authored = /* @__PURE__ */ new Map();
5090
+ function claim(handle, revision) {
5091
+ const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
5092
+ owned.add(revision);
5093
+ authored.set(handle, owned);
5094
+ }
5095
+ async function ensureRoot() {
5096
+ if (ensured === void 0) {
5097
+ ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
5098
+ () => void 0,
5099
+ (error) => {
5100
+ ensured = void 0;
5101
+ throw error;
5102
+ }
5103
+ );
5104
+ }
5105
+ return ensured;
5106
+ }
5107
+ function dirFor(handle) {
5108
+ return path6.join(root, handle);
5109
+ }
5110
+ async function writeAtomic2(target, contents) {
5111
+ const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
5112
+ try {
5113
+ await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
5114
+ await fs7.rename(temp, target);
5115
+ } catch (error) {
5116
+ await fs7.rm(temp, { force: true }).catch(() => void 0);
5117
+ throw error;
5118
+ }
5119
+ }
5120
+ function revisionFile(handle, revision) {
5121
+ return path6.join(
5122
+ dirFor(handle),
5123
+ `${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
5124
+ );
5125
+ }
5126
+ async function readMeta(handle) {
5127
+ let raw;
5128
+ try {
5129
+ raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
5130
+ } catch {
5131
+ return void 0;
5132
+ }
5133
+ return parseMeta(raw, handle);
5134
+ }
5135
+ async function readRevision(handle, revision) {
5136
+ let text;
5137
+ try {
5138
+ text = await fs7.readFile(revisionFile(handle, revision), "utf8");
5139
+ } catch {
5140
+ return void 0;
5141
+ }
5142
+ try {
5143
+ const parsed = JSON.parse(text);
5144
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
5145
+ return void 0;
5146
+ } catch {
5147
+ return void 0;
5148
+ }
5149
+ return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
5150
+ }
5151
+ async function handleDirs() {
5152
+ try {
5153
+ const entries = await fs7.readdir(root, { withFileTypes: true });
5154
+ return entries.filter(
5155
+ (entry) => entry.isDirectory() && isPersistableHandle(entry.name)
5156
+ ).map((entry) => entry.name);
5157
+ } catch {
5158
+ return [];
5159
+ }
5160
+ }
5161
+ async function pruneWorkspaces(incoming) {
5162
+ const handles = (await handleDirs()).filter(
5163
+ (handle) => handle !== incoming
5164
+ );
5165
+ const excess = handles.length + 1 - bounds.maxWorkspaces;
5166
+ if (excess <= 0) return;
5167
+ const metas = await Promise.all(
5168
+ handles.map(async (handle) => ({
5169
+ handle,
5170
+ // An unreadable entry sorts first: it is the one worth losing.
5171
+ updatedAt: (await readMeta(handle))?.updatedAt ?? 0
5172
+ }))
5173
+ );
5174
+ metas.sort((a, b) => a.updatedAt - b.updatedAt);
5175
+ for (const victim of metas.slice(0, excess)) {
5176
+ await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
5177
+ authored.delete(victim.handle);
5178
+ }
5179
+ }
5180
+ async function pruneRevisions(handle, retained) {
5181
+ let names;
5182
+ try {
5183
+ names = await fs7.readdir(dirFor(handle));
5184
+ } catch {
5185
+ return;
5186
+ }
5187
+ const owned = authored.get(handle);
5188
+ for (const name of names) {
5189
+ const target = path6.join(dirFor(handle), name);
5190
+ if (name.endsWith(TEMP_SUFFIX)) {
5191
+ if (await isStale(target)) {
5192
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5193
+ }
5194
+ continue;
5195
+ }
5196
+ const revision = revisionOf(name);
5197
+ if (revision === void 0 || retained.has(revision)) continue;
5198
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5199
+ owned?.delete(revision);
5200
+ }
5201
+ }
5202
+ return {
5203
+ root,
5204
+ limits: bounds,
5205
+ async save(snapshot) {
5206
+ if (!isPersistableHandle(snapshot.handle)) {
5207
+ throw new Error(
5208
+ `Workspace handle ${JSON.stringify(
5209
+ snapshot.handle
5210
+ )} cannot be used as a directory name.`
5211
+ );
5212
+ }
5213
+ if (snapshot.head.bytes > bounds.maxEntryBytes) {
5214
+ throw new Error(
5215
+ `Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
5216
+ );
5217
+ }
5218
+ await ensureRoot();
5219
+ const dir = dirFor(snapshot.handle);
5220
+ const fresh = !await exists(dir);
5221
+ if (fresh) await pruneWorkspaces(snapshot.handle);
5222
+ await fs7.mkdir(dir, { recursive: true, mode: 448 });
5223
+ const files = [snapshot.head];
5224
+ const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
5225
+ const pinned = [];
5226
+ for (const pin of [...snapshot.pins].sort(
5227
+ (a, b) => b.revision - a.revision
5228
+ )) {
5229
+ if (!retained.has(pin.revision)) {
5230
+ if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
5231
+ files.push(pin);
5232
+ retained.add(pin.revision);
5233
+ }
5234
+ pinned.push(pin.revision);
5235
+ }
5236
+ const owned = authored.get(snapshot.handle);
5237
+ for (const document of files) {
5238
+ if (owned?.has(document.revision)) continue;
5239
+ await writeAtomic2(
5240
+ revisionFile(snapshot.handle, document.revision),
5241
+ document.text
5242
+ );
5243
+ claim(snapshot.handle, document.revision);
5244
+ }
5245
+ const meta = {
5246
+ schema: SCHEMA_VERSION,
5247
+ handle: snapshot.handle,
5248
+ format: snapshot.format,
5249
+ revision: snapshot.head.revision,
5250
+ bytes: snapshot.head.bytes,
5251
+ createdAt: snapshot.createdAt,
5252
+ updatedAt: snapshot.updatedAt,
5253
+ ...snapshot.title !== void 0 && { title: snapshot.title },
5254
+ pinnedRevisions: pinned.sort((a, b) => a - b)
5255
+ };
5256
+ await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
5257
+ await pruneRevisions(snapshot.handle, retained);
5258
+ },
5259
+ async list() {
5260
+ const handles = await handleDirs();
5261
+ const metas = await Promise.all(
5262
+ handles.map((handle) => readMeta(handle))
5263
+ );
5264
+ return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
5265
+ },
5266
+ async load(handle) {
5267
+ if (!isPersistableHandle(handle)) return void 0;
5268
+ let meta = await readMeta(handle);
5269
+ if (!meta) return void 0;
5270
+ let head = await readRevision(handle, meta.revision);
5271
+ if (!head) {
5272
+ const second = await readMeta(handle);
5273
+ if (second) {
5274
+ meta = second;
5275
+ head = await readRevision(handle, second.revision);
5276
+ }
5277
+ }
5278
+ if (!head) {
5279
+ await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
5280
+ return void 0;
5281
+ }
5282
+ const pins = [];
5283
+ for (const revision of meta.pinnedRevisions) {
5284
+ const pin = await readRevision(handle, revision);
5285
+ if (pin) pins.push(pin);
5286
+ }
5287
+ return {
5288
+ ...meta,
5289
+ // A pin whose file went missing is dropped rather than advertised: the
5290
+ // record has to describe what can actually be read back.
5291
+ pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
5292
+ head,
5293
+ pins
5294
+ };
5295
+ },
5296
+ async remove(handle) {
5297
+ if (!isPersistableHandle(handle)) return false;
5298
+ const dir = dirFor(handle);
5299
+ if (!await exists(dir)) {
5300
+ authored.delete(handle);
5301
+ return false;
5302
+ }
5303
+ await fs7.rm(dir, { recursive: true, force: true });
5304
+ authored.delete(handle);
5305
+ return true;
5306
+ }
5307
+ };
5308
+ }
5309
+ async function exists(target) {
5310
+ try {
5311
+ await fs7.stat(target);
5312
+ return true;
5313
+ } catch {
5314
+ return false;
5315
+ }
5316
+ }
5317
+ async function isStale(target) {
5318
+ try {
5319
+ const { mtimeMs } = await fs7.stat(target);
5320
+ return Date.now() - mtimeMs > STALE_TEMP_MS;
5321
+ } catch {
5322
+ return false;
5323
+ }
5324
+ }
5325
+ function revisionOf(name) {
5326
+ if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
5327
+ return void 0;
5328
+ }
5329
+ const digits = name.slice(
5330
+ REVISION_PREFIX.length,
5331
+ name.length - REVISION_SUFFIX.length
5332
+ );
5333
+ if (!/^[0-9]+$/.test(digits)) return void 0;
5334
+ return Number(digits);
5335
+ }
5336
+ function parseMeta(raw, handle) {
5337
+ let parsed;
5338
+ try {
5339
+ parsed = JSON.parse(raw);
5340
+ } catch {
5341
+ return void 0;
5342
+ }
5343
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5344
+ return void 0;
5345
+ }
5346
+ const meta = parsed;
5347
+ if (meta.schema !== SCHEMA_VERSION) return void 0;
5348
+ if (meta.handle !== handle) return void 0;
5349
+ if (!FORMAT_NAMES.includes(meta.format)) return void 0;
5350
+ if (!isPositiveInteger(meta.revision)) return void 0;
5351
+ if (!isNonNegativeInteger(meta.bytes)) return void 0;
5352
+ if (!isNonNegativeInteger(meta.createdAt)) return void 0;
5353
+ if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
5354
+ if (meta.title !== void 0 && typeof meta.title !== "string")
5355
+ return void 0;
5356
+ const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
5357
+ return {
5358
+ handle,
5359
+ format: meta.format,
5360
+ revision: meta.revision,
5361
+ bytes: meta.bytes,
5362
+ createdAt: meta.createdAt,
5363
+ updatedAt: meta.updatedAt,
5364
+ ...typeof meta.title === "string" && { title: meta.title },
5365
+ pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
5366
+ };
5367
+ }
5368
+ function isPositiveInteger(value) {
5369
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
5370
+ }
5371
+ function isNonNegativeInteger(value) {
5372
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
5373
+ }
5374
+
5375
+ // src/workspace/store.ts
5043
5376
  var WORKSPACE_ERROR_CODES = {
5044
5377
  EVICTED: "E_WORKSPACE_EVICTED",
5045
5378
  LIMIT: "E_WORKSPACE_LIMIT",
5046
5379
  DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
5047
- INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
5380
+ INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
5381
+ /**
5382
+ * A revision could not be mirrored to disk (#290). A warning, never a
5383
+ * failure: the edit landed and the workspace is usable, but this connection
5384
+ * has stopped being survivable and saying so is the whole point.
5385
+ */
5386
+ NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
5387
+ /**
5388
+ * A close could not delete the durable copy, so nothing was released (#290).
5389
+ * An error rather than a warning: the agent asked for the document to be
5390
+ * destroyed, and it is still there to be read by the next connection.
5391
+ */
5392
+ NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
5048
5393
  };
5049
5394
  var DEFAULT_WORKSPACE_LIMITS = {
5050
5395
  maxWorkspaces: 16,
@@ -5064,6 +5409,7 @@ function createMemoryWorkspaceStore(options = {}) {
5064
5409
  };
5065
5410
  const now = options.now ?? Date.now;
5066
5411
  const newHandle = options.newHandle ?? defaultHandle;
5412
+ const persistence = options.persistence;
5067
5413
  const entries = /* @__PURE__ */ new Map();
5068
5414
  const tombstones = /* @__PURE__ */ new Map();
5069
5415
  function footprint(entry) {
@@ -5076,12 +5422,8 @@ function createMemoryWorkspaceStore(options = {}) {
5076
5422
  for (const entry of entries.values()) total += footprint(entry);
5077
5423
  return total;
5078
5424
  }
5079
- function tombstone(entry, reason) {
5080
- tombstones.set(entry.handle, {
5081
- reason,
5082
- at: now(),
5083
- revision: entry.revision
5084
- });
5425
+ function tombstone(handle, reason, revision) {
5426
+ tombstones.set(handle, { reason, at: now(), revision });
5085
5427
  while (tombstones.size > MAX_TOMBSTONES) {
5086
5428
  const oldest = tombstones.keys().next();
5087
5429
  if (oldest.done) break;
@@ -5091,7 +5433,7 @@ function createMemoryWorkspaceStore(options = {}) {
5091
5433
  function evict(entry, reason) {
5092
5434
  entries.delete(entry.handle);
5093
5435
  tombstones.delete(entry.handle);
5094
- tombstone(entry, reason);
5436
+ tombstone(entry.handle, reason, entry.revision);
5095
5437
  }
5096
5438
  function sweep() {
5097
5439
  const at = now();
@@ -5099,6 +5441,130 @@ function createMemoryWorkspaceStore(options = {}) {
5099
5441
  if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
5100
5442
  }
5101
5443
  }
5444
+ let durable = Promise.resolve();
5445
+ function queued(work) {
5446
+ const running = durable.then(work);
5447
+ durable = running.catch(() => void 0);
5448
+ return running;
5449
+ }
5450
+ async function persist(entry) {
5451
+ if (!persistence) return void 0;
5452
+ try {
5453
+ const written = await queued(async () => {
5454
+ if (entries.get(entry.handle) !== entry) return false;
5455
+ await saveEntry(entry);
5456
+ return true;
5457
+ });
5458
+ if (!written) {
5459
+ entry.persisted = false;
5460
+ return void 0;
5461
+ }
5462
+ entry.persisted = true;
5463
+ return void 0;
5464
+ } catch (error) {
5465
+ entry.persisted = false;
5466
+ return notPersisted(entry, error);
5467
+ }
5468
+ }
5469
+ async function saveEntry(entry) {
5470
+ if (!persistence) return;
5471
+ await persistence.save({
5472
+ handle: entry.handle,
5473
+ format: entry.format,
5474
+ createdAt: entry.createdAt,
5475
+ updatedAt: entry.updatedAt,
5476
+ ...entry.title !== void 0 && { title: entry.title },
5477
+ head: {
5478
+ revision: entry.revision,
5479
+ text: entry.text,
5480
+ bytes: entry.bytes
5481
+ },
5482
+ pins: [...entry.pins.entries()].map(([revision, pin]) => ({
5483
+ revision,
5484
+ text: pin.text,
5485
+ bytes: pin.bytes
5486
+ }))
5487
+ });
5488
+ }
5489
+ function notPersisted(entry, error) {
5490
+ const root = persistence?.root ?? "";
5491
+ return diagnostic(
5492
+ WORKSPACE_ERROR_CODES.NOT_PERSISTED,
5493
+ `Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${root}: ${error instanceof Error ? error.message : String(error)}`,
5494
+ {
5495
+ severity: "warning",
5496
+ suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
5497
+ context: {
5498
+ handle: entry.handle,
5499
+ revision: entry.revision,
5500
+ workspaceRoot: root
5501
+ }
5502
+ }
5503
+ );
5504
+ }
5505
+ function committed(record, warning) {
5506
+ return { ok: true, record, ...warning && { warnings: [warning] } };
5507
+ }
5508
+ async function rehydrate(handle) {
5509
+ if (!persistence || !isPersistableHandle(handle)) return void 0;
5510
+ let loaded;
5511
+ try {
5512
+ loaded = await persistence.load(handle);
5513
+ } catch {
5514
+ return void 0;
5515
+ }
5516
+ if (!loaded) return void 0;
5517
+ const restored = loaded;
5518
+ const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
5519
+ const needed = pins.reduce(
5520
+ (total, pin) => total + pin.bytes,
5521
+ restored.head.bytes
5522
+ );
5523
+ if (restored.head.bytes > limits.maxDocumentBytes) {
5524
+ return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
5525
+ }
5526
+ if (!hasRoom(needed, true)) {
5527
+ return failure(
5528
+ WORKSPACE_ERROR_CODES.LIMIT,
5529
+ `Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
5530
+ {
5531
+ suggestion: "Close a workspace you have finished with, then use this handle again.",
5532
+ context: {
5533
+ handle,
5534
+ bytes: needed,
5535
+ maxTotalBytes: limits.maxTotalBytes,
5536
+ maxWorkspaces: limits.maxWorkspaces
5537
+ }
5538
+ }
5539
+ );
5540
+ }
5541
+ const entry = {
5542
+ handle,
5543
+ format: restored.format,
5544
+ revision: restored.head.revision,
5545
+ text: restored.head.text,
5546
+ bytes: restored.head.bytes,
5547
+ createdAt: restored.createdAt,
5548
+ updatedAt: restored.updatedAt,
5549
+ touchedAt: now(),
5550
+ ...restored.title !== void 0 && { title: restored.title },
5551
+ pins: new Map(
5552
+ pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
5553
+ ),
5554
+ persisted: true
5555
+ };
5556
+ entries.set(handle, entry);
5557
+ tombstones.delete(handle);
5558
+ return { ok: true, entry };
5559
+ }
5560
+ async function resolve3(handle) {
5561
+ const entry = entries.get(handle);
5562
+ if (entry) return { entry };
5563
+ const restored = await rehydrate(handle);
5564
+ if (restored === void 0) return missing(handle);
5565
+ if (!restored.ok) return restored;
5566
+ return { entry: restored.entry };
5567
+ }
5102
5568
  function missing(handle) {
5103
5569
  const grave = tombstones.get(handle);
5104
5570
  if (grave?.reason === "ttl") {
@@ -5134,12 +5600,29 @@ function createMemoryWorkspaceStore(options = {}) {
5134
5600
  createdAt: new Date(entry.createdAt).toISOString(),
5135
5601
  updatedAt: new Date(entry.updatedAt).toISOString(),
5136
5602
  ...entry.title !== void 0 && { title: entry.title },
5137
- pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
5603
+ pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
5604
+ ...persistence && { persisted: entry.persisted }
5605
+ };
5606
+ }
5607
+ function fromMeta(meta) {
5608
+ return {
5609
+ handle: meta.handle,
5610
+ format: meta.format,
5611
+ revision: meta.revision,
5612
+ bytes: meta.bytes,
5613
+ createdAt: new Date(meta.createdAt).toISOString(),
5614
+ updatedAt: new Date(meta.updatedAt).toISOString(),
5615
+ ...meta.title !== void 0 && { title: meta.title },
5616
+ pinnedRevisions: [...meta.pinnedRevisions],
5617
+ persisted: true
5138
5618
  };
5139
5619
  }
5140
5620
  return {
5141
5621
  available: true,
5142
5622
  limits,
5623
+ ...persistence && {
5624
+ persistence: { root: persistence.root, limits: persistence.limits }
5625
+ },
5143
5626
  usage() {
5144
5627
  sweep();
5145
5628
  return { workspaces: entries.size, bytes: totalBytes() };
@@ -5180,15 +5663,18 @@ function createMemoryWorkspaceStore(options = {}) {
5180
5663
  updatedAt: at,
5181
5664
  touchedAt: at,
5182
5665
  ...input.title !== void 0 && { title: input.title },
5183
- pins: /* @__PURE__ */ new Map()
5666
+ pins: /* @__PURE__ */ new Map(),
5667
+ persisted: false
5184
5668
  };
5185
5669
  entries.set(entry.handle, entry);
5186
- return { ok: true, record: toRecord(entry) };
5670
+ const warning = await persist(entry);
5671
+ return committed(toRecord(entry), warning);
5187
5672
  },
5188
5673
  async get(handle, readOptions) {
5189
5674
  sweep();
5190
- const entry = entries.get(handle);
5191
- if (!entry) return missing(handle);
5675
+ const found = await resolve3(handle);
5676
+ if (!("entry" in found)) return found;
5677
+ const entry = found.entry;
5192
5678
  entry.touchedAt = now();
5193
5679
  let text = entry.text;
5194
5680
  let bytes = entry.bytes;
@@ -5213,15 +5699,16 @@ function createMemoryWorkspaceStore(options = {}) {
5213
5699
  context: { handle, pointer }
5214
5700
  });
5215
5701
  }
5216
- const found = resolvePointer(document, parsed.tokens);
5217
- if (found.found) projection[pointer] = found.value;
5702
+ const found2 = resolvePointer(document, parsed.tokens);
5703
+ if (found2.found) projection[pointer] = found2.value;
5218
5704
  }
5219
5705
  return { ok: true, record, document, projection };
5220
5706
  },
5221
5707
  async patch(input) {
5222
5708
  sweep();
5223
- const entry = entries.get(input.handle);
5224
- if (!entry) return missing(input.handle);
5709
+ const found = await resolve3(input.handle);
5710
+ if (!("entry" in found)) return found;
5711
+ const entry = found.entry;
5225
5712
  entry.touchedAt = now();
5226
5713
  if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
5227
5714
  return stale(
@@ -5287,12 +5774,14 @@ function createMemoryWorkspaceStore(options = {}) {
5287
5774
  entry.revision += 1;
5288
5775
  entry.updatedAt = now();
5289
5776
  entry.touchedAt = entry.updatedAt;
5290
- return { ok: true, record: toRecord(entry) };
5777
+ const warning = await persist(entry);
5778
+ return committed(toRecord(entry), warning);
5291
5779
  },
5292
5780
  async snapshot(handle) {
5293
5781
  sweep();
5294
- const entry = entries.get(handle);
5295
- if (!entry) return missing(handle);
5782
+ const found = await resolve3(handle);
5783
+ if (!("entry" in found)) return found;
5784
+ const entry = found.entry;
5296
5785
  entry.touchedAt = now();
5297
5786
  const revision = entry.revision;
5298
5787
  const document = JSON.parse(entry.text);
@@ -5301,24 +5790,79 @@ function createMemoryWorkspaceStore(options = {}) {
5301
5790
  entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
5302
5791
  }
5303
5792
  }
5304
- return { ok: true, record: toRecord(entry), document };
5305
- },
5306
- async list() {
5307
- sweep();
5793
+ const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
5308
5794
  return {
5309
5795
  ok: true,
5310
- records: [...entries.values()].map((entry) => toRecord(entry))
5796
+ record: toRecord(entry),
5797
+ document,
5798
+ ...warning && { warnings: [warning] }
5311
5799
  };
5312
5800
  },
5801
+ async list() {
5802
+ sweep();
5803
+ const records = [...entries.values()].map((entry) => toRecord(entry));
5804
+ if (!persistence) return { ok: true, records };
5805
+ const resident = new Set(records.map((record) => record.handle));
5806
+ let stored = [];
5807
+ try {
5808
+ stored = await persistence.list();
5809
+ } catch {
5810
+ }
5811
+ for (const meta of stored) {
5812
+ if (resident.has(meta.handle)) continue;
5813
+ if (tombstones.get(meta.handle)?.reason === "closed") continue;
5814
+ records.push(fromMeta(meta));
5815
+ }
5816
+ return { ok: true, records };
5817
+ },
5313
5818
  async close(handle) {
5314
5819
  sweep();
5315
5820
  const entry = entries.get(handle);
5316
- if (!entry) return { ok: true, handle, closed: false };
5317
- evict(entry, "closed");
5318
- return { ok: true, handle, closed: true };
5821
+ if (!persistence) {
5822
+ if (entry) evict(entry, "closed");
5823
+ return { ok: true, handle, closed: entry !== void 0 };
5824
+ }
5825
+ const root = persistence.root;
5826
+ try {
5827
+ return await queued(async () => {
5828
+ const removed = await persistence.remove(handle);
5829
+ const resident = entries.get(handle);
5830
+ if (resident) evict(resident, "closed");
5831
+ else if (removed) tombstone(handle, "closed", 0);
5832
+ return {
5833
+ ok: true,
5834
+ handle,
5835
+ closed: resident !== void 0 || removed
5836
+ };
5837
+ });
5838
+ } catch (error) {
5839
+ return failure(
5840
+ WORKSPACE_ERROR_CODES.NOT_CLOSED,
5841
+ `Workspace ${handle} could not be removed from ${root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
5842
+ {
5843
+ suggestion: "Check the workspace directory is writable, then close again.",
5844
+ context: { handle, workspaceRoot: root }
5845
+ }
5846
+ );
5847
+ }
5319
5848
  },
5849
+ /**
5850
+ * Release memory, keep the disk.
5851
+ *
5852
+ * The asymmetry with `close` is the point: this exists for a host
5853
+ * reclaiming memory at a moment of its own choosing, and #290 is precisely
5854
+ * about an event that ends a connection not being allowed to destroy
5855
+ * revisions. The handles come back from disk on next use.
5856
+ */
5320
5857
  async closeAll() {
5321
- for (const entry of [...entries.values()]) evict(entry, "closed");
5858
+ for (const entry of [...entries.values()]) {
5859
+ if (!persistence) {
5860
+ evict(entry, "closed");
5861
+ continue;
5862
+ }
5863
+ entries.delete(entry.handle);
5864
+ tombstones.delete(entry.handle);
5865
+ }
5322
5866
  }
5323
5867
  };
5324
5868
  function stale(entry, wanted, pinned, mutation = false) {
@@ -5338,7 +5882,7 @@ function createMemoryWorkspaceStore(options = {}) {
5338
5882
  }
5339
5883
  }
5340
5884
  function defaultHandle() {
5341
- return `ws_${randomBytes2(9).toString("base64url")}`;
5885
+ return `ws_${randomBytes3(9).toString("base64url")}`;
5342
5886
  }
5343
5887
  function serialize(document) {
5344
5888
  let text;
@@ -5391,7 +5935,7 @@ var workspaceSchema = {
5391
5935
  properties: {
5392
5936
  handle: {
5393
5937
  type: "string",
5394
- description: "Opaque, valid only on this connection."
5938
+ description: "Opaque and server-generated. Valid only on this connection, unless the server has a workspace directory \u2014 then it survives a reconnect and jto_workspace_list hands it back."
5395
5939
  },
5396
5940
  format: { type: "string", enum: [...FORMAT_NAMES] },
5397
5941
  revision: {
@@ -5409,6 +5953,10 @@ var workspaceSchema = {
5409
5953
  type: "array",
5410
5954
  items: { type: "integer" },
5411
5955
  description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
5956
+ },
5957
+ persisted: {
5958
+ type: "boolean",
5959
+ description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
5412
5960
  }
5413
5961
  },
5414
5962
  required: [
@@ -5422,6 +5970,26 @@ var workspaceSchema = {
5422
5970
  ],
5423
5971
  additionalProperties: false
5424
5972
  };
5973
+ var persistenceSchema = {
5974
+ type: "object",
5975
+ description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
5976
+ properties: {
5977
+ root: { type: "string" },
5978
+ maxWorkspaces: { type: "integer" },
5979
+ maxRevisionsPerWorkspace: {
5980
+ type: "integer",
5981
+ description: "Revision files kept per handle: the head plus its pins."
5982
+ },
5983
+ maxEntryBytes: { type: "integer" }
5984
+ },
5985
+ required: [
5986
+ "root",
5987
+ "maxWorkspaces",
5988
+ "maxRevisionsPerWorkspace",
5989
+ "maxEntryBytes"
5990
+ ],
5991
+ additionalProperties: false
5992
+ };
5425
5993
  var limitsSchema = {
5426
5994
  type: "object",
5427
5995
  description: "What this connection allows. Bounded on purpose; see the codes above.",
@@ -5453,7 +6021,9 @@ function isMemoryStore(store) {
5453
6021
  }
5454
6022
  function ensureStore(deps) {
5455
6023
  if (deps.workspaces().available || hasWorkspaceStore()) return;
5456
- const owned = createMemoryWorkspaceStore();
6024
+ const owned = createMemoryWorkspaceStore(
6025
+ deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
6026
+ );
5457
6027
  deps.workspaces = () => owned;
5458
6028
  }
5459
6029
  function register8(server, deps) {
@@ -5496,9 +6066,9 @@ function register8(server, deps) {
5496
6066
  ...args.title !== void 0 && { title: args.title }
5497
6067
  });
5498
6068
  if (!created.ok) return created;
5499
- return success(
5500
- { workspace: created.record },
5501
- seeded ? [
6069
+ return success({ workspace: created.record }, [
6070
+ ...created.warnings ?? [],
6071
+ ...seeded ? [
5502
6072
  diagnostic(
5503
6073
  "W_BLANK_DOCUMENT",
5504
6074
  `Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
@@ -5508,7 +6078,7 @@ function register8(server, deps) {
5508
6078
  }
5509
6079
  )
5510
6080
  ] : []
5511
- );
6081
+ ]);
5512
6082
  })
5513
6083
  )
5514
6084
  );
@@ -5574,17 +6144,20 @@ function register8(server, deps) {
5574
6144
  const missingPaths = projecting ? args.paths.filter(
5575
6145
  (pointer) => !(pointer in projection)
5576
6146
  ) : [];
5577
- const diagnostics = missingPaths.map(
5578
- (pointer) => diagnostic(
5579
- "W_PATH_NOT_FOUND",
5580
- `${pointer} does not resolve in revision ${read.record.revision}.`,
5581
- {
5582
- severity: "warning",
5583
- path: pointer,
5584
- suggestion: "Read a shorter prefix of the pointer to see what is actually there."
5585
- }
6147
+ const diagnostics = [
6148
+ ...read.warnings ?? [],
6149
+ ...missingPaths.map(
6150
+ (pointer) => diagnostic(
6151
+ "W_PATH_NOT_FOUND",
6152
+ `${pointer} does not resolve in revision ${read.record.revision}.`,
6153
+ {
6154
+ severity: "warning",
6155
+ path: pointer,
6156
+ suggestion: "Read a shorter prefix of the pointer to see what is actually there."
6157
+ }
6158
+ )
5586
6159
  )
5587
- );
6160
+ ];
5588
6161
  return success(
5589
6162
  {
5590
6163
  workspace: read.record,
@@ -5653,7 +6226,7 @@ function register8(server, deps) {
5653
6226
  }
5654
6227
  });
5655
6228
  if (!patched.ok) return patched;
5656
- return success({ workspace: patched.record });
6229
+ return success({ workspace: patched.record }, patched.warnings ?? []);
5657
6230
  })
5658
6231
  )
5659
6232
  );
@@ -5699,7 +6272,7 @@ function register8(server, deps) {
5699
6272
  await guarded(async () => {
5700
6273
  const snapshot = await deps.workspaces().snapshot(args.handle);
5701
6274
  if (!snapshot.ok) return snapshot;
5702
- const diagnostics = [];
6275
+ const diagnostics = [...snapshot.warnings ?? []];
5703
6276
  if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
5704
6277
  diagnostics.push(
5705
6278
  diagnostic(
@@ -5742,7 +6315,7 @@ function register8(server, deps) {
5742
6315
  "jto_workspace_list",
5743
6316
  {
5744
6317
  title: "List open workspaces",
5745
- description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened. An empty list means nothing is open, not that anything failed.",
6318
+ description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened, including after a reconnect when the server has a workspace directory. An empty list means nothing is open, not that anything failed.",
5746
6319
  annotations: { readOnlyHint: true, openWorldHint: false },
5747
6320
  inputSchema: S({
5748
6321
  type: "object",
@@ -5757,6 +6330,7 @@ function register8(server, deps) {
5757
6330
  description: "False when this connection has workspaces switched off."
5758
6331
  },
5759
6332
  limits: limitsSchema,
6333
+ persistence: persistenceSchema,
5760
6334
  usage: {
5761
6335
  type: "object",
5762
6336
  properties: {
@@ -5774,12 +6348,24 @@ function register8(server, deps) {
5774
6348
  const store = deps.workspaces();
5775
6349
  const listed = await store.list();
5776
6350
  if (!listed.ok) return listed;
5777
- const budget = isMemoryStore(store) ? { limits: store.limits, usage: store.usage() } : {};
5778
- return success({
5779
- workspaces: listed.records,
5780
- available: store.available,
5781
- ...budget
5782
- });
6351
+ const budget = isMemoryStore(store) ? {
6352
+ limits: store.limits,
6353
+ usage: store.usage(),
6354
+ ...store.persistence && {
6355
+ persistence: {
6356
+ root: store.persistence.root,
6357
+ ...store.persistence.limits
6358
+ }
6359
+ }
6360
+ } : {};
6361
+ return success(
6362
+ {
6363
+ workspaces: listed.records,
6364
+ available: store.available,
6365
+ ...budget
6366
+ },
6367
+ listed.warnings ?? []
6368
+ );
5783
6369
  })
5784
6370
  )
5785
6371
  );
@@ -5787,7 +6373,7 @@ function register8(server, deps) {
5787
6373
  "jto_workspace_close",
5788
6374
  {
5789
6375
  title: "Close a workspace",
5790
- description: "Release a handle and the memory behind it, including its pinned snapshots. Idempotent: closing a handle that is already gone reports `closed: false` rather than failing. Snapshot anything you still want first \u2014 closing is not recoverable.",
6376
+ description: "Release a handle and everything behind it \u2014 the document, its pinned snapshots, and the durable copy when this connection keeps one. Idempotent: closing a handle that is already gone reports `closed: false` rather than failing. Snapshot anything you still want first \u2014 closing is not recoverable.",
5791
6377
  annotations: {
5792
6378
  readOnlyHint: false,
5793
6379
  destructiveHint: true,
@@ -6004,6 +6590,12 @@ function createServerFactory(deps) {
6004
6590
 
6005
6591
  // src/lib/deps.ts
6006
6592
  function createToolDeps(options = {}) {
6593
+ const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
6594
+ ...options.workspaceDir !== void 0 && {
6595
+ flagDir: options.workspaceDir
6596
+ },
6597
+ ...options.env !== void 0 && { env: options.env }
6598
+ });
6007
6599
  return {
6008
6600
  serverVersion: options.serverVersion ?? SERVER_VERSION,
6009
6601
  outputRoot: options.outputRoot ?? createOutputRoot({
@@ -6012,10 +6604,13 @@ function createToolDeps(options = {}) {
6012
6604
  }),
6013
6605
  getAdapter: options.getAdapter ?? getAdapter,
6014
6606
  workspaces: options.workspaces ?? getWorkspaceStore,
6607
+ ...workspacePersistence !== void 0 && { workspacePersistence },
6015
6608
  maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
6016
6609
  };
6017
6610
  }
6018
6611
  export {
6612
+ DEFAULT_PERSISTENCE_LIMITS,
6613
+ DEFAULT_WORKSPACE_LIMITS,
6019
6614
  DOCUMENT_SOURCE_RULE,
6020
6615
  ERROR_CODES,
6021
6616
  FORMAT_NAMES,
@@ -6028,15 +6623,20 @@ export {
6028
6623
  SERVER_INSTRUCTIONS,
6029
6624
  SERVER_NAME,
6030
6625
  SERVER_VERSION,
6626
+ WORKSPACE_DIR_ENV,
6627
+ WORKSPACE_ERROR_CODES,
6031
6628
  artifactOutputProperties,
6032
6629
  artifactSchema,
6033
6630
  checkOutputName,
6034
6631
  checkRenderer,
6035
6632
  countDiagnostics,
6633
+ createMemoryWorkspaceStore,
6036
6634
  createOutputRoot,
6037
6635
  createServer,
6038
6636
  createServerFactory,
6039
6637
  createToolDeps,
6638
+ createWorkspacePersistence,
6639
+ createWorkspacePersistenceAt,
6040
6640
  deliverArtifact,
6041
6641
  diagnostic,
6042
6642
  diagnosticSchema,