@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/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.1" : "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,345 @@ 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
+ let meta = await readMeta(handle);
5257
+ if (!meta) return void 0;
5258
+ let head = await readRevision(handle, meta.revision);
5259
+ if (!head) {
5260
+ const second = await readMeta(handle);
5261
+ if (second) {
5262
+ meta = second;
5263
+ head = await readRevision(handle, second.revision);
5264
+ }
5265
+ }
5266
+ if (!head) {
5267
+ await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
5268
+ return void 0;
5269
+ }
5270
+ const pins = [];
5271
+ for (const revision of meta.pinnedRevisions) {
5272
+ const pin = await readRevision(handle, revision);
5273
+ if (pin) pins.push(pin);
5274
+ }
5275
+ return {
5276
+ ...meta,
5277
+ // A pin whose file went missing is dropped rather than advertised: the
5278
+ // record has to describe what can actually be read back.
5279
+ pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
5280
+ head,
5281
+ pins
5282
+ };
5283
+ },
5284
+ async remove(handle) {
5285
+ if (!isPersistableHandle(handle)) return false;
5286
+ const dir = dirFor(handle);
5287
+ if (!await exists(dir)) {
5288
+ authored.delete(handle);
5289
+ return false;
5290
+ }
5291
+ await fs7.rm(dir, { recursive: true, force: true });
5292
+ authored.delete(handle);
5293
+ return true;
5294
+ }
5295
+ };
5296
+ }
5297
+ async function exists(target) {
5298
+ try {
5299
+ await fs7.stat(target);
5300
+ return true;
5301
+ } catch {
5302
+ return false;
5303
+ }
5304
+ }
5305
+ async function isStale(target) {
5306
+ try {
5307
+ const { mtimeMs } = await fs7.stat(target);
5308
+ return Date.now() - mtimeMs > STALE_TEMP_MS;
5309
+ } catch {
5310
+ return false;
5311
+ }
5312
+ }
5313
+ function revisionOf(name) {
5314
+ if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
5315
+ return void 0;
5316
+ }
5317
+ const digits = name.slice(
5318
+ REVISION_PREFIX.length,
5319
+ name.length - REVISION_SUFFIX.length
5320
+ );
5321
+ if (!/^[0-9]+$/.test(digits)) return void 0;
5322
+ return Number(digits);
5323
+ }
5324
+ function parseMeta(raw, handle) {
5325
+ let parsed;
5326
+ try {
5327
+ parsed = JSON.parse(raw);
5328
+ } catch {
5329
+ return void 0;
5330
+ }
5331
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5332
+ return void 0;
5333
+ }
5334
+ const meta = parsed;
5335
+ if (meta.schema !== SCHEMA_VERSION) return void 0;
5336
+ if (meta.handle !== handle) return void 0;
5337
+ if (!FORMAT_NAMES.includes(meta.format)) return void 0;
5338
+ if (!isPositiveInteger(meta.revision)) return void 0;
5339
+ if (!isNonNegativeInteger(meta.bytes)) return void 0;
5340
+ if (!isNonNegativeInteger(meta.createdAt)) return void 0;
5341
+ if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
5342
+ if (meta.title !== void 0 && typeof meta.title !== "string")
5343
+ return void 0;
5344
+ const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
5345
+ return {
5346
+ handle,
5347
+ format: meta.format,
5348
+ revision: meta.revision,
5349
+ bytes: meta.bytes,
5350
+ createdAt: meta.createdAt,
5351
+ updatedAt: meta.updatedAt,
5352
+ ...typeof meta.title === "string" && { title: meta.title },
5353
+ pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
5354
+ };
5355
+ }
5356
+ function isPositiveInteger(value) {
5357
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
5358
+ }
5359
+ function isNonNegativeInteger(value) {
5360
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
5361
+ }
5362
+
5363
+ // src/workspace/store.ts
5031
5364
  var WORKSPACE_ERROR_CODES = {
5032
5365
  EVICTED: "E_WORKSPACE_EVICTED",
5033
5366
  LIMIT: "E_WORKSPACE_LIMIT",
5034
5367
  DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
5035
- INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
5368
+ INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
5369
+ /**
5370
+ * A revision could not be mirrored to disk (#290). A warning, never a
5371
+ * failure: the edit landed and the workspace is usable, but this connection
5372
+ * has stopped being survivable and saying so is the whole point.
5373
+ */
5374
+ NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
5375
+ /**
5376
+ * A close could not delete the durable copy, so nothing was released (#290).
5377
+ * An error rather than a warning: the agent asked for the document to be
5378
+ * destroyed, and it is still there to be read by the next connection.
5379
+ */
5380
+ NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
5036
5381
  };
5037
5382
  var DEFAULT_WORKSPACE_LIMITS = {
5038
5383
  maxWorkspaces: 16,
@@ -5052,6 +5397,7 @@ function createMemoryWorkspaceStore(options = {}) {
5052
5397
  };
5053
5398
  const now = options.now ?? Date.now;
5054
5399
  const newHandle = options.newHandle ?? defaultHandle;
5400
+ const persistence = options.persistence;
5055
5401
  const entries = /* @__PURE__ */ new Map();
5056
5402
  const tombstones = /* @__PURE__ */ new Map();
5057
5403
  function footprint(entry) {
@@ -5064,12 +5410,8 @@ function createMemoryWorkspaceStore(options = {}) {
5064
5410
  for (const entry of entries.values()) total += footprint(entry);
5065
5411
  return total;
5066
5412
  }
5067
- function tombstone(entry, reason) {
5068
- tombstones.set(entry.handle, {
5069
- reason,
5070
- at: now(),
5071
- revision: entry.revision
5072
- });
5413
+ function tombstone(handle, reason, revision) {
5414
+ tombstones.set(handle, { reason, at: now(), revision });
5073
5415
  while (tombstones.size > MAX_TOMBSTONES) {
5074
5416
  const oldest = tombstones.keys().next();
5075
5417
  if (oldest.done) break;
@@ -5079,7 +5421,7 @@ function createMemoryWorkspaceStore(options = {}) {
5079
5421
  function evict(entry, reason) {
5080
5422
  entries.delete(entry.handle);
5081
5423
  tombstones.delete(entry.handle);
5082
- tombstone(entry, reason);
5424
+ tombstone(entry.handle, reason, entry.revision);
5083
5425
  }
5084
5426
  function sweep() {
5085
5427
  const at = now();
@@ -5087,6 +5429,130 @@ function createMemoryWorkspaceStore(options = {}) {
5087
5429
  if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
5088
5430
  }
5089
5431
  }
5432
+ let durable = Promise.resolve();
5433
+ function queued(work) {
5434
+ const running = durable.then(work);
5435
+ durable = running.catch(() => void 0);
5436
+ return running;
5437
+ }
5438
+ async function persist(entry) {
5439
+ if (!persistence) return void 0;
5440
+ try {
5441
+ const written = await queued(async () => {
5442
+ if (entries.get(entry.handle) !== entry) return false;
5443
+ await saveEntry(entry);
5444
+ return true;
5445
+ });
5446
+ if (!written) {
5447
+ entry.persisted = false;
5448
+ return void 0;
5449
+ }
5450
+ entry.persisted = true;
5451
+ return void 0;
5452
+ } catch (error) {
5453
+ entry.persisted = false;
5454
+ return notPersisted(entry, error);
5455
+ }
5456
+ }
5457
+ async function saveEntry(entry) {
5458
+ if (!persistence) return;
5459
+ await persistence.save({
5460
+ handle: entry.handle,
5461
+ format: entry.format,
5462
+ createdAt: entry.createdAt,
5463
+ updatedAt: entry.updatedAt,
5464
+ ...entry.title !== void 0 && { title: entry.title },
5465
+ head: {
5466
+ revision: entry.revision,
5467
+ text: entry.text,
5468
+ bytes: entry.bytes
5469
+ },
5470
+ pins: [...entry.pins.entries()].map(([revision, pin]) => ({
5471
+ revision,
5472
+ text: pin.text,
5473
+ bytes: pin.bytes
5474
+ }))
5475
+ });
5476
+ }
5477
+ function notPersisted(entry, error) {
5478
+ const root = persistence?.root ?? "";
5479
+ return diagnostic(
5480
+ WORKSPACE_ERROR_CODES.NOT_PERSISTED,
5481
+ `Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${root}: ${error instanceof Error ? error.message : String(error)}`,
5482
+ {
5483
+ severity: "warning",
5484
+ suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
5485
+ context: {
5486
+ handle: entry.handle,
5487
+ revision: entry.revision,
5488
+ workspaceRoot: root
5489
+ }
5490
+ }
5491
+ );
5492
+ }
5493
+ function committed(record, warning) {
5494
+ return { ok: true, record, ...warning && { warnings: [warning] } };
5495
+ }
5496
+ async function rehydrate(handle) {
5497
+ if (!persistence || !isPersistableHandle(handle)) return void 0;
5498
+ let loaded;
5499
+ try {
5500
+ loaded = await persistence.load(handle);
5501
+ } catch {
5502
+ return void 0;
5503
+ }
5504
+ if (!loaded) return void 0;
5505
+ const restored = loaded;
5506
+ const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
5507
+ const needed = pins.reduce(
5508
+ (total, pin) => total + pin.bytes,
5509
+ restored.head.bytes
5510
+ );
5511
+ if (restored.head.bytes > limits.maxDocumentBytes) {
5512
+ return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
5513
+ }
5514
+ if (!hasRoom(needed, true)) {
5515
+ return failure(
5516
+ WORKSPACE_ERROR_CODES.LIMIT,
5517
+ `Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
5518
+ {
5519
+ suggestion: "Close a workspace you have finished with, then use this handle again.",
5520
+ context: {
5521
+ handle,
5522
+ bytes: needed,
5523
+ maxTotalBytes: limits.maxTotalBytes,
5524
+ maxWorkspaces: limits.maxWorkspaces
5525
+ }
5526
+ }
5527
+ );
5528
+ }
5529
+ const entry = {
5530
+ handle,
5531
+ format: restored.format,
5532
+ revision: restored.head.revision,
5533
+ text: restored.head.text,
5534
+ bytes: restored.head.bytes,
5535
+ createdAt: restored.createdAt,
5536
+ updatedAt: restored.updatedAt,
5537
+ touchedAt: now(),
5538
+ ...restored.title !== void 0 && { title: restored.title },
5539
+ pins: new Map(
5540
+ pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
5541
+ ),
5542
+ persisted: true
5543
+ };
5544
+ entries.set(handle, entry);
5545
+ tombstones.delete(handle);
5546
+ return { ok: true, entry };
5547
+ }
5548
+ async function resolve3(handle) {
5549
+ const entry = entries.get(handle);
5550
+ if (entry) return { entry };
5551
+ const restored = await rehydrate(handle);
5552
+ if (restored === void 0) return missing(handle);
5553
+ if (!restored.ok) return restored;
5554
+ return { entry: restored.entry };
5555
+ }
5090
5556
  function missing(handle) {
5091
5557
  const grave = tombstones.get(handle);
5092
5558
  if (grave?.reason === "ttl") {
@@ -5122,12 +5588,29 @@ function createMemoryWorkspaceStore(options = {}) {
5122
5588
  createdAt: new Date(entry.createdAt).toISOString(),
5123
5589
  updatedAt: new Date(entry.updatedAt).toISOString(),
5124
5590
  ...entry.title !== void 0 && { title: entry.title },
5125
- pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
5591
+ pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
5592
+ ...persistence && { persisted: entry.persisted }
5593
+ };
5594
+ }
5595
+ function fromMeta(meta) {
5596
+ return {
5597
+ handle: meta.handle,
5598
+ format: meta.format,
5599
+ revision: meta.revision,
5600
+ bytes: meta.bytes,
5601
+ createdAt: new Date(meta.createdAt).toISOString(),
5602
+ updatedAt: new Date(meta.updatedAt).toISOString(),
5603
+ ...meta.title !== void 0 && { title: meta.title },
5604
+ pinnedRevisions: [...meta.pinnedRevisions],
5605
+ persisted: true
5126
5606
  };
5127
5607
  }
5128
5608
  return {
5129
5609
  available: true,
5130
5610
  limits,
5611
+ ...persistence && {
5612
+ persistence: { root: persistence.root, limits: persistence.limits }
5613
+ },
5131
5614
  usage() {
5132
5615
  sweep();
5133
5616
  return { workspaces: entries.size, bytes: totalBytes() };
@@ -5168,15 +5651,18 @@ function createMemoryWorkspaceStore(options = {}) {
5168
5651
  updatedAt: at,
5169
5652
  touchedAt: at,
5170
5653
  ...input.title !== void 0 && { title: input.title },
5171
- pins: /* @__PURE__ */ new Map()
5654
+ pins: /* @__PURE__ */ new Map(),
5655
+ persisted: false
5172
5656
  };
5173
5657
  entries.set(entry.handle, entry);
5174
- return { ok: true, record: toRecord(entry) };
5658
+ const warning = await persist(entry);
5659
+ return committed(toRecord(entry), warning);
5175
5660
  },
5176
5661
  async get(handle, readOptions) {
5177
5662
  sweep();
5178
- const entry = entries.get(handle);
5179
- if (!entry) return missing(handle);
5663
+ const found = await resolve3(handle);
5664
+ if (!("entry" in found)) return found;
5665
+ const entry = found.entry;
5180
5666
  entry.touchedAt = now();
5181
5667
  let text = entry.text;
5182
5668
  let bytes = entry.bytes;
@@ -5201,15 +5687,16 @@ function createMemoryWorkspaceStore(options = {}) {
5201
5687
  context: { handle, pointer }
5202
5688
  });
5203
5689
  }
5204
- const found = resolvePointer(document, parsed.tokens);
5205
- if (found.found) projection[pointer] = found.value;
5690
+ const found2 = resolvePointer(document, parsed.tokens);
5691
+ if (found2.found) projection[pointer] = found2.value;
5206
5692
  }
5207
5693
  return { ok: true, record, document, projection };
5208
5694
  },
5209
5695
  async patch(input) {
5210
5696
  sweep();
5211
- const entry = entries.get(input.handle);
5212
- if (!entry) return missing(input.handle);
5697
+ const found = await resolve3(input.handle);
5698
+ if (!("entry" in found)) return found;
5699
+ const entry = found.entry;
5213
5700
  entry.touchedAt = now();
5214
5701
  if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
5215
5702
  return stale(
@@ -5275,12 +5762,14 @@ function createMemoryWorkspaceStore(options = {}) {
5275
5762
  entry.revision += 1;
5276
5763
  entry.updatedAt = now();
5277
5764
  entry.touchedAt = entry.updatedAt;
5278
- return { ok: true, record: toRecord(entry) };
5765
+ const warning = await persist(entry);
5766
+ return committed(toRecord(entry), warning);
5279
5767
  },
5280
5768
  async snapshot(handle) {
5281
5769
  sweep();
5282
- const entry = entries.get(handle);
5283
- if (!entry) return missing(handle);
5770
+ const found = await resolve3(handle);
5771
+ if (!("entry" in found)) return found;
5772
+ const entry = found.entry;
5284
5773
  entry.touchedAt = now();
5285
5774
  const revision = entry.revision;
5286
5775
  const document = JSON.parse(entry.text);
@@ -5289,24 +5778,79 @@ function createMemoryWorkspaceStore(options = {}) {
5289
5778
  entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
5290
5779
  }
5291
5780
  }
5292
- return { ok: true, record: toRecord(entry), document };
5293
- },
5294
- async list() {
5295
- sweep();
5781
+ const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
5296
5782
  return {
5297
5783
  ok: true,
5298
- records: [...entries.values()].map((entry) => toRecord(entry))
5784
+ record: toRecord(entry),
5785
+ document,
5786
+ ...warning && { warnings: [warning] }
5299
5787
  };
5300
5788
  },
5789
+ async list() {
5790
+ sweep();
5791
+ const records = [...entries.values()].map((entry) => toRecord(entry));
5792
+ if (!persistence) return { ok: true, records };
5793
+ const resident = new Set(records.map((record) => record.handle));
5794
+ let stored = [];
5795
+ try {
5796
+ stored = await persistence.list();
5797
+ } catch {
5798
+ }
5799
+ for (const meta of stored) {
5800
+ if (resident.has(meta.handle)) continue;
5801
+ if (tombstones.get(meta.handle)?.reason === "closed") continue;
5802
+ records.push(fromMeta(meta));
5803
+ }
5804
+ return { ok: true, records };
5805
+ },
5301
5806
  async close(handle) {
5302
5807
  sweep();
5303
5808
  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 };
5809
+ if (!persistence) {
5810
+ if (entry) evict(entry, "closed");
5811
+ return { ok: true, handle, closed: entry !== void 0 };
5812
+ }
5813
+ const root = persistence.root;
5814
+ try {
5815
+ return await queued(async () => {
5816
+ const removed = await persistence.remove(handle);
5817
+ const resident = entries.get(handle);
5818
+ if (resident) evict(resident, "closed");
5819
+ else if (removed) tombstone(handle, "closed", 0);
5820
+ return {
5821
+ ok: true,
5822
+ handle,
5823
+ closed: resident !== void 0 || removed
5824
+ };
5825
+ });
5826
+ } catch (error) {
5827
+ return failure(
5828
+ WORKSPACE_ERROR_CODES.NOT_CLOSED,
5829
+ `Workspace ${handle} could not be removed from ${root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
5830
+ {
5831
+ suggestion: "Check the workspace directory is writable, then close again.",
5832
+ context: { handle, workspaceRoot: root }
5833
+ }
5834
+ );
5835
+ }
5307
5836
  },
5837
+ /**
5838
+ * Release memory, keep the disk.
5839
+ *
5840
+ * The asymmetry with `close` is the point: this exists for a host
5841
+ * reclaiming memory at a moment of its own choosing, and #290 is precisely
5842
+ * about an event that ends a connection not being allowed to destroy
5843
+ * revisions. The handles come back from disk on next use.
5844
+ */
5308
5845
  async closeAll() {
5309
- for (const entry of [...entries.values()]) evict(entry, "closed");
5846
+ for (const entry of [...entries.values()]) {
5847
+ if (!persistence) {
5848
+ evict(entry, "closed");
5849
+ continue;
5850
+ }
5851
+ entries.delete(entry.handle);
5852
+ tombstones.delete(entry.handle);
5853
+ }
5310
5854
  }
5311
5855
  };
5312
5856
  function stale(entry, wanted, pinned, mutation = false) {
@@ -5326,7 +5870,7 @@ function createMemoryWorkspaceStore(options = {}) {
5326
5870
  }
5327
5871
  }
5328
5872
  function defaultHandle() {
5329
- return `ws_${randomBytes2(9).toString("base64url")}`;
5873
+ return `ws_${randomBytes3(9).toString("base64url")}`;
5330
5874
  }
5331
5875
  function serialize(document) {
5332
5876
  let text;
@@ -5379,7 +5923,7 @@ var workspaceSchema = {
5379
5923
  properties: {
5380
5924
  handle: {
5381
5925
  type: "string",
5382
- description: "Opaque, valid only on this connection."
5926
+ 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
5927
  },
5384
5928
  format: { type: "string", enum: [...FORMAT_NAMES] },
5385
5929
  revision: {
@@ -5397,6 +5941,10 @@ var workspaceSchema = {
5397
5941
  type: "array",
5398
5942
  items: { type: "integer" },
5399
5943
  description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
5944
+ },
5945
+ persisted: {
5946
+ type: "boolean",
5947
+ 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
5948
  }
5401
5949
  },
5402
5950
  required: [
@@ -5410,6 +5958,26 @@ var workspaceSchema = {
5410
5958
  ],
5411
5959
  additionalProperties: false
5412
5960
  };
5961
+ var persistenceSchema = {
5962
+ type: "object",
5963
+ description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
5964
+ properties: {
5965
+ root: { type: "string" },
5966
+ maxWorkspaces: { type: "integer" },
5967
+ maxRevisionsPerWorkspace: {
5968
+ type: "integer",
5969
+ description: "Revision files kept per handle: the head plus its pins."
5970
+ },
5971
+ maxEntryBytes: { type: "integer" }
5972
+ },
5973
+ required: [
5974
+ "root",
5975
+ "maxWorkspaces",
5976
+ "maxRevisionsPerWorkspace",
5977
+ "maxEntryBytes"
5978
+ ],
5979
+ additionalProperties: false
5980
+ };
5413
5981
  var limitsSchema = {
5414
5982
  type: "object",
5415
5983
  description: "What this connection allows. Bounded on purpose; see the codes above.",
@@ -5441,7 +6009,9 @@ function isMemoryStore(store) {
5441
6009
  }
5442
6010
  function ensureStore(deps) {
5443
6011
  if (deps.workspaces().available || hasWorkspaceStore()) return;
5444
- const owned = createMemoryWorkspaceStore();
6012
+ const owned = createMemoryWorkspaceStore(
6013
+ deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
6014
+ );
5445
6015
  deps.workspaces = () => owned;
5446
6016
  }
5447
6017
  function register8(server, deps) {
@@ -5484,9 +6054,9 @@ function register8(server, deps) {
5484
6054
  ...args.title !== void 0 && { title: args.title }
5485
6055
  });
5486
6056
  if (!created.ok) return created;
5487
- return success(
5488
- { workspace: created.record },
5489
- seeded ? [
6057
+ return success({ workspace: created.record }, [
6058
+ ...created.warnings ?? [],
6059
+ ...seeded ? [
5490
6060
  diagnostic(
5491
6061
  "W_BLANK_DOCUMENT",
5492
6062
  `Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
@@ -5496,7 +6066,7 @@ function register8(server, deps) {
5496
6066
  }
5497
6067
  )
5498
6068
  ] : []
5499
- );
6069
+ ]);
5500
6070
  })
5501
6071
  )
5502
6072
  );
@@ -5562,17 +6132,20 @@ function register8(server, deps) {
5562
6132
  const missingPaths = projecting ? args.paths.filter(
5563
6133
  (pointer) => !(pointer in projection)
5564
6134
  ) : [];
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
- }
6135
+ const diagnostics = [
6136
+ ...read.warnings ?? [],
6137
+ ...missingPaths.map(
6138
+ (pointer) => diagnostic(
6139
+ "W_PATH_NOT_FOUND",
6140
+ `${pointer} does not resolve in revision ${read.record.revision}.`,
6141
+ {
6142
+ severity: "warning",
6143
+ path: pointer,
6144
+ suggestion: "Read a shorter prefix of the pointer to see what is actually there."
6145
+ }
6146
+ )
5574
6147
  )
5575
- );
6148
+ ];
5576
6149
  return success(
5577
6150
  {
5578
6151
  workspace: read.record,
@@ -5641,7 +6214,7 @@ function register8(server, deps) {
5641
6214
  }
5642
6215
  });
5643
6216
  if (!patched.ok) return patched;
5644
- return success({ workspace: patched.record });
6217
+ return success({ workspace: patched.record }, patched.warnings ?? []);
5645
6218
  })
5646
6219
  )
5647
6220
  );
@@ -5687,7 +6260,7 @@ function register8(server, deps) {
5687
6260
  await guarded(async () => {
5688
6261
  const snapshot = await deps.workspaces().snapshot(args.handle);
5689
6262
  if (!snapshot.ok) return snapshot;
5690
- const diagnostics = [];
6263
+ const diagnostics = [...snapshot.warnings ?? []];
5691
6264
  if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
5692
6265
  diagnostics.push(
5693
6266
  diagnostic(
@@ -5730,7 +6303,7 @@ function register8(server, deps) {
5730
6303
  "jto_workspace_list",
5731
6304
  {
5732
6305
  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.",
6306
+ 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
6307
  annotations: { readOnlyHint: true, openWorldHint: false },
5735
6308
  inputSchema: S({
5736
6309
  type: "object",
@@ -5745,6 +6318,7 @@ function register8(server, deps) {
5745
6318
  description: "False when this connection has workspaces switched off."
5746
6319
  },
5747
6320
  limits: limitsSchema,
6321
+ persistence: persistenceSchema,
5748
6322
  usage: {
5749
6323
  type: "object",
5750
6324
  properties: {
@@ -5762,12 +6336,24 @@ function register8(server, deps) {
5762
6336
  const store = deps.workspaces();
5763
6337
  const listed = await store.list();
5764
6338
  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
- });
6339
+ const budget = isMemoryStore(store) ? {
6340
+ limits: store.limits,
6341
+ usage: store.usage(),
6342
+ ...store.persistence && {
6343
+ persistence: {
6344
+ root: store.persistence.root,
6345
+ ...store.persistence.limits
6346
+ }
6347
+ }
6348
+ } : {};
6349
+ return success(
6350
+ {
6351
+ workspaces: listed.records,
6352
+ available: store.available,
6353
+ ...budget
6354
+ },
6355
+ listed.warnings ?? []
6356
+ );
5771
6357
  })
5772
6358
  )
5773
6359
  );
@@ -5775,7 +6361,7 @@ function register8(server, deps) {
5775
6361
  "jto_workspace_close",
5776
6362
  {
5777
6363
  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.",
6364
+ 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
6365
  annotations: {
5780
6366
  readOnlyHint: false,
5781
6367
  destructiveHint: true,
@@ -5992,6 +6578,12 @@ function createServerFactory(deps) {
5992
6578
 
5993
6579
  // src/lib/deps.ts
5994
6580
  function createToolDeps(options = {}) {
6581
+ const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
6582
+ ...options.workspaceDir !== void 0 && {
6583
+ flagDir: options.workspaceDir
6584
+ },
6585
+ ...options.env !== void 0 && { env: options.env }
6586
+ });
5995
6587
  return {
5996
6588
  serverVersion: options.serverVersion ?? SERVER_VERSION,
5997
6589
  outputRoot: options.outputRoot ?? createOutputRoot({
@@ -6000,23 +6592,45 @@ function createToolDeps(options = {}) {
6000
6592
  }),
6001
6593
  getAdapter: options.getAdapter ?? getAdapter,
6002
6594
  workspaces: options.workspaces ?? getWorkspaceStore,
6595
+ ...workspacePersistence !== void 0 && { workspacePersistence },
6003
6596
  maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
6004
6597
  };
6005
6598
  }
6006
6599
 
6007
6600
  // src/cli.ts
6601
+ function takeValue(argv, index) {
6602
+ const next = argv[index];
6603
+ if (next === void 0 || next.startsWith("-") || next.trim() === "") {
6604
+ return void 0;
6605
+ }
6606
+ return { value: next };
6607
+ }
6008
6608
  function parseArgs(argv) {
6009
- const parsed = { version: false, help: false, unknown: [] };
6609
+ const parsed = {
6610
+ version: false,
6611
+ help: false,
6612
+ unknown: [],
6613
+ missingValue: []
6614
+ };
6010
6615
  for (let index = 0; index < argv.length; index += 1) {
6011
6616
  const arg = argv[index];
6012
6617
  if (arg === "--version" || arg === "-v") {
6013
6618
  parsed.version = true;
6014
6619
  } else if (arg === "--help" || arg === "-h") {
6015
6620
  parsed.help = true;
6016
- } else if (arg === "--output-dir") {
6017
- parsed.outputDir = argv[++index];
6621
+ } else if (arg === "--output-dir" || arg === "--workspace-dir") {
6622
+ const taken = takeValue(argv, index + 1);
6623
+ if (!taken) {
6624
+ parsed.missingValue.push(arg);
6625
+ continue;
6626
+ }
6627
+ index += 1;
6628
+ if (arg === "--output-dir") parsed.outputDir = taken.value;
6629
+ else parsed.workspaceDir = taken.value;
6018
6630
  } else if (arg.startsWith("--output-dir=")) {
6019
6631
  parsed.outputDir = arg.slice("--output-dir=".length);
6632
+ } else if (arg.startsWith("--workspace-dir=")) {
6633
+ parsed.workspaceDir = arg.slice("--workspace-dir=".length);
6020
6634
  } else {
6021
6635
  parsed.unknown.push(arg);
6022
6636
  }
@@ -6034,11 +6648,16 @@ Options:
6034
6648
  --output-dir <path> Directory generated files are written to. Overrides
6035
6649
  ${OUTPUT_DIR_ENV}. Defaults to a per-connection
6036
6650
  directory under the system temp dir.
6651
+ --workspace-dir <p> Directory workspace revisions are mirrored to, so a lost
6652
+ client session does not destroy them. Overrides
6653
+ ${WORKSPACE_DIR_ENV}. Off by default:
6654
+ without it, handles live only in memory.
6037
6655
  -v, --version Print the version and exit.
6038
6656
  -h, --help Print this help and exit.
6039
6657
 
6040
6658
  Environment:
6041
6659
  ${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
6660
+ ${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
6042
6661
  LIBREOFFICE_PATH LibreOffice binary, for preview.
6043
6662
  PDFTOPPM_PATH poppler pdftoppm binary, for preview.
6044
6663
 
@@ -6061,13 +6680,23 @@ function main(argv) {
6061
6680
  process.stderr.write(
6062
6681
  `jto-mcp: unknown argument ${args.unknown[0]}
6063
6682
  Run jto-mcp --help.
6683
+ `
6684
+ );
6685
+ process.exitCode = 1;
6686
+ return;
6687
+ }
6688
+ if (args.missingValue.length > 0) {
6689
+ process.stderr.write(
6690
+ `jto-mcp: ${args.missingValue[0]} needs a directory path.
6691
+ Run jto-mcp --help.
6064
6692
  `
6065
6693
  );
6066
6694
  process.exitCode = 1;
6067
6695
  return;
6068
6696
  }
6069
6697
  const deps = createToolDeps({
6070
- ...args.outputDir !== void 0 && { outputDir: args.outputDir }
6698
+ ...args.outputDir !== void 0 && { outputDir: args.outputDir },
6699
+ ...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
6071
6700
  });
6072
6701
  const handle = serveStdio(createServerFactory(deps), {
6073
6702
  legacy: "serve",