@json-to-office/mcp-server 1.5.0 → 1.8.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/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
7
  import { McpServer } from "@modelcontextprotocol/server";
8
8
 
9
9
  // src/lib/version.ts
10
- var SERVER_VERSION = true ? "1.5.0" : "dev-mode";
10
+ var SERVER_VERSION = true ? "1.8.0" : "dev-mode";
11
11
  var SERVER_NAME = "json-to-office";
12
12
  var PACKAGE_NAME = "@json-to-office/mcp-server";
13
13
 
@@ -420,9 +420,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
420
420
  function escapePointerSegment(segment) {
421
421
  return segment.replace(/~/g, "~0").replace(/\//g, "~1");
422
422
  }
423
- function toJsonPointer(path6) {
424
- if (path6 === void 0) return void 0;
425
- const trimmed = path6.trim();
423
+ function toJsonPointer(path7) {
424
+ if (path7 === void 0) return void 0;
425
+ const trimmed = path7.trim();
426
426
  if (ROOT_SENTINELS.has(trimmed)) return "";
427
427
  const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
428
428
  if (segments.length === 0) return "";
@@ -623,11 +623,11 @@ async function probeService(rawUrl, envVar) {
623
623
  }
624
624
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
625
625
  const net = await import("net");
626
- return new Promise((resolve2) => {
626
+ return new Promise((resolve3) => {
627
627
  const socket = net.connect({ host: target.hostname, port });
628
628
  const settle = (detail) => {
629
629
  socket.destroy();
630
- resolve2({
630
+ resolve3({
631
631
  available: detail === void 0,
632
632
  url: target.origin,
633
633
  envVar,
@@ -667,7 +667,7 @@ function register(server, deps) {
667
667
  "jto_info",
668
668
  {
669
669
  title: "Server info",
670
- 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.",
670
+ 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.",
671
671
  annotations: { readOnlyHint: true, openWorldHint: false },
672
672
  inputSchema: S({
673
673
  type: "object",
@@ -752,9 +752,17 @@ function register(server, deps) {
752
752
  type: "object",
753
753
  properties: {
754
754
  available: { type: "boolean" },
755
- open: { type: "integer" }
755
+ open: { type: "integer" },
756
+ persistent: {
757
+ type: "boolean",
758
+ description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
759
+ },
760
+ root: {
761
+ type: "string",
762
+ description: "Directory the revisions are mirrored to, when persistent."
763
+ }
756
764
  },
757
- required: ["available", "open"],
765
+ required: ["available", "open", "persistent"],
758
766
  additionalProperties: false
759
767
  },
760
768
  output: {
@@ -915,7 +923,11 @@ function register(server, deps) {
915
923
  formats,
916
924
  workspaces: {
917
925
  available: store.available,
918
- open: listed.ok ? listed.records.length : 0
926
+ open: listed.ok ? listed.records.length : 0,
927
+ persistent: deps.workspacePersistence !== void 0,
928
+ ...deps.workspacePersistence && {
929
+ root: deps.workspacePersistence.root
930
+ }
919
931
  },
920
932
  output: {
921
933
  root: deps.outputRoot.path,
@@ -3136,9 +3148,9 @@ async function digestAssets(document, baseDir) {
3136
3148
  [...references].map(async (reference) => {
3137
3149
  const resolved = path4.resolve(root, reference);
3138
3150
  try {
3139
- const stat = await fs4.stat(resolved);
3140
- if (!stat.isFile()) return;
3141
- entries.push(`${resolved}|${stat.size}|${stat.mtimeMs}`);
3151
+ const stat2 = await fs4.stat(resolved);
3152
+ if (!stat2.isFile()) return;
3153
+ entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
3142
3154
  } catch {
3143
3155
  }
3144
3156
  })
@@ -3168,8 +3180,8 @@ function collectAssetReferences(value, into) {
3168
3180
  async function digestThemeFile(themePath) {
3169
3181
  if (!themePath) return "none";
3170
3182
  try {
3171
- const stat = await fs4.stat(themePath);
3172
- return `${path4.resolve(themePath)}|${stat.size}|${stat.mtimeMs}`;
3183
+ const stat2 = await fs4.stat(themePath);
3184
+ return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
3173
3185
  } catch {
3174
3186
  return `${path4.resolve(themePath)}|missing`;
3175
3187
  }
@@ -3241,18 +3253,18 @@ function missingDependencyFailure(dependencies) {
3241
3253
  );
3242
3254
  }
3243
3255
  async function binaryIdentity(binary, args, signal) {
3244
- const [version, stat] = await Promise.all([
3256
+ const [version, stat2] = await Promise.all([
3245
3257
  readVersion(binary, args, signal),
3246
3258
  fs5.stat(binary).catch(() => void 0)
3247
3259
  ]);
3248
3260
  const identity = [
3249
3261
  version ?? "unknown",
3250
- stat ? `${stat.size}:${stat.mtimeMs}` : "nostat"
3262
+ stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
3251
3263
  ].join("|");
3252
3264
  return { identity, ...version !== void 0 && { version } };
3253
3265
  }
3254
3266
  function readVersion(binary, args, signal) {
3255
- return new Promise((resolve2) => {
3267
+ return new Promise((resolve3) => {
3256
3268
  execFile(
3257
3269
  binary,
3258
3270
  args,
@@ -3266,8 +3278,8 @@ function readVersion(binary, args, signal) {
3266
3278
  const text = `${stdout ?? ""}
3267
3279
  ${stderr ?? ""}`.trim();
3268
3280
  const line = text.split("\n")[0]?.trim();
3269
- if (!line && error) return resolve2(void 0);
3270
- resolve2(line || void 0);
3281
+ if (!line && error) return resolve3(void 0);
3282
+ resolve3(line || void 0);
3271
3283
  }
3272
3284
  );
3273
3285
  });
@@ -3312,12 +3324,12 @@ function defaultPreviewCacheDir() {
3312
3324
  }
3313
3325
  var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
3314
3326
  async function inspectPrivateCacheDir(cacheDir) {
3315
- const stat = await fs6.lstat(cacheDir).catch(() => void 0);
3316
- if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return false;
3317
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
3327
+ const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
3328
+ if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
3329
+ if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
3318
3330
  return false;
3319
3331
  }
3320
- if (process.platform !== "win32" && (stat.mode & 63) !== 0) {
3332
+ if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
3321
3333
  try {
3322
3334
  await fs6.chmod(cacheDir, 448);
3323
3335
  } catch {
@@ -3347,9 +3359,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
3347
3359
  for (const name of names) {
3348
3360
  if (!CACHE_ENTRY_NAME.test(name)) continue;
3349
3361
  const file = path5.join(cacheDir, name);
3350
- const stat = await fs6.lstat(file).catch(() => void 0);
3351
- if (!stat?.isFile()) continue;
3352
- entries.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
3362
+ const stat2 = await fs6.lstat(file).catch(() => void 0);
3363
+ if (!stat2?.isFile()) continue;
3364
+ entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
3353
3365
  }
3354
3366
  const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
3355
3367
  const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
@@ -3436,7 +3448,7 @@ function countPdfPages(pdf) {
3436
3448
  return matches ? matches.length : 0;
3437
3449
  }
3438
3450
  function exec(binary, args, timeoutMs, options = {}) {
3439
- return new Promise((resolve2, reject) => {
3451
+ return new Promise((resolve3, reject) => {
3440
3452
  execFile2(
3441
3453
  binary,
3442
3454
  args,
@@ -3447,7 +3459,7 @@ function exec(binary, args, timeoutMs, options = {}) {
3447
3459
  env: options.env ? { ...process.env, ...options.env } : process.env,
3448
3460
  ...options.signal && { signal: options.signal }
3449
3461
  },
3450
- (error) => error ? reject(error) : resolve2()
3462
+ (error) => error ? reject(error) : resolve3()
3451
3463
  );
3452
3464
  });
3453
3465
  }
@@ -3541,7 +3553,7 @@ async function resolvePdfinfo(pdftoppmPath) {
3541
3553
  return status.available ? status.path : void 0;
3542
3554
  }
3543
3555
  async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3544
- return new Promise((resolve2) => {
3556
+ return new Promise((resolve3) => {
3545
3557
  execFile2(
3546
3558
  pdfinfo,
3547
3559
  [pdfPath],
@@ -3552,9 +3564,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3552
3564
  ...signal && { signal }
3553
3565
  },
3554
3566
  (error, stdout) => {
3555
- if (error && !stdout) return resolve2(void 0);
3567
+ if (error && !stdout) return resolve3(void 0);
3556
3568
  const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
3557
- resolve2(match ? Number(match[1]) : void 0);
3569
+ resolve3(match ? Number(match[1]) : void 0);
3558
3570
  }
3559
3571
  );
3560
3572
  });
@@ -4667,9 +4679,9 @@ function compilePatch(operations) {
4667
4679
  }
4668
4680
  );
4669
4681
  }
4670
- const path6 = parsePointer(raw.path);
4671
- if (!path6.ok) {
4672
- return problem(PATCH_ERROR_CODES.INVALID_POINTER, path6.message, index, {
4682
+ const path7 = parsePointer(raw.path);
4683
+ if (!path7.ok) {
4684
+ return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
4673
4685
  context: { op, pointer: raw.path },
4674
4686
  suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
4675
4687
  });
@@ -4677,7 +4689,7 @@ function compilePatch(operations) {
4677
4689
  const entry = {
4678
4690
  op,
4679
4691
  path: raw.path,
4680
- tokens: path6.tokens
4692
+ tokens: path7.tokens
4681
4693
  };
4682
4694
  if (VALUE_OPS.has(op)) {
4683
4695
  if (raw.value === void 0) {
@@ -5027,12 +5039,338 @@ function jsonEqualTokens(left, right) {
5027
5039
  }
5028
5040
 
5029
5041
  // src/workspace/store.ts
5042
+ import { randomBytes as randomBytes3 } from "crypto";
5043
+
5044
+ // src/workspace/persistence.ts
5045
+ import * as fs7 from "fs/promises";
5046
+ import * as path6 from "path";
5030
5047
  import { randomBytes as randomBytes2 } from "crypto";
5048
+ var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
5049
+ var SCHEMA_VERSION = 1;
5050
+ var META_FILE = "meta.json";
5051
+ var REVISION_PREFIX = "rev-";
5052
+ var REVISION_SUFFIX = ".json";
5053
+ var TEMP_SUFFIX = ".tmp";
5054
+ var STALE_TEMP_MS = 60 * 60 * 1e3;
5055
+ var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
5056
+ function isPersistableHandle(handle) {
5057
+ return SAFE_HANDLE.test(handle);
5058
+ }
5059
+ var DEFAULT_PERSISTENCE_LIMITS = {
5060
+ maxWorkspaces: 32,
5061
+ maxRevisionsPerWorkspace: 9,
5062
+ maxEntryBytes: 16 * 1024 * 1024
5063
+ };
5064
+ function createWorkspacePersistence(options = {}) {
5065
+ const env = options.env ?? process.env;
5066
+ const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
5067
+ if (!configured) return void 0;
5068
+ return createWorkspacePersistenceAt(path6.resolve(configured), options);
5069
+ }
5070
+ function createWorkspacePersistenceAt(root, limits = {}) {
5071
+ const bounds = {
5072
+ maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
5073
+ maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
5074
+ maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
5075
+ };
5076
+ let ensured;
5077
+ const authored = /* @__PURE__ */ new Map();
5078
+ function claim(handle, revision) {
5079
+ const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
5080
+ owned.add(revision);
5081
+ authored.set(handle, owned);
5082
+ }
5083
+ async function ensureRoot() {
5084
+ if (ensured === void 0) {
5085
+ ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
5086
+ () => void 0,
5087
+ (error) => {
5088
+ ensured = void 0;
5089
+ throw error;
5090
+ }
5091
+ );
5092
+ }
5093
+ return ensured;
5094
+ }
5095
+ function dirFor(handle) {
5096
+ return path6.join(root, handle);
5097
+ }
5098
+ async function writeAtomic2(target, contents) {
5099
+ const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
5100
+ try {
5101
+ await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
5102
+ await fs7.rename(temp, target);
5103
+ } catch (error) {
5104
+ await fs7.rm(temp, { force: true }).catch(() => void 0);
5105
+ throw error;
5106
+ }
5107
+ }
5108
+ function revisionFile(handle, revision) {
5109
+ return path6.join(
5110
+ dirFor(handle),
5111
+ `${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
5112
+ );
5113
+ }
5114
+ async function readMeta(handle) {
5115
+ let raw;
5116
+ try {
5117
+ raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
5118
+ } catch {
5119
+ return void 0;
5120
+ }
5121
+ return parseMeta(raw, handle);
5122
+ }
5123
+ async function readRevision(handle, revision) {
5124
+ let text;
5125
+ try {
5126
+ text = await fs7.readFile(revisionFile(handle, revision), "utf8");
5127
+ } catch {
5128
+ return void 0;
5129
+ }
5130
+ try {
5131
+ const parsed = JSON.parse(text);
5132
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
5133
+ return void 0;
5134
+ } catch {
5135
+ return void 0;
5136
+ }
5137
+ return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
5138
+ }
5139
+ async function handleDirs() {
5140
+ try {
5141
+ const entries = await fs7.readdir(root, { withFileTypes: true });
5142
+ return entries.filter(
5143
+ (entry) => entry.isDirectory() && isPersistableHandle(entry.name)
5144
+ ).map((entry) => entry.name);
5145
+ } catch {
5146
+ return [];
5147
+ }
5148
+ }
5149
+ async function pruneWorkspaces(incoming) {
5150
+ const handles = (await handleDirs()).filter(
5151
+ (handle) => handle !== incoming
5152
+ );
5153
+ const excess = handles.length + 1 - bounds.maxWorkspaces;
5154
+ if (excess <= 0) return;
5155
+ const metas = await Promise.all(
5156
+ handles.map(async (handle) => ({
5157
+ handle,
5158
+ // An unreadable entry sorts first: it is the one worth losing.
5159
+ updatedAt: (await readMeta(handle))?.updatedAt ?? 0
5160
+ }))
5161
+ );
5162
+ metas.sort((a, b) => a.updatedAt - b.updatedAt);
5163
+ for (const victim of metas.slice(0, excess)) {
5164
+ await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
5165
+ authored.delete(victim.handle);
5166
+ }
5167
+ }
5168
+ async function pruneRevisions(handle, retained) {
5169
+ let names;
5170
+ try {
5171
+ names = await fs7.readdir(dirFor(handle));
5172
+ } catch {
5173
+ return;
5174
+ }
5175
+ const owned = authored.get(handle);
5176
+ for (const name of names) {
5177
+ const target = path6.join(dirFor(handle), name);
5178
+ if (name.endsWith(TEMP_SUFFIX)) {
5179
+ if (await isStale(target)) {
5180
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5181
+ }
5182
+ continue;
5183
+ }
5184
+ const revision = revisionOf(name);
5185
+ if (revision === void 0 || retained.has(revision)) continue;
5186
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5187
+ owned?.delete(revision);
5188
+ }
5189
+ }
5190
+ return {
5191
+ root,
5192
+ limits: bounds,
5193
+ async save(snapshot) {
5194
+ if (!isPersistableHandle(snapshot.handle)) {
5195
+ throw new Error(
5196
+ `Workspace handle ${JSON.stringify(
5197
+ snapshot.handle
5198
+ )} cannot be used as a directory name.`
5199
+ );
5200
+ }
5201
+ if (snapshot.head.bytes > bounds.maxEntryBytes) {
5202
+ throw new Error(
5203
+ `Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
5204
+ );
5205
+ }
5206
+ await ensureRoot();
5207
+ const dir = dirFor(snapshot.handle);
5208
+ const fresh = !await exists(dir);
5209
+ if (fresh) await pruneWorkspaces(snapshot.handle);
5210
+ await fs7.mkdir(dir, { recursive: true, mode: 448 });
5211
+ const files = [snapshot.head];
5212
+ const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
5213
+ const pinned = [];
5214
+ for (const pin of [...snapshot.pins].sort(
5215
+ (a, b) => b.revision - a.revision
5216
+ )) {
5217
+ if (!retained.has(pin.revision)) {
5218
+ if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
5219
+ files.push(pin);
5220
+ retained.add(pin.revision);
5221
+ }
5222
+ pinned.push(pin.revision);
5223
+ }
5224
+ const owned = authored.get(snapshot.handle);
5225
+ for (const document of files) {
5226
+ if (owned?.has(document.revision)) continue;
5227
+ await writeAtomic2(
5228
+ revisionFile(snapshot.handle, document.revision),
5229
+ document.text
5230
+ );
5231
+ claim(snapshot.handle, document.revision);
5232
+ }
5233
+ const meta = {
5234
+ schema: SCHEMA_VERSION,
5235
+ handle: snapshot.handle,
5236
+ format: snapshot.format,
5237
+ revision: snapshot.head.revision,
5238
+ bytes: snapshot.head.bytes,
5239
+ createdAt: snapshot.createdAt,
5240
+ updatedAt: snapshot.updatedAt,
5241
+ ...snapshot.title !== void 0 && { title: snapshot.title },
5242
+ pinnedRevisions: pinned.sort((a, b) => a - b)
5243
+ };
5244
+ await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
5245
+ await pruneRevisions(snapshot.handle, retained);
5246
+ },
5247
+ async list() {
5248
+ const handles = await handleDirs();
5249
+ const metas = await Promise.all(
5250
+ handles.map((handle) => readMeta(handle))
5251
+ );
5252
+ return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
5253
+ },
5254
+ async load(handle) {
5255
+ if (!isPersistableHandle(handle)) return void 0;
5256
+ const meta = await readMeta(handle);
5257
+ if (!meta) return void 0;
5258
+ const head = await readRevision(handle, meta.revision);
5259
+ if (!head) {
5260
+ await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
5261
+ return void 0;
5262
+ }
5263
+ const pins = [];
5264
+ for (const revision of meta.pinnedRevisions) {
5265
+ const pin = await readRevision(handle, revision);
5266
+ if (pin) pins.push(pin);
5267
+ }
5268
+ return {
5269
+ ...meta,
5270
+ // A pin whose file went missing is dropped rather than advertised: the
5271
+ // record has to describe what can actually be read back.
5272
+ pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
5273
+ head,
5274
+ pins
5275
+ };
5276
+ },
5277
+ async remove(handle) {
5278
+ if (!isPersistableHandle(handle)) return false;
5279
+ const dir = dirFor(handle);
5280
+ if (!await exists(dir)) {
5281
+ authored.delete(handle);
5282
+ return false;
5283
+ }
5284
+ await fs7.rm(dir, { recursive: true, force: true });
5285
+ authored.delete(handle);
5286
+ return true;
5287
+ }
5288
+ };
5289
+ }
5290
+ async function exists(target) {
5291
+ try {
5292
+ await fs7.stat(target);
5293
+ return true;
5294
+ } catch {
5295
+ return false;
5296
+ }
5297
+ }
5298
+ async function isStale(target) {
5299
+ try {
5300
+ const { mtimeMs } = await fs7.stat(target);
5301
+ return Date.now() - mtimeMs > STALE_TEMP_MS;
5302
+ } catch {
5303
+ return false;
5304
+ }
5305
+ }
5306
+ function revisionOf(name) {
5307
+ if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
5308
+ return void 0;
5309
+ }
5310
+ const digits = name.slice(
5311
+ REVISION_PREFIX.length,
5312
+ name.length - REVISION_SUFFIX.length
5313
+ );
5314
+ if (!/^[0-9]+$/.test(digits)) return void 0;
5315
+ return Number(digits);
5316
+ }
5317
+ function parseMeta(raw, handle) {
5318
+ let parsed;
5319
+ try {
5320
+ parsed = JSON.parse(raw);
5321
+ } catch {
5322
+ return void 0;
5323
+ }
5324
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5325
+ return void 0;
5326
+ }
5327
+ const meta = parsed;
5328
+ if (meta.schema !== SCHEMA_VERSION) return void 0;
5329
+ if (meta.handle !== handle) return void 0;
5330
+ if (!FORMAT_NAMES.includes(meta.format)) return void 0;
5331
+ if (!isPositiveInteger(meta.revision)) return void 0;
5332
+ if (!isNonNegativeInteger(meta.bytes)) return void 0;
5333
+ if (!isNonNegativeInteger(meta.createdAt)) return void 0;
5334
+ if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
5335
+ if (meta.title !== void 0 && typeof meta.title !== "string")
5336
+ return void 0;
5337
+ const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
5338
+ return {
5339
+ handle,
5340
+ format: meta.format,
5341
+ revision: meta.revision,
5342
+ bytes: meta.bytes,
5343
+ createdAt: meta.createdAt,
5344
+ updatedAt: meta.updatedAt,
5345
+ ...typeof meta.title === "string" && { title: meta.title },
5346
+ pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
5347
+ };
5348
+ }
5349
+ function isPositiveInteger(value) {
5350
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
5351
+ }
5352
+ function isNonNegativeInteger(value) {
5353
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
5354
+ }
5355
+
5356
+ // src/workspace/store.ts
5031
5357
  var WORKSPACE_ERROR_CODES = {
5032
5358
  EVICTED: "E_WORKSPACE_EVICTED",
5033
5359
  LIMIT: "E_WORKSPACE_LIMIT",
5034
5360
  DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
5035
- INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
5361
+ INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
5362
+ /**
5363
+ * A revision could not be mirrored to disk (#290). A warning, never a
5364
+ * failure: the edit landed and the workspace is usable, but this connection
5365
+ * has stopped being survivable and saying so is the whole point.
5366
+ */
5367
+ NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
5368
+ /**
5369
+ * A close could not delete the durable copy, so nothing was released (#290).
5370
+ * An error rather than a warning: the agent asked for the document to be
5371
+ * destroyed, and it is still there to be read by the next connection.
5372
+ */
5373
+ NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
5036
5374
  };
5037
5375
  var DEFAULT_WORKSPACE_LIMITS = {
5038
5376
  maxWorkspaces: 16,
@@ -5052,6 +5390,7 @@ function createMemoryWorkspaceStore(options = {}) {
5052
5390
  };
5053
5391
  const now = options.now ?? Date.now;
5054
5392
  const newHandle = options.newHandle ?? defaultHandle;
5393
+ const persistence = options.persistence;
5055
5394
  const entries = /* @__PURE__ */ new Map();
5056
5395
  const tombstones = /* @__PURE__ */ new Map();
5057
5396
  function footprint(entry) {
@@ -5064,12 +5403,8 @@ function createMemoryWorkspaceStore(options = {}) {
5064
5403
  for (const entry of entries.values()) total += footprint(entry);
5065
5404
  return total;
5066
5405
  }
5067
- function tombstone(entry, reason) {
5068
- tombstones.set(entry.handle, {
5069
- reason,
5070
- at: now(),
5071
- revision: entry.revision
5072
- });
5406
+ function tombstone(handle, reason, revision) {
5407
+ tombstones.set(handle, { reason, at: now(), revision });
5073
5408
  while (tombstones.size > MAX_TOMBSTONES) {
5074
5409
  const oldest = tombstones.keys().next();
5075
5410
  if (oldest.done) break;
@@ -5079,7 +5414,7 @@ function createMemoryWorkspaceStore(options = {}) {
5079
5414
  function evict(entry, reason) {
5080
5415
  entries.delete(entry.handle);
5081
5416
  tombstones.delete(entry.handle);
5082
- tombstone(entry, reason);
5417
+ tombstone(entry.handle, reason, entry.revision);
5083
5418
  }
5084
5419
  function sweep() {
5085
5420
  const at = now();
@@ -5087,6 +5422,108 @@ function createMemoryWorkspaceStore(options = {}) {
5087
5422
  if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
5088
5423
  }
5089
5424
  }
5425
+ async function persist(entry) {
5426
+ if (!persistence) return void 0;
5427
+ try {
5428
+ await persistence.save({
5429
+ handle: entry.handle,
5430
+ format: entry.format,
5431
+ createdAt: entry.createdAt,
5432
+ updatedAt: entry.updatedAt,
5433
+ ...entry.title !== void 0 && { title: entry.title },
5434
+ head: {
5435
+ revision: entry.revision,
5436
+ text: entry.text,
5437
+ bytes: entry.bytes
5438
+ },
5439
+ pins: [...entry.pins.entries()].map(([revision, pin]) => ({
5440
+ revision,
5441
+ text: pin.text,
5442
+ bytes: pin.bytes
5443
+ }))
5444
+ });
5445
+ entry.persisted = true;
5446
+ return void 0;
5447
+ } catch (error) {
5448
+ entry.persisted = false;
5449
+ return diagnostic(
5450
+ WORKSPACE_ERROR_CODES.NOT_PERSISTED,
5451
+ `Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${persistence.root}: ${error instanceof Error ? error.message : String(error)}`,
5452
+ {
5453
+ severity: "warning",
5454
+ suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
5455
+ context: {
5456
+ handle: entry.handle,
5457
+ revision: entry.revision,
5458
+ workspaceRoot: persistence.root
5459
+ }
5460
+ }
5461
+ );
5462
+ }
5463
+ }
5464
+ function committed(record, warning) {
5465
+ return { ok: true, record, ...warning && { warnings: [warning] } };
5466
+ }
5467
+ async function rehydrate(handle) {
5468
+ if (!persistence || !isPersistableHandle(handle)) return void 0;
5469
+ let loaded;
5470
+ try {
5471
+ loaded = await persistence.load(handle);
5472
+ } catch {
5473
+ return void 0;
5474
+ }
5475
+ if (!loaded) return void 0;
5476
+ const restored = loaded;
5477
+ const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
5478
+ const needed = pins.reduce(
5479
+ (total, pin) => total + pin.bytes,
5480
+ restored.head.bytes
5481
+ );
5482
+ if (restored.head.bytes > limits.maxDocumentBytes) {
5483
+ return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
5484
+ }
5485
+ if (!hasRoom(needed, true)) {
5486
+ return failure(
5487
+ WORKSPACE_ERROR_CODES.LIMIT,
5488
+ `Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
5489
+ {
5490
+ suggestion: "Close a workspace you have finished with, then use this handle again.",
5491
+ context: {
5492
+ handle,
5493
+ bytes: needed,
5494
+ maxTotalBytes: limits.maxTotalBytes,
5495
+ maxWorkspaces: limits.maxWorkspaces
5496
+ }
5497
+ }
5498
+ );
5499
+ }
5500
+ const entry = {
5501
+ handle,
5502
+ format: restored.format,
5503
+ revision: restored.head.revision,
5504
+ text: restored.head.text,
5505
+ bytes: restored.head.bytes,
5506
+ createdAt: restored.createdAt,
5507
+ updatedAt: restored.updatedAt,
5508
+ touchedAt: now(),
5509
+ ...restored.title !== void 0 && { title: restored.title },
5510
+ pins: new Map(
5511
+ pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
5512
+ ),
5513
+ persisted: true
5514
+ };
5515
+ entries.set(handle, entry);
5516
+ tombstones.delete(handle);
5517
+ return { ok: true, entry };
5518
+ }
5519
+ async function resolve3(handle) {
5520
+ const entry = entries.get(handle);
5521
+ if (entry) return { entry };
5522
+ const restored = await rehydrate(handle);
5523
+ if (restored === void 0) return missing(handle);
5524
+ if (!restored.ok) return restored;
5525
+ return { entry: restored.entry };
5526
+ }
5090
5527
  function missing(handle) {
5091
5528
  const grave = tombstones.get(handle);
5092
5529
  if (grave?.reason === "ttl") {
@@ -5122,12 +5559,29 @@ function createMemoryWorkspaceStore(options = {}) {
5122
5559
  createdAt: new Date(entry.createdAt).toISOString(),
5123
5560
  updatedAt: new Date(entry.updatedAt).toISOString(),
5124
5561
  ...entry.title !== void 0 && { title: entry.title },
5125
- pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
5562
+ pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
5563
+ ...persistence && { persisted: entry.persisted }
5564
+ };
5565
+ }
5566
+ function fromMeta(meta) {
5567
+ return {
5568
+ handle: meta.handle,
5569
+ format: meta.format,
5570
+ revision: meta.revision,
5571
+ bytes: meta.bytes,
5572
+ createdAt: new Date(meta.createdAt).toISOString(),
5573
+ updatedAt: new Date(meta.updatedAt).toISOString(),
5574
+ ...meta.title !== void 0 && { title: meta.title },
5575
+ pinnedRevisions: [...meta.pinnedRevisions],
5576
+ persisted: true
5126
5577
  };
5127
5578
  }
5128
5579
  return {
5129
5580
  available: true,
5130
5581
  limits,
5582
+ ...persistence && {
5583
+ persistence: { root: persistence.root, limits: persistence.limits }
5584
+ },
5131
5585
  usage() {
5132
5586
  sweep();
5133
5587
  return { workspaces: entries.size, bytes: totalBytes() };
@@ -5168,15 +5622,18 @@ function createMemoryWorkspaceStore(options = {}) {
5168
5622
  updatedAt: at,
5169
5623
  touchedAt: at,
5170
5624
  ...input.title !== void 0 && { title: input.title },
5171
- pins: /* @__PURE__ */ new Map()
5625
+ pins: /* @__PURE__ */ new Map(),
5626
+ persisted: false
5172
5627
  };
5173
5628
  entries.set(entry.handle, entry);
5174
- return { ok: true, record: toRecord(entry) };
5629
+ const warning = await persist(entry);
5630
+ return committed(toRecord(entry), warning);
5175
5631
  },
5176
5632
  async get(handle, readOptions) {
5177
5633
  sweep();
5178
- const entry = entries.get(handle);
5179
- if (!entry) return missing(handle);
5634
+ const found = await resolve3(handle);
5635
+ if (!("entry" in found)) return found;
5636
+ const entry = found.entry;
5180
5637
  entry.touchedAt = now();
5181
5638
  let text = entry.text;
5182
5639
  let bytes = entry.bytes;
@@ -5201,15 +5658,16 @@ function createMemoryWorkspaceStore(options = {}) {
5201
5658
  context: { handle, pointer }
5202
5659
  });
5203
5660
  }
5204
- const found = resolvePointer(document, parsed.tokens);
5205
- if (found.found) projection[pointer] = found.value;
5661
+ const found2 = resolvePointer(document, parsed.tokens);
5662
+ if (found2.found) projection[pointer] = found2.value;
5206
5663
  }
5207
5664
  return { ok: true, record, document, projection };
5208
5665
  },
5209
5666
  async patch(input) {
5210
5667
  sweep();
5211
- const entry = entries.get(input.handle);
5212
- if (!entry) return missing(input.handle);
5668
+ const found = await resolve3(input.handle);
5669
+ if (!("entry" in found)) return found;
5670
+ const entry = found.entry;
5213
5671
  entry.touchedAt = now();
5214
5672
  if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
5215
5673
  return stale(
@@ -5275,12 +5733,14 @@ function createMemoryWorkspaceStore(options = {}) {
5275
5733
  entry.revision += 1;
5276
5734
  entry.updatedAt = now();
5277
5735
  entry.touchedAt = entry.updatedAt;
5278
- return { ok: true, record: toRecord(entry) };
5736
+ const warning = await persist(entry);
5737
+ return committed(toRecord(entry), warning);
5279
5738
  },
5280
5739
  async snapshot(handle) {
5281
5740
  sweep();
5282
- const entry = entries.get(handle);
5283
- if (!entry) return missing(handle);
5741
+ const found = await resolve3(handle);
5742
+ if (!("entry" in found)) return found;
5743
+ const entry = found.entry;
5284
5744
  entry.touchedAt = now();
5285
5745
  const revision = entry.revision;
5286
5746
  const document = JSON.parse(entry.text);
@@ -5289,24 +5749,70 @@ function createMemoryWorkspaceStore(options = {}) {
5289
5749
  entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
5290
5750
  }
5291
5751
  }
5292
- return { ok: true, record: toRecord(entry), document };
5293
- },
5294
- async list() {
5295
- sweep();
5752
+ const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
5296
5753
  return {
5297
5754
  ok: true,
5298
- records: [...entries.values()].map((entry) => toRecord(entry))
5755
+ record: toRecord(entry),
5756
+ document,
5757
+ ...warning && { warnings: [warning] }
5299
5758
  };
5300
5759
  },
5760
+ async list() {
5761
+ sweep();
5762
+ const records = [...entries.values()].map((entry) => toRecord(entry));
5763
+ if (!persistence) return { ok: true, records };
5764
+ const resident = new Set(records.map((record) => record.handle));
5765
+ let stored = [];
5766
+ try {
5767
+ stored = await persistence.list();
5768
+ } catch {
5769
+ }
5770
+ for (const meta of stored) {
5771
+ if (resident.has(meta.handle)) continue;
5772
+ if (tombstones.get(meta.handle)?.reason === "closed") continue;
5773
+ records.push(fromMeta(meta));
5774
+ }
5775
+ return { ok: true, records };
5776
+ },
5301
5777
  async close(handle) {
5302
5778
  sweep();
5303
5779
  const entry = entries.get(handle);
5304
- if (!entry) return { ok: true, handle, closed: false };
5305
- evict(entry, "closed");
5306
- return { ok: true, handle, closed: true };
5780
+ let durable = false;
5781
+ if (persistence) {
5782
+ try {
5783
+ durable = await persistence.remove(handle);
5784
+ } catch (error) {
5785
+ return failure(
5786
+ WORKSPACE_ERROR_CODES.NOT_CLOSED,
5787
+ `Workspace ${handle} could not be removed from ${persistence.root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
5788
+ {
5789
+ suggestion: "Check the workspace directory is writable, then close again.",
5790
+ context: { handle, workspaceRoot: persistence.root }
5791
+ }
5792
+ );
5793
+ }
5794
+ }
5795
+ if (entry) evict(entry, "closed");
5796
+ else if (durable) tombstone(handle, "closed", 0);
5797
+ return { ok: true, handle, closed: entry !== void 0 || durable };
5307
5798
  },
5799
+ /**
5800
+ * Release memory, keep the disk.
5801
+ *
5802
+ * The asymmetry with `close` is the point: this exists for a host
5803
+ * reclaiming memory at a moment of its own choosing, and #290 is precisely
5804
+ * about an event that ends a connection not being allowed to destroy
5805
+ * revisions. The handles come back from disk on next use.
5806
+ */
5308
5807
  async closeAll() {
5309
- for (const entry of [...entries.values()]) evict(entry, "closed");
5808
+ for (const entry of [...entries.values()]) {
5809
+ if (!persistence) {
5810
+ evict(entry, "closed");
5811
+ continue;
5812
+ }
5813
+ entries.delete(entry.handle);
5814
+ tombstones.delete(entry.handle);
5815
+ }
5310
5816
  }
5311
5817
  };
5312
5818
  function stale(entry, wanted, pinned, mutation = false) {
@@ -5326,7 +5832,7 @@ function createMemoryWorkspaceStore(options = {}) {
5326
5832
  }
5327
5833
  }
5328
5834
  function defaultHandle() {
5329
- return `ws_${randomBytes2(9).toString("base64url")}`;
5835
+ return `ws_${randomBytes3(9).toString("base64url")}`;
5330
5836
  }
5331
5837
  function serialize(document) {
5332
5838
  let text;
@@ -5379,7 +5885,7 @@ var workspaceSchema = {
5379
5885
  properties: {
5380
5886
  handle: {
5381
5887
  type: "string",
5382
- description: "Opaque, valid only on this connection."
5888
+ 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."
5383
5889
  },
5384
5890
  format: { type: "string", enum: [...FORMAT_NAMES] },
5385
5891
  revision: {
@@ -5397,6 +5903,10 @@ var workspaceSchema = {
5397
5903
  type: "array",
5398
5904
  items: { type: "integer" },
5399
5905
  description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
5906
+ },
5907
+ persisted: {
5908
+ type: "boolean",
5909
+ description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
5400
5910
  }
5401
5911
  },
5402
5912
  required: [
@@ -5410,6 +5920,26 @@ var workspaceSchema = {
5410
5920
  ],
5411
5921
  additionalProperties: false
5412
5922
  };
5923
+ var persistenceSchema = {
5924
+ type: "object",
5925
+ description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
5926
+ properties: {
5927
+ root: { type: "string" },
5928
+ maxWorkspaces: { type: "integer" },
5929
+ maxRevisionsPerWorkspace: {
5930
+ type: "integer",
5931
+ description: "Revision files kept per handle: the head plus its pins."
5932
+ },
5933
+ maxEntryBytes: { type: "integer" }
5934
+ },
5935
+ required: [
5936
+ "root",
5937
+ "maxWorkspaces",
5938
+ "maxRevisionsPerWorkspace",
5939
+ "maxEntryBytes"
5940
+ ],
5941
+ additionalProperties: false
5942
+ };
5413
5943
  var limitsSchema = {
5414
5944
  type: "object",
5415
5945
  description: "What this connection allows. Bounded on purpose; see the codes above.",
@@ -5441,7 +5971,9 @@ function isMemoryStore(store) {
5441
5971
  }
5442
5972
  function ensureStore(deps) {
5443
5973
  if (deps.workspaces().available || hasWorkspaceStore()) return;
5444
- const owned = createMemoryWorkspaceStore();
5974
+ const owned = createMemoryWorkspaceStore(
5975
+ deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
5976
+ );
5445
5977
  deps.workspaces = () => owned;
5446
5978
  }
5447
5979
  function register8(server, deps) {
@@ -5484,9 +6016,9 @@ function register8(server, deps) {
5484
6016
  ...args.title !== void 0 && { title: args.title }
5485
6017
  });
5486
6018
  if (!created.ok) return created;
5487
- return success(
5488
- { workspace: created.record },
5489
- seeded ? [
6019
+ return success({ workspace: created.record }, [
6020
+ ...created.warnings ?? [],
6021
+ ...seeded ? [
5490
6022
  diagnostic(
5491
6023
  "W_BLANK_DOCUMENT",
5492
6024
  `Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
@@ -5496,7 +6028,7 @@ function register8(server, deps) {
5496
6028
  }
5497
6029
  )
5498
6030
  ] : []
5499
- );
6031
+ ]);
5500
6032
  })
5501
6033
  )
5502
6034
  );
@@ -5562,17 +6094,20 @@ function register8(server, deps) {
5562
6094
  const missingPaths = projecting ? args.paths.filter(
5563
6095
  (pointer) => !(pointer in projection)
5564
6096
  ) : [];
5565
- const diagnostics = missingPaths.map(
5566
- (pointer) => diagnostic(
5567
- "W_PATH_NOT_FOUND",
5568
- `${pointer} does not resolve in revision ${read.record.revision}.`,
5569
- {
5570
- severity: "warning",
5571
- path: pointer,
5572
- suggestion: "Read a shorter prefix of the pointer to see what is actually there."
5573
- }
6097
+ const diagnostics = [
6098
+ ...read.warnings ?? [],
6099
+ ...missingPaths.map(
6100
+ (pointer) => diagnostic(
6101
+ "W_PATH_NOT_FOUND",
6102
+ `${pointer} does not resolve in revision ${read.record.revision}.`,
6103
+ {
6104
+ severity: "warning",
6105
+ path: pointer,
6106
+ suggestion: "Read a shorter prefix of the pointer to see what is actually there."
6107
+ }
6108
+ )
5574
6109
  )
5575
- );
6110
+ ];
5576
6111
  return success(
5577
6112
  {
5578
6113
  workspace: read.record,
@@ -5641,7 +6176,7 @@ function register8(server, deps) {
5641
6176
  }
5642
6177
  });
5643
6178
  if (!patched.ok) return patched;
5644
- return success({ workspace: patched.record });
6179
+ return success({ workspace: patched.record }, patched.warnings ?? []);
5645
6180
  })
5646
6181
  )
5647
6182
  );
@@ -5687,7 +6222,7 @@ function register8(server, deps) {
5687
6222
  await guarded(async () => {
5688
6223
  const snapshot = await deps.workspaces().snapshot(args.handle);
5689
6224
  if (!snapshot.ok) return snapshot;
5690
- const diagnostics = [];
6225
+ const diagnostics = [...snapshot.warnings ?? []];
5691
6226
  if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
5692
6227
  diagnostics.push(
5693
6228
  diagnostic(
@@ -5730,7 +6265,7 @@ function register8(server, deps) {
5730
6265
  "jto_workspace_list",
5731
6266
  {
5732
6267
  title: "List open workspaces",
5733
- 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.",
6268
+ 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.",
5734
6269
  annotations: { readOnlyHint: true, openWorldHint: false },
5735
6270
  inputSchema: S({
5736
6271
  type: "object",
@@ -5745,6 +6280,7 @@ function register8(server, deps) {
5745
6280
  description: "False when this connection has workspaces switched off."
5746
6281
  },
5747
6282
  limits: limitsSchema,
6283
+ persistence: persistenceSchema,
5748
6284
  usage: {
5749
6285
  type: "object",
5750
6286
  properties: {
@@ -5762,12 +6298,24 @@ function register8(server, deps) {
5762
6298
  const store = deps.workspaces();
5763
6299
  const listed = await store.list();
5764
6300
  if (!listed.ok) return listed;
5765
- const budget = isMemoryStore(store) ? { limits: store.limits, usage: store.usage() } : {};
5766
- return success({
5767
- workspaces: listed.records,
5768
- available: store.available,
5769
- ...budget
5770
- });
6301
+ const budget = isMemoryStore(store) ? {
6302
+ limits: store.limits,
6303
+ usage: store.usage(),
6304
+ ...store.persistence && {
6305
+ persistence: {
6306
+ root: store.persistence.root,
6307
+ ...store.persistence.limits
6308
+ }
6309
+ }
6310
+ } : {};
6311
+ return success(
6312
+ {
6313
+ workspaces: listed.records,
6314
+ available: store.available,
6315
+ ...budget
6316
+ },
6317
+ listed.warnings ?? []
6318
+ );
5771
6319
  })
5772
6320
  )
5773
6321
  );
@@ -5775,7 +6323,7 @@ function register8(server, deps) {
5775
6323
  "jto_workspace_close",
5776
6324
  {
5777
6325
  title: "Close a workspace",
5778
- 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.",
6326
+ 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.",
5779
6327
  annotations: {
5780
6328
  readOnlyHint: false,
5781
6329
  destructiveHint: true,
@@ -5992,6 +6540,12 @@ function createServerFactory(deps) {
5992
6540
 
5993
6541
  // src/lib/deps.ts
5994
6542
  function createToolDeps(options = {}) {
6543
+ const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
6544
+ ...options.workspaceDir !== void 0 && {
6545
+ flagDir: options.workspaceDir
6546
+ },
6547
+ ...options.env !== void 0 && { env: options.env }
6548
+ });
5995
6549
  return {
5996
6550
  serverVersion: options.serverVersion ?? SERVER_VERSION,
5997
6551
  outputRoot: options.outputRoot ?? createOutputRoot({
@@ -6000,23 +6554,45 @@ function createToolDeps(options = {}) {
6000
6554
  }),
6001
6555
  getAdapter: options.getAdapter ?? getAdapter,
6002
6556
  workspaces: options.workspaces ?? getWorkspaceStore,
6557
+ ...workspacePersistence !== void 0 && { workspacePersistence },
6003
6558
  maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
6004
6559
  };
6005
6560
  }
6006
6561
 
6007
6562
  // src/cli.ts
6563
+ function takeValue(argv, index) {
6564
+ const next = argv[index];
6565
+ if (next === void 0 || next.startsWith("-") || next.trim() === "") {
6566
+ return void 0;
6567
+ }
6568
+ return { value: next };
6569
+ }
6008
6570
  function parseArgs(argv) {
6009
- const parsed = { version: false, help: false, unknown: [] };
6571
+ const parsed = {
6572
+ version: false,
6573
+ help: false,
6574
+ unknown: [],
6575
+ missingValue: []
6576
+ };
6010
6577
  for (let index = 0; index < argv.length; index += 1) {
6011
6578
  const arg = argv[index];
6012
6579
  if (arg === "--version" || arg === "-v") {
6013
6580
  parsed.version = true;
6014
6581
  } else if (arg === "--help" || arg === "-h") {
6015
6582
  parsed.help = true;
6016
- } else if (arg === "--output-dir") {
6017
- parsed.outputDir = argv[++index];
6583
+ } else if (arg === "--output-dir" || arg === "--workspace-dir") {
6584
+ const taken = takeValue(argv, index + 1);
6585
+ if (!taken) {
6586
+ parsed.missingValue.push(arg);
6587
+ continue;
6588
+ }
6589
+ index += 1;
6590
+ if (arg === "--output-dir") parsed.outputDir = taken.value;
6591
+ else parsed.workspaceDir = taken.value;
6018
6592
  } else if (arg.startsWith("--output-dir=")) {
6019
6593
  parsed.outputDir = arg.slice("--output-dir=".length);
6594
+ } else if (arg.startsWith("--workspace-dir=")) {
6595
+ parsed.workspaceDir = arg.slice("--workspace-dir=".length);
6020
6596
  } else {
6021
6597
  parsed.unknown.push(arg);
6022
6598
  }
@@ -6034,11 +6610,16 @@ Options:
6034
6610
  --output-dir <path> Directory generated files are written to. Overrides
6035
6611
  ${OUTPUT_DIR_ENV}. Defaults to a per-connection
6036
6612
  directory under the system temp dir.
6613
+ --workspace-dir <p> Directory workspace revisions are mirrored to, so a lost
6614
+ client session does not destroy them. Overrides
6615
+ ${WORKSPACE_DIR_ENV}. Off by default:
6616
+ without it, handles live only in memory.
6037
6617
  -v, --version Print the version and exit.
6038
6618
  -h, --help Print this help and exit.
6039
6619
 
6040
6620
  Environment:
6041
6621
  ${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
6622
+ ${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
6042
6623
  LIBREOFFICE_PATH LibreOffice binary, for preview.
6043
6624
  PDFTOPPM_PATH poppler pdftoppm binary, for preview.
6044
6625
 
@@ -6061,13 +6642,23 @@ function main(argv) {
6061
6642
  process.stderr.write(
6062
6643
  `jto-mcp: unknown argument ${args.unknown[0]}
6063
6644
  Run jto-mcp --help.
6645
+ `
6646
+ );
6647
+ process.exitCode = 1;
6648
+ return;
6649
+ }
6650
+ if (args.missingValue.length > 0) {
6651
+ process.stderr.write(
6652
+ `jto-mcp: ${args.missingValue[0]} needs a directory path.
6653
+ Run jto-mcp --help.
6064
6654
  `
6065
6655
  );
6066
6656
  process.exitCode = 1;
6067
6657
  return;
6068
6658
  }
6069
6659
  const deps = createToolDeps({
6070
- ...args.outputDir !== void 0 && { outputDir: args.outputDir }
6660
+ ...args.outputDir !== void 0 && { outputDir: args.outputDir },
6661
+ ...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
6071
6662
  });
6072
6663
  const handle = serveStdio(createServerFactory(deps), {
6073
6664
  legacy: "serve",