@mulmoclaude/mulmoscript-plugin 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/contract-BHqxMUWm.cjs +82 -0
  2. package/dist/contract-BHqxMUWm.cjs.map +1 -0
  3. package/dist/contract-BnIl6w_f.js +53 -0
  4. package/dist/contract-BnIl6w_f.js.map +1 -0
  5. package/dist/core/contract.d.ts +80 -6
  6. package/dist/core/contract.d.ts.map +1 -1
  7. package/dist/core/definition.d.ts.map +1 -1
  8. package/dist/core/paths.d.ts +41 -0
  9. package/dist/core/paths.d.ts.map +1 -1
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/lang/de.d.ts.map +1 -1
  13. package/dist/lang/en.d.ts.map +1 -1
  14. package/dist/lang/es.d.ts.map +1 -1
  15. package/dist/lang/fr.d.ts.map +1 -1
  16. package/dist/lang/ja.d.ts.map +1 -1
  17. package/dist/lang/ko.d.ts.map +1 -1
  18. package/dist/lang/messages.d.ts +4 -0
  19. package/dist/lang/messages.d.ts.map +1 -1
  20. package/dist/lang/ptBR.d.ts.map +1 -1
  21. package/dist/lang/zh.d.ts.map +1 -1
  22. package/dist/{plugin-BOq5YkTl.cjs → plugin-C5JC2lNR.cjs} +51 -1
  23. package/dist/plugin-C5JC2lNR.cjs.map +1 -0
  24. package/dist/{plugin-CAhxJ5WM.js → plugin-Jo8iGv4K.js} +40 -2
  25. package/dist/plugin-Jo8iGv4K.js.map +1 -0
  26. package/dist/server/dispatch.d.ts +0 -6
  27. package/dist/server/dispatch.d.ts.map +1 -1
  28. package/dist/server/ops.d.ts +29 -18
  29. package/dist/server/ops.d.ts.map +1 -1
  30. package/dist/server/types.d.ts +64 -2
  31. package/dist/server/types.d.ts.map +1 -1
  32. package/dist/server.cjs +423 -110
  33. package/dist/server.cjs.map +1 -1
  34. package/dist/server.js +423 -110
  35. package/dist/server.js.map +1 -1
  36. package/dist/style.css +46 -18
  37. package/dist/vue/View.vue.d.ts.map +1 -1
  38. package/dist/vue/composables/useDeckEditor.d.ts +1 -1
  39. package/dist/vue/composables/useDeckEditor.d.ts.map +1 -1
  40. package/dist/vue/helpers.d.ts +11 -8
  41. package/dist/vue/helpers.d.ts.map +1 -1
  42. package/dist/vue/index.d.ts +1 -1
  43. package/dist/vue/index.d.ts.map +1 -1
  44. package/dist/vue/subscription.d.ts +16 -0
  45. package/dist/vue/subscription.d.ts.map +1 -0
  46. package/dist/vue/transport.d.ts +16 -5
  47. package/dist/vue/transport.d.ts.map +1 -1
  48. package/dist/vue.cjs +244 -110
  49. package/dist/vue.cjs.map +1 -1
  50. package/dist/vue.js +244 -110
  51. package/dist/vue.js.map +1 -1
  52. package/package.json +11 -11
  53. package/dist/contract-BhN3yKJC.cjs +0 -36
  54. package/dist/contract-BhN3yKJC.cjs.map +0 -1
  55. package/dist/contract-CIt1ZAtt.js +0 -19
  56. package/dist/contract-CIt1ZAtt.js.map +0 -1
  57. package/dist/plugin-BOq5YkTl.cjs.map +0 -1
  58. package/dist/plugin-CAhxJ5WM.js.map +0 -1
package/dist/server.cjs CHANGED
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_plugin = require("./plugin-BOq5YkTl.cjs");
25
- const require_contract = require("./contract-BhN3yKJC.cjs");
24
+ const require_plugin = require("./plugin-C5JC2lNR.cjs");
25
+ const require_contract = require("./contract-BHqxMUWm.cjs");
26
26
  let fs = require("fs");
27
27
  let path = require("path");
28
28
  path = __toESM(path, 1);
@@ -190,11 +190,17 @@ async function withBeatProgress(beats, onBeat, body) {
190
190
  }
191
191
  /** Map identity for the in-flight tracker. JSON array keeps the three
192
192
  * fields unambiguous (a human-visible delimiter could collide). */
193
- function generationMapKey(kind, filePath, key) {
194
- return JSON.stringify([
193
+ function generationMapKey(kind, filePath, key, root) {
194
+ const normalized = require_contract.normalizeRoot(root);
195
+ return normalized === "" ? JSON.stringify([
195
196
  kind,
196
197
  filePath,
197
198
  key
199
+ ]) : JSON.stringify([
200
+ kind,
201
+ filePath,
202
+ key,
203
+ normalized
198
204
  ]);
199
205
  }
200
206
  /**
@@ -205,19 +211,38 @@ function generationMapKey(kind, filePath, key) {
205
211
  function createMulmoScriptServerOps(backend) {
206
212
  const log = backend.log ?? NOOP_LOG;
207
213
  setMulmoErrorCaptureLogger(log);
208
- const storiesDir = path.default.resolve(backend.storiesDir);
209
- function toStoryRef(absolutePath) {
210
- const root = ensureStoriesReal() ?? storiesDir;
211
- const rel = path.default.relative(root, absolutePath).split(path.default.sep).join("/");
212
- return rel ? `stories/${rel}` : "stories";
213
- }
214
- let storiesRealCache = null;
215
- function ensureStoriesReal() {
216
- if (storiesRealCache) return storiesRealCache;
214
+ const rootDirs = /* @__PURE__ */ new Map([["", path.default.resolve(backend.storiesDir)]]);
215
+ for (const [id, dir] of Object.entries(backend.extraRoots ?? {})) {
216
+ const trimmed = id.trim();
217
+ if (trimmed === "") throw new Error("mulmoScript: extraRoots key must not be empty — the empty id is reserved for the default stories root");
218
+ if (rootDirs.has(trimmed)) throw new Error(`mulmoScript: extraRoots keys must be distinct after trimming — "${trimmed}" is registered twice`);
219
+ rootDirs.set(trimmed, path.default.resolve(dir));
220
+ }
221
+ if (rootDirs.size > 1) log.warn(backend.rootScopedGenerationState === true ? "extra stories roots registered — reads, uploads and generation work; save/update still land in the DEFAULT root until this host passes `artifactsFor` (#3019)" : "extra stories roots registered — reads and uploads work, but GENERATION is refused until this host declares `rootScopedGenerationState` (its pending-generation state must carry the root, or it must keep none), and save/update land in the DEFAULT root until it passes `artifactsFor` (#3019)", { roots: [...rootDirs.keys()].filter((id) => id !== "") });
222
+ /** The registered directory for a wire `root`, or null when the host never
223
+ * registered it. Null is a REJECTION, not a fallback to the default: an
224
+ * unknown root must not quietly read the workspace's file of the same
225
+ * name. */
226
+ function rootDir(root) {
227
+ return rootDirs.get(require_contract.normalizeRoot(root)) ?? null;
228
+ }
229
+ function toStoryRef(absolutePath, root) {
230
+ const dir = rootDir(root);
231
+ if (dir === null) return null;
232
+ const base = ensureStoriesReal(root) ?? dir;
233
+ return require_plugin.storyRefWithin(base, absolutePath, path.default);
234
+ }
235
+ const storiesRealCache = /* @__PURE__ */ new Map();
236
+ function ensureStoriesReal(root) {
237
+ const dir = rootDir(root);
238
+ if (dir === null) return null;
239
+ const cached = storiesRealCache.get(dir);
240
+ if (cached) return cached;
217
241
  try {
218
- (0, fs.mkdirSync)(storiesDir, { recursive: true });
219
- storiesRealCache = (0, fs.realpathSync)(storiesDir);
220
- return storiesRealCache;
242
+ if (require_contract.normalizeRoot(root) === "") (0, fs.mkdirSync)(dir, { recursive: true });
243
+ const real = (0, fs.realpathSync)(dir);
244
+ storiesRealCache.set(dir, real);
245
+ return real;
221
246
  } catch {
222
247
  return null;
223
248
  }
@@ -233,8 +258,9 @@ function createMulmoScriptServerOps(backend) {
233
258
  * stories/ is a regular directory or a legitimate symlink to another
234
259
  * location. ENOENT and traversal are distinguished (404 vs 400).
235
260
  */
236
- function resolveStory(filePath) {
237
- const storiesReal = ensureStoriesReal();
261
+ function resolveStory(filePath, root) {
262
+ if (rootDir(root) === null) return opBadRequest("Unknown stories root");
263
+ const storiesReal = ensureStoriesReal(root);
238
264
  if (!storiesReal) return opServerError("stories directory not available");
239
265
  if (path.default.isAbsolute(filePath)) return opBadRequest("Invalid filePath");
240
266
  const ARTIFACTS_STORIES = "artifacts/stories";
@@ -254,6 +280,19 @@ function createMulmoScriptServerOps(backend) {
254
280
  };
255
281
  }
256
282
  /**
283
+ * Whether this root is one the host registered.
284
+ *
285
+ * Every other root-aware op learns this from `resolveStory`, which needs a
286
+ * file. `pendingGenerations` needs none — it only filters an in-memory map —
287
+ * so an unregistered root produced `{ ok: true, pending: [] }`, and a host
288
+ * typo or a stale card read back as "no work is running" (Codex P2 on
289
+ * #3015). An unknown root is a question this package cannot answer, and the
290
+ * answer it must not give is a confident empty one.
291
+ */
292
+ function guardStoryRootRegistered(root) {
293
+ return rootDir(root) === null ? opBadRequest(`unknown stories root "${require_contract.normalizeRoot(root)}"`) : null;
294
+ }
295
+ /**
257
296
  * Realpath containment pre-guard for wire paths handed to the phase-1
258
297
  * core's save/reopen/update executes. The core's own path guard is
259
298
  * lexical (it runs against the generic FileOps, whose read/write follows
@@ -265,11 +304,121 @@ function createMulmoScriptServerOps(backend) {
265
304
  * validation (including the script-vs-filePath mode check) belongs to
266
305
  * the core.
267
306
  */
268
- function guardStoryWirePath(filePath) {
307
+ function guardStoryWirePath(filePath, root) {
269
308
  if (typeof filePath !== "string" || filePath === "") return null;
270
- const resolved = resolveStory(filePath);
309
+ const resolved = resolveStory(filePath, root);
271
310
  return resolved.ok ? null : resolved;
272
311
  }
312
+ /**
313
+ * Whether a GENERATION may run in this root.
314
+ *
315
+ * The pair identity is complete inside this package but stops at the host
316
+ * boundary: a host's per-session store keys pending work by
317
+ * `(kind, filePath, key)` — `generationKey` in `@mulmobridge/protocol`,
318
+ * which bridges also consume. Two roots running the same generation in one
319
+ * session would collapse to one entry, and either completion would clear the
320
+ * other root's indicator (Codex P1 on #3015).
321
+ *
322
+ * Whose hazard it is decides who answers: a host declares
323
+ * `rootScopedGenerationState` when its own pending state carries the root —
324
+ * or when it keeps none, which is MulmoTerminal's case. It was refused for a
325
+ * collision it cannot have (#3019).
326
+ *
327
+ * Absent still refuses, so a host that has not thought about this keeps the
328
+ * shipped behaviour rather than being quietly opened up.
329
+ */
330
+ function guardStoryGenerationRoot(root) {
331
+ if (require_contract.normalizeRoot(root) === "") return null;
332
+ const registered = guardStoryRootRegistered(root);
333
+ if (registered) return registered;
334
+ if (backend.rootScopedGenerationState === true) return null;
335
+ return opBadRequest("generating in a non-default stories root is not supported yet");
336
+ }
337
+ /**
338
+ * The write half of the same fail-closed rule.
339
+ *
340
+ * It lives in the ops that write rather than at their dispatch sites,
341
+ * because a per-site guard is a list someone has to remember to extend:
342
+ * `save` / `updateBeat` / `updateScript` were guarded and the two upload
343
+ * kinds were not, so an image could still land in a named root
344
+ * (CodeRabbit on #3015). `save` / `update* ` cannot follow suit — they run
345
+ * through the package executors, not through these ops — so
346
+ * `test_server_roots.ts` walks the whole ops surface and fails on any op
347
+ * that is neither in the read-only allowlist nor refusing.
348
+ */
349
+ /**
350
+ * Whether a WRITE may target this root.
351
+ *
352
+ * Reads are root-aware; writes are not. `executeMulmoScriptSave` and the
353
+ * update executors run against one `FileOps`, bound by the host to the
354
+ * default root, so a write naming another root would rewrite the DEFAULT
355
+ * root's identically-named file and then announce the other one as changed
356
+ * (#3015 review G1). Closing it was fail-closed: "readable but not yet
357
+ * writable" beats "wrote somewhere else and said so".
358
+ *
359
+ * It opens per root, not globally: the host answers `artifactsFor` for the
360
+ * roots it can serve, and a root it cannot is still refused. A host that
361
+ * wires nothing keeps the shipped refusal (#3019).
362
+ */
363
+ function guardStoryWriteRoot(root) {
364
+ if (require_contract.normalizeRoot(root) === "") return null;
365
+ const registered = guardStoryRootRegistered(root);
366
+ if (registered) return registered;
367
+ return artifactsForRoot(root) === null ? opBadRequest("writing to a non-default stories root is not supported yet") : null;
368
+ }
369
+ /**
370
+ * The FileOps a write to this root must go through.
371
+ *
372
+ * The default root keeps the single `artifacts` it always had, byte for
373
+ * byte. A named root is served only when the host both REGISTERED it
374
+ * (`extraRoots` — the containment boundary, so a host cannot widen the
375
+ * addressable set through this back door) and can supply a FileOps for it.
376
+ */
377
+ function artifactsForRoot(root) {
378
+ const normalized = require_contract.normalizeRoot(root);
379
+ if (normalized === "") return backend.artifacts;
380
+ if (rootDir(normalized) === null) return null;
381
+ const hostOps = backend.artifactsFor?.(normalized);
382
+ return hostOps === void 0 || hostOps === null ? null : storiesScoped(hostOps);
383
+ }
384
+ /**
385
+ * Address a named root's FileOps the way the READ side addresses it.
386
+ *
387
+ * The two sides disagreed about one path segment, and the disagreement was
388
+ * silent. A read strips `stories/` and resolves under the registered
389
+ * directory (`<root>/<rel>`); a write handed the executors' wire path
390
+ * straight to the host's FileOps, which is rooted at that same registered
391
+ * directory, so the bytes landed in `<root>/stories/<rel>`. `save` then
392
+ * REPORTED SUCCESS and returned a wire path that could not be read back —
393
+ * a card pointing at nothing (#3020 review H1, from the consuming host).
394
+ *
395
+ * Stripping here makes "the directory you registered is the directory your
396
+ * FileOps is rooted at" true, which is what a host writes without being
397
+ * told. The default root is untouched: its FileOps is rooted one level up,
398
+ * at `<workspace>/artifacts`, and is shared with other plugins.
399
+ *
400
+ * A path that is not a stories wire path throws rather than passing
401
+ * through. Everything reaching here has been through `normalizeStoryPath`
402
+ * or `storyFilePath`, so one that has not is a bug — and letting it through
403
+ * would write it somewhere nobody can read, which is the failure this whole
404
+ * wrapper exists to end.
405
+ */
406
+ function storiesScoped(inner) {
407
+ const within = (wirePath) => {
408
+ const relative = require_plugin.storiesRelativePath(wirePath);
409
+ if (relative === null) throw new Error(`mulmoScript: "${wirePath}" is not a stories path — a named root's FileOps takes stories paths only`);
410
+ return relative;
411
+ };
412
+ return {
413
+ read: async (wirePath) => inner.read(within(wirePath)),
414
+ readBytes: async (wirePath) => inner.readBytes(within(wirePath)),
415
+ write: async (wirePath, data) => inner.write(within(wirePath), data),
416
+ readDir: async (wirePath) => inner.readDir(within(wirePath)),
417
+ stat: async (wirePath) => inner.stat(within(wirePath)),
418
+ exists: async (wirePath) => inner.exists(within(wirePath)),
419
+ unlink: async (wirePath) => inner.unlink(within(wirePath))
420
+ };
421
+ }
273
422
  function ffmpegGuard() {
274
423
  if (backend.isFfmpegAvailable?.() === false) return {
275
424
  ok: false,
@@ -279,6 +428,13 @@ function createMulmoScriptServerOps(backend) {
279
428
  return null;
280
429
  }
281
430
  const inFlightGenerations = /* @__PURE__ */ new Map();
431
+ /** Emit `root` only when it names a non-default one: an event carrying
432
+ * `root: ""` and one carrying nothing must stay indistinguishable to every
433
+ * pre-#3014 consumer. */
434
+ function rootField(root) {
435
+ const normalized = require_contract.normalizeRoot(root);
436
+ return normalized === "" ? {} : { root: normalized };
437
+ }
282
438
  /** Tracker state and events key on the canonical `stories/<rel>` wire
283
439
  * form: subscribers (the View's pubsub filter, `pendingGenerations`
284
440
  * callers) match by exact string, so the accepted alias spellings
@@ -288,9 +444,23 @@ function createMulmoScriptServerOps(backend) {
288
444
  function canonicalWirePath(filePath) {
289
445
  return require_plugin.normalizeStoryPath(filePath) ?? filePath;
290
446
  }
291
- function publishGeneration(chatSessionId, kind, filePath, key, finished, error) {
447
+ /**
448
+ * `error` and `root` travel in an options object, NOT as two trailing
449
+ * optional positionals.
450
+ *
451
+ * They were positional once, and appending `root` to the sixteen call sites
452
+ * put it in `error`'s slot at the nine that pass no error: every start event
453
+ * carried `error: "<root>"`, the root was dropped from the key, the tracker
454
+ * entry was filed under the default root — and because the matching finish
455
+ * DID pass both, its key differed and the entry was never deleted, leaking
456
+ * one row per generation for the life of the process. Two adjacent optional
457
+ * strings cannot be told apart by the type checker, so the shape is the only
458
+ * thing that can prevent it (#3015 review).
459
+ */
460
+ function publishGeneration(chatSessionId, kind, filePath, key, finished, opts = {}) {
461
+ const { error, root } = opts;
292
462
  const wirePath = canonicalWirePath(filePath);
293
- const mapKey = generationMapKey(kind, wirePath, key);
463
+ const mapKey = generationMapKey(kind, wirePath, key, root);
294
464
  const existing = inFlightGenerations.get(mapKey);
295
465
  if (finished) {
296
466
  if (existing && existing.count > 1) {
@@ -307,6 +477,7 @@ function createMulmoScriptServerOps(backend) {
307
477
  kind,
308
478
  filePath: wirePath,
309
479
  key,
480
+ root: require_contract.normalizeRoot(root),
310
481
  count: 1
311
482
  });
312
483
  }
@@ -315,7 +486,8 @@ function createMulmoScriptServerOps(backend) {
315
486
  filePath: wirePath,
316
487
  key,
317
488
  done: finished,
318
- ...error ? { error } : {}
489
+ ...error ? { error } : {},
490
+ ...rootField(root)
319
491
  };
320
492
  backend.onGenerationEvent?.(chatSessionId, event);
321
493
  }
@@ -326,21 +498,31 @@ function createMulmoScriptServerOps(backend) {
326
498
  * save — reloading there would rebuild the element the caret is in, mid-keystroke. A write
327
499
  * from the agent carries no origin, so everyone reloads.
328
500
  */
329
- function publishScriptChanged(filePath, origin) {
501
+ function publishScriptChanged(filePath, origin, root) {
330
502
  backend.onScriptChanged?.({
331
503
  filePath: canonicalWirePath(filePath),
332
- ...origin === void 0 ? {} : { origin }
504
+ ...origin === void 0 ? {} : { origin },
505
+ ...rootField(root)
333
506
  });
334
507
  }
335
- /** Snapshot of generations currently in flight for one script — the
336
- * View's mount-time catch-up, filtered to its wire `filePath`. */
337
- function pendingGenerations(filePath) {
508
+ /**
509
+ * Snapshot of generations currently in flight for one script — the View's
510
+ * mount-time catch-up.
511
+ *
512
+ * Filtered on the PAIR. Filtering on `filePath` alone hands a View watching
513
+ * `repoB/stories/deck.json` the run started for `repoA`'s identically-named
514
+ * deck, and the caller cannot discard it because the returned event would
515
+ * carry no root either (Codex P1 / CodeRabbit on #3015).
516
+ */
517
+ function pendingGenerations(filePath, root) {
338
518
  const wirePath = canonicalWirePath(filePath);
339
- return [...inFlightGenerations.values()].filter((entry) => entry.filePath === wirePath).map(({ kind, key }) => ({
519
+ const wanted = require_contract.normalizeRoot(root);
520
+ return [...inFlightGenerations.values()].filter((entry) => entry.filePath === wirePath && entry.root === wanted).map(({ kind, key }) => ({
340
521
  kind,
341
522
  filePath: wirePath,
342
523
  key,
343
- done: false
524
+ done: false,
525
+ ...rootField(root)
344
526
  }));
345
527
  }
346
528
  /**
@@ -352,7 +534,7 @@ function createMulmoScriptServerOps(backend) {
352
534
  async function runStoryOp(filePath, options, handler, deps = {}) {
353
535
  const resolver = deps.resolveStory ?? resolveStory;
354
536
  const build = deps.buildContext ?? buildContext;
355
- const resolved = resolver(filePath);
537
+ const resolved = resolver(filePath, options.root);
356
538
  if (!resolved.ok) return resolved;
357
539
  try {
358
540
  const context = await build(resolved.absolutePath, options.force ?? false);
@@ -373,8 +555,11 @@ function createMulmoScriptServerOps(backend) {
373
555
  return opServerError(require_plugin.errorMessage(err));
374
556
  }
375
557
  }
376
- async function beatImageOp(filePath, beatIndex) {
377
- return runStoryOp(filePath, { operation: "beat-image" }, async ({ context }) => {
558
+ async function beatImageOp(filePath, beatIndex, root) {
559
+ return runStoryOp(filePath, {
560
+ operation: "beat-image",
561
+ root
562
+ }, async ({ context }) => {
378
563
  const { imagePath } = (0, mulmocast.getBeatPngImagePath)(context, beatIndex);
379
564
  if (!(0, fs.existsSync)(imagePath)) return {
380
565
  ok: true,
@@ -386,9 +571,10 @@ function createMulmoScriptServerOps(backend) {
386
571
  };
387
572
  });
388
573
  }
389
- async function beatAudioOp(filePath, beatIndex) {
574
+ async function beatAudioOp(filePath, beatIndex, root) {
390
575
  return runStoryOp(filePath, {
391
576
  operation: "beat-audio",
577
+ root,
392
578
  onContextMissing: () => ({
393
579
  ok: true,
394
580
  audio: null
@@ -410,8 +596,11 @@ function createMulmoScriptServerOps(backend) {
410
596
  };
411
597
  });
412
598
  }
413
- async function beatMovieOp(filePath, beatIndex) {
414
- return runStoryOp(filePath, { operation: "beat-movie" }, async ({ context }) => {
599
+ async function beatMovieOp(filePath, beatIndex, root) {
600
+ return runStoryOp(filePath, {
601
+ operation: "beat-movie",
602
+ root
603
+ }, async ({ context }) => {
415
604
  const { movieFile, soundEffectFile, lipSyncFile } = (0, mulmocast.getBeatMoviePaths)(context, beatIndex);
416
605
  const existing = [
417
606
  lipSyncFile,
@@ -421,12 +610,15 @@ function createMulmoScriptServerOps(backend) {
421
610
  ].find((candidate) => (0, fs.existsSync)(candidate));
422
611
  return {
423
612
  ok: true,
424
- moviePath: existing ? toStoryRef(existing) : null
613
+ moviePath: existing ? toStoryRef(existing, root) : null
425
614
  };
426
615
  });
427
616
  }
428
- async function characterImageOp(filePath, key) {
429
- return runStoryOp(filePath, { operation: "character-image" }, async ({ context }) => {
617
+ async function characterImageOp(filePath, key, root) {
618
+ return runStoryOp(filePath, {
619
+ operation: "character-image",
620
+ root
621
+ }, async ({ context }) => {
430
622
  const imagePath = (0, mulmocast.getReferenceImagePath)(context, key, "png");
431
623
  if (!(0, fs.existsSync)(imagePath)) return {
432
624
  ok: true,
@@ -441,46 +633,51 @@ function createMulmoScriptServerOps(backend) {
441
633
  /** Shared "output exists and is newer than the source script" gate for
442
634
  * movie / PDF status. A stale artifact (script edited after it was
443
635
  * generated) reports null so the UI re-offers the Generate button. */
444
- function freshOutputRef(outputPath, absoluteFilePath) {
636
+ function freshOutputRef(outputPath, absoluteFilePath, root) {
445
637
  if (!(0, fs.existsSync)(outputPath)) return null;
446
638
  if ((0, fs.statSync)(outputPath).mtimeMs < (0, fs.statSync)(absoluteFilePath).mtimeMs) return null;
447
- return toStoryRef(outputPath);
639
+ return toStoryRef(outputPath, root);
448
640
  }
449
- async function movieStatusOp(filePath) {
641
+ async function movieStatusOp(filePath, root) {
450
642
  return runStoryOp(filePath, {
451
643
  operation: "movie-status",
644
+ root,
452
645
  onContextMissing: () => ({
453
646
  ok: true,
454
647
  moviePath: null
455
648
  })
456
649
  }, async ({ absoluteFilePath, context }) => ({
457
650
  ok: true,
458
- moviePath: freshOutputRef((0, mulmocast.movieFilePath)(context), absoluteFilePath)
651
+ moviePath: freshOutputRef((0, mulmocast.movieFilePath)(context), absoluteFilePath, root)
459
652
  }));
460
653
  }
461
- async function pdfStatusOp(filePath) {
654
+ async function pdfStatusOp(filePath, root) {
462
655
  return runStoryOp(filePath, {
463
656
  operation: "pdf-status",
657
+ root,
464
658
  onContextMissing: () => ({
465
659
  ok: true,
466
660
  pdfPath: null
467
661
  })
468
662
  }, async ({ absoluteFilePath, context }) => ({
469
663
  ok: true,
470
- pdfPath: freshOutputRef((0, mulmocast.pdfFilePath)(context, PDF_MODE), absoluteFilePath)
664
+ pdfPath: freshOutputRef((0, mulmocast.pdfFilePath)(context, PDF_MODE), absoluteFilePath, root)
471
665
  }));
472
666
  }
473
667
  async function renderBeatOp(args) {
474
- const { filePath, beatIndex, force, chatSessionId } = args;
668
+ const { filePath, beatIndex, force, chatSessionId, root } = args;
669
+ const rootGuard = guardStoryGenerationRoot(root);
670
+ if (rootGuard) return rootGuard;
475
671
  const ffmpeg = ffmpegGuard();
476
672
  if (ffmpeg) return ffmpeg;
477
673
  const mapKey = String(beatIndex);
478
- publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false);
674
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false, { root });
479
675
  let genError;
480
676
  try {
481
677
  const result = await runStoryOp(filePath, {
482
678
  force,
483
- operation: "render-beat"
679
+ operation: "render-beat",
680
+ root
484
681
  }, async ({ context }) => {
485
682
  await (0, mulmocast.generateBeatImage)({
486
683
  index: beatIndex,
@@ -497,18 +694,24 @@ function createMulmoScriptServerOps(backend) {
497
694
  if (!result.ok) genError = result.error;
498
695
  return result;
499
696
  } finally {
500
- publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, genError);
697
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, {
698
+ error: genError,
699
+ root
700
+ });
501
701
  }
502
702
  }
503
703
  async function generateBeatAudioOp(args) {
504
- const { filePath, beatIndex, force, chatSessionId } = args;
704
+ const { filePath, beatIndex, force, chatSessionId, root } = args;
705
+ const rootGuard = guardStoryGenerationRoot(root);
706
+ if (rootGuard) return rootGuard;
505
707
  const mapKey = String(beatIndex);
506
- publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false);
708
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false, { root });
507
709
  let genError;
508
710
  try {
509
711
  const result = await runStoryOp(filePath, {
510
712
  force,
511
- operation: "generate-beat-audio"
713
+ operation: "generate-beat-audio",
714
+ root
512
715
  }, async ({ context }) => {
513
716
  await (0, mulmocast.generateBeatAudio)(beatIndex, context, { settings: process.env });
514
717
  const beat = context.studio.script.beats[beatIndex];
@@ -531,17 +734,23 @@ function createMulmoScriptServerOps(backend) {
531
734
  if (!result.ok) genError = result.error;
532
735
  return result;
533
736
  } finally {
534
- publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, genError);
737
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, {
738
+ error: genError,
739
+ root
740
+ });
535
741
  }
536
742
  }
537
743
  async function renderCharacterOp(args) {
538
- const { filePath, key, force, chatSessionId } = args;
539
- publishGeneration(chatSessionId, "characterImage", filePath, key, false);
744
+ const { filePath, key, force, chatSessionId, root } = args;
745
+ const rootGuard = guardStoryGenerationRoot(root);
746
+ if (rootGuard) return rootGuard;
747
+ publishGeneration(chatSessionId, "characterImage", filePath, key, false, { root });
540
748
  let genError;
541
749
  try {
542
750
  const result = await runStoryOp(filePath, {
543
751
  force,
544
- operation: "render-character"
752
+ operation: "render-character",
753
+ root
545
754
  }, async ({ context }) => {
546
755
  const imageEntries = context.studio.script.imageParams?.images ?? {};
547
756
  const imageEntry = imageEntries[key];
@@ -565,11 +774,17 @@ function createMulmoScriptServerOps(backend) {
565
774
  if (!result.ok) genError = result.error;
566
775
  return result;
567
776
  } finally {
568
- publishGeneration(chatSessionId, "characterImage", filePath, key, true, genError);
777
+ publishGeneration(chatSessionId, "characterImage", filePath, key, true, {
778
+ error: genError,
779
+ root
780
+ });
569
781
  }
570
782
  }
571
- async function uploadBeatImageOp(filePath, beatIndex, imageData) {
572
- return runStoryOp(filePath, { operation: "upload-beat-image" }, async ({ context }) => {
783
+ async function uploadBeatImageOp(filePath, beatIndex, imageData, root) {
784
+ return runStoryOp(filePath, {
785
+ operation: "upload-beat-image",
786
+ root
787
+ }, async ({ context }) => {
573
788
  const { imagePath } = (0, mulmocast.getBeatPngImagePath)(context, beatIndex);
574
789
  const base64 = stripDataUri(imageData);
575
790
  await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
@@ -579,8 +794,11 @@ function createMulmoScriptServerOps(backend) {
579
794
  };
580
795
  });
581
796
  }
582
- async function uploadCharacterImageOp(filePath, key, imageData) {
583
- return runStoryOp(filePath, { operation: "upload-character-image" }, async ({ context }) => {
797
+ async function uploadCharacterImageOp(filePath, key, imageData, root) {
798
+ return runStoryOp(filePath, {
799
+ operation: "upload-character-image",
800
+ root
801
+ }, async ({ context }) => {
584
802
  const imagePath = (0, mulmocast.getReferenceImagePath)(context, key, "png");
585
803
  const base64 = stripDataUri(imageData);
586
804
  await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
@@ -629,54 +847,68 @@ function createMulmoScriptServerOps(backend) {
629
847
  * initiating View (and any other mounted View) reloads assets off disk
630
848
  * as they land — the successor of the SSE per-beat events.
631
849
  */
632
- async function generateMovieOp(filePath, chatSessionId) {
850
+ async function generateMovieOp(filePath, chatSessionId, root) {
851
+ const rootGuard = guardStoryGenerationRoot(root);
852
+ if (rootGuard) return rootGuard;
633
853
  const ffmpeg = ffmpegGuard();
634
854
  if (ffmpeg) return ffmpeg;
635
- const resolved = resolveStory(filePath);
855
+ const resolved = resolveStory(filePath, root);
636
856
  if (!resolved.ok) return resolved;
637
857
  const absoluteFilePath = resolved.absolutePath;
638
858
  if (inFlightMovies.has(absoluteFilePath)) return opBadRequest("Movie generation is already in progress for this script");
639
859
  inFlightMovies.add(absoluteFilePath);
640
- publishGeneration(chatSessionId, "movie", filePath, "", false);
860
+ publishGeneration(chatSessionId, "movie", filePath, "", false, { root });
641
861
  let genError;
642
862
  try {
643
863
  const result = await runMovieGeneration(absoluteFilePath, (event) => {
644
- publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true);
864
+ publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true, { root });
645
865
  });
646
866
  if (!result.ok) {
647
867
  genError = result.error;
648
868
  return opServerError(result.error);
649
869
  }
870
+ const movieRef = toStoryRef(result.outputPath, root);
871
+ if (movieRef === null) return opServerError("generated movie is outside the registered stories root");
650
872
  return {
651
873
  ok: true,
652
- moviePath: toStoryRef(result.outputPath)
874
+ moviePath: movieRef
653
875
  };
654
876
  } catch (err) {
655
877
  genError = require_plugin.errorMessage(err);
656
878
  return opServerError(genError);
657
879
  } finally {
658
880
  inFlightMovies.delete(absoluteFilePath);
659
- publishGeneration(chatSessionId, "movie", filePath, "", true, genError);
881
+ publishGeneration(chatSessionId, "movie", filePath, "", true, {
882
+ error: genError,
883
+ root
884
+ });
660
885
  }
661
886
  }
662
- function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId) {
887
+ function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId, root) {
888
+ if (guardStoryGenerationRoot(root)) {
889
+ log.warn("refused an auto background movie in a non-default stories root", {
890
+ filePath: wireFilePath,
891
+ root
892
+ });
893
+ return;
894
+ }
663
895
  if (inFlightMovies.has(absoluteFilePath)) return;
664
896
  inFlightMovies.add(absoluteFilePath);
665
- runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
897
+ runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root);
666
898
  }
667
- async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId) {
899
+ async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root) {
668
900
  const errorSidecarPath = `${absoluteFilePath}.error.txt`;
669
901
  try {
670
902
  (0, fs.unlinkSync)(errorSidecarPath);
671
903
  } catch {}
672
- publishGeneration(chatSessionId, "movie", wireFilePath, "", false);
904
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", false, { root });
673
905
  let genError;
674
906
  try {
675
907
  const result = await runMovieGeneration(absoluteFilePath, (event) => {
676
908
  const eventKind = event.kind === "image" ? "beatImage" : "beatAudio";
677
909
  const key = String(event.beatIndex);
678
- publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
679
- setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
910
+ publishGeneration(chatSessionId, eventKind, wireFilePath, key, false, { root });
911
+ setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true, { root }));
680
912
  });
681
913
  if (!result.ok) {
682
914
  genError = result.error;
@@ -700,7 +932,10 @@ function createMulmoScriptServerOps(backend) {
700
932
  });
701
933
  } finally {
702
934
  inFlightMovies.delete(absoluteFilePath);
703
- publishGeneration(chatSessionId, "movie", wireFilePath, "", true, genError);
935
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", true, {
936
+ error: genError,
937
+ root
938
+ });
704
939
  }
705
940
  }
706
941
  async function writeErrorSidecar(errorSidecarPath, message) {
@@ -736,15 +971,17 @@ function createMulmoScriptServerOps(backend) {
736
971
  }
737
972
  /** Long-held foreground PDF generation (the package View's `generatePdf`
738
973
  * dispatch) — the PDF sibling of `generateMovieOp`. */
739
- async function generatePdfOp(filePath, chatSessionId) {
974
+ async function generatePdfOp(filePath, chatSessionId, root) {
975
+ const rootGuard = guardStoryGenerationRoot(root);
976
+ if (rootGuard) return rootGuard;
740
977
  const ffmpeg = ffmpegGuard();
741
978
  if (ffmpeg) return ffmpeg;
742
- const resolved = resolveStory(filePath);
979
+ const resolved = resolveStory(filePath, root);
743
980
  if (!resolved.ok) return resolved;
744
981
  const absoluteFilePath = resolved.absolutePath;
745
982
  if (inFlightPdfs.has(absoluteFilePath)) return opBadRequest("PDF generation is already in progress for this script");
746
983
  inFlightPdfs.add(absoluteFilePath);
747
- publishGeneration(chatSessionId, "pdf", filePath, "", false);
984
+ publishGeneration(chatSessionId, "pdf", filePath, "", false, { root });
748
985
  let genError;
749
986
  try {
750
987
  const context = await buildContext(absoluteFilePath);
@@ -753,22 +990,27 @@ function createMulmoScriptServerOps(backend) {
753
990
  return opServerError(genError);
754
991
  }
755
992
  const result = await runPdfGeneration(context, (beatIndex) => {
756
- publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true);
993
+ publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true, { root });
757
994
  });
758
995
  if (!result.ok) {
759
996
  genError = result.error;
760
997
  return opServerError(result.error);
761
998
  }
999
+ const pdfRef = toStoryRef(result.outputPath, root);
1000
+ if (pdfRef === null) return opServerError("generated PDF is outside the registered stories root");
762
1001
  return {
763
1002
  ok: true,
764
- pdfPath: toStoryRef(result.outputPath)
1003
+ pdfPath: pdfRef
765
1004
  };
766
1005
  } catch (err) {
767
1006
  genError = require_plugin.errorMessage(err);
768
1007
  return opServerError(genError);
769
1008
  } finally {
770
1009
  inFlightPdfs.delete(absoluteFilePath);
771
- publishGeneration(chatSessionId, "pdf", filePath, "", true, genError);
1010
+ publishGeneration(chatSessionId, "pdf", filePath, "", true, {
1011
+ error: genError,
1012
+ root
1013
+ });
772
1014
  }
773
1015
  }
774
1016
  return {
@@ -776,6 +1018,10 @@ function createMulmoScriptServerOps(backend) {
776
1018
  toStoryRef,
777
1019
  resolveStory,
778
1020
  guardStoryWirePath,
1021
+ guardStoryRootRegistered,
1022
+ guardStoryWriteRoot,
1023
+ artifactsForRoot,
1024
+ guardStoryGenerationRoot,
779
1025
  ffmpegGuard,
780
1026
  runStoryOp,
781
1027
  publishGeneration,
@@ -874,12 +1120,56 @@ var UPLOAD_KINDS = /* @__PURE__ */ new Set(["uploadBeatImage", "uploadCharacterI
874
1120
  * FileOps, guarded by the instance's realpath containment
875
1121
  * (`guardStoryWirePath`) — the core's own guard is lexical.
876
1122
  */
1123
+ /**
1124
+ * Stamp the root a successful result acted in.
1125
+ *
1126
+ * Only on success: a failure carries `code` and `error`, and adding a root to
1127
+ * it would invite a reader to treat the pair as addressable when the call did
1128
+ * not happen. Only when NON-default, so a result for a call that named no root
1129
+ * stays byte-identical to what this package returned before roots existed —
1130
+ * which is what keeps every existing card working untouched.
1131
+ */
1132
+ function withRoot(result, root) {
1133
+ const normalized = require_contract.normalizeRoot(root);
1134
+ if (normalized === "") return result;
1135
+ if (!isRecord(result) || result.ok !== true) return result;
1136
+ return {
1137
+ ...result,
1138
+ root: normalized
1139
+ };
1140
+ }
1141
+ /** `undefined` when `root` is absent or a string; a failure envelope otherwise. */
1142
+ function guardSuppliedRoot(root) {
1143
+ if (root === void 0 || typeof root === "string") return void 0;
1144
+ return {
1145
+ ok: false,
1146
+ code: "bad_request",
1147
+ error: `mulmoScript root must be a string, got ${typeof root}`
1148
+ };
1149
+ }
877
1150
  function createMulmoScriptDispatchHandler(ops) {
878
- const executeContext = { files: { artifacts: ops.backend.artifacts } };
1151
+ /**
1152
+ * The executor context for a write, bound to the root it names.
1153
+ *
1154
+ * One `FileOps` was held for the whole handler, so the executors — which
1155
+ * take a WIRE path (`stories/…`) and resolve it against whatever FileOps
1156
+ * they are given — wrote a named root's script into the DEFAULT root's
1157
+ * identically-named file. Choosing here keeps `MulmoScriptExecuteContext`
1158
+ * and every executor unchanged: the root never reaches them, only the right
1159
+ * FileOps does (#3019).
1160
+ */
1161
+ function executeContextFor(root) {
1162
+ const artifacts = ops.artifactsForRoot(root);
1163
+ return artifacts === null ? null : { files: { artifacts } };
1164
+ }
879
1165
  async function saveKind(args) {
880
- const guard = ops.guardStoryWirePath(args.filePath);
1166
+ const rootGuard = ops.guardStoryWriteRoot(str(args.root));
1167
+ if (rootGuard) return fromOpFailure(rootGuard);
1168
+ const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
881
1169
  if (guard) return fromOpFailure(guard);
882
- const outcome = await require_plugin.executeMulmoScriptSave(executeContext, {
1170
+ const context = executeContextFor(str(args.root));
1171
+ if (context === null) return invalidArgs("save");
1172
+ const outcome = await require_plugin.executeMulmoScriptSave(context, {
883
1173
  script: args.script,
884
1174
  filename: str(args.filename),
885
1175
  filePath: str(args.filePath)
@@ -893,11 +1183,15 @@ function createMulmoScriptDispatchHandler(ops) {
893
1183
  };
894
1184
  }
895
1185
  async function updateKind(kind, args) {
896
- const guard = ops.guardStoryWirePath(args.filePath);
1186
+ const rootGuard = ops.guardStoryWriteRoot(str(args.root));
1187
+ if (rootGuard) return fromOpFailure(rootGuard);
1188
+ const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
897
1189
  if (guard) return fromOpFailure(guard);
898
- const outcome = kind === "updateBeat" ? await require_plugin.executeUpdateBeat(executeContext, args) : await require_plugin.executeUpdateScript(executeContext, args);
1190
+ const context = executeContextFor(str(args.root));
1191
+ if (context === null) return invalidArgs(kind);
1192
+ const outcome = kind === "updateBeat" ? await require_plugin.executeUpdateBeat(context, args) : await require_plugin.executeUpdateScript(context, args);
899
1193
  if (!outcome.ok) return fromPackageFailure(outcome);
900
- ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin));
1194
+ ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin), str(args.root));
901
1195
  return { ok: true };
902
1196
  }
903
1197
  const STATUS_OPS = {
@@ -913,30 +1207,37 @@ function createMulmoScriptDispatchHandler(ops) {
913
1207
  const statusOp = STATUS_OPS[kind];
914
1208
  if (statusOp) {
915
1209
  const filePath = str(args.filePath);
916
- return filePath ? envelope(await statusOp(filePath)) : invalidArgs(kind);
1210
+ return filePath ? envelope(await statusOp(filePath, str(args.root))) : invalidArgs(kind);
917
1211
  }
918
1212
  if (kind === "characterImage") {
919
1213
  const parsed = keyArgs(args);
920
- return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key)) : invalidArgs(kind);
1214
+ return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key, str(args.root))) : invalidArgs(kind);
921
1215
  }
922
1216
  const parsed = beatArgs(args);
923
1217
  if (!parsed) return invalidArgs(kind);
924
- return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex));
1218
+ return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex, str(args.root)));
1219
+ }
1220
+ /** Movie and PDF take the whole script; the other generate kinds take a beat
1221
+ * or a character within it. */
1222
+ async function wholeScriptGenerationKind(kind, args) {
1223
+ const filePath = str(args.filePath);
1224
+ if (!filePath) return invalidArgs(kind);
1225
+ const chatSessionId = str(args.chatSessionId);
1226
+ const root = str(args.root);
1227
+ return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId, root) : await ops.generatePdfOp(filePath, chatSessionId, root));
925
1228
  }
926
1229
  async function generateKind(kind, args) {
1230
+ if (kind === "generateMovie" || kind === "generatePdf") return wholeScriptGenerationKind(kind, args);
927
1231
  const chatSessionId = str(args.chatSessionId);
1232
+ const root = str(args.root);
928
1233
  const force = args.force === true;
929
- if (kind === "generateMovie" || kind === "generatePdf") {
930
- const filePath = str(args.filePath);
931
- if (!filePath) return invalidArgs(kind);
932
- return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId) : await ops.generatePdfOp(filePath, chatSessionId));
933
- }
934
1234
  if (kind === "renderCharacter") {
935
1235
  const parsed = keyArgs(args);
936
1236
  return parsed ? envelope(await ops.renderCharacterOp({
937
1237
  ...parsed,
938
1238
  force,
939
- chatSessionId
1239
+ chatSessionId,
1240
+ root
940
1241
  })) : invalidArgs(kind);
941
1242
  }
942
1243
  const parsed = beatArgs(args);
@@ -944,11 +1245,13 @@ function createMulmoScriptDispatchHandler(ops) {
944
1245
  return envelope(kind === "renderBeat" ? await ops.renderBeatOp({
945
1246
  ...parsed,
946
1247
  force,
947
- chatSessionId
1248
+ chatSessionId,
1249
+ root
948
1250
  }) : await ops.generateBeatAudioOp({
949
1251
  ...parsed,
950
1252
  force,
951
- chatSessionId
1253
+ chatSessionId,
1254
+ root
952
1255
  }));
953
1256
  }
954
1257
  async function uploadKind(kind, args) {
@@ -956,33 +1259,43 @@ function createMulmoScriptDispatchHandler(ops) {
956
1259
  if (!imageData) return invalidArgs(kind);
957
1260
  if (kind === "uploadCharacterImage") {
958
1261
  const parsed = keyArgs(args);
959
- return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData)) : invalidArgs(kind);
1262
+ return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData, str(args.root))) : invalidArgs(kind);
960
1263
  }
961
1264
  const parsed = beatArgs(args);
962
1265
  if (!parsed) return invalidArgs(kind);
963
- return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData));
1266
+ return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData, str(args.root)));
964
1267
  }
965
- return async (args) => {
966
- const kind = str(args.kind);
967
- if (!kind) return invalidArgs("<missing>");
1268
+ async function pendingKind(kind, args) {
1269
+ const filePath = str(args.filePath);
1270
+ if (!filePath) return invalidArgs(kind);
1271
+ const root = str(args.root);
1272
+ const rootGuard = ops.guardStoryRootRegistered(root);
1273
+ if (rootGuard) return fromOpFailure(rootGuard);
1274
+ return {
1275
+ ok: true,
1276
+ pending: ops.pendingGenerations(filePath, root)
1277
+ };
1278
+ }
1279
+ /** Which handler serves this kind. Routing only — the caller tags the answer. */
1280
+ async function route(kind, args) {
968
1281
  if (kind === "save") return saveKind(args);
969
1282
  if (kind === "updateBeat" || kind === "updateScript") return updateKind(kind, args);
970
1283
  if (PROBE_KINDS.has(kind)) return probeKind(kind, args);
971
1284
  if (GENERATE_KINDS.has(kind)) return generateKind(kind, args);
972
1285
  if (UPLOAD_KINDS.has(kind)) return uploadKind(kind, args);
973
- if (kind === "pendingGenerations") {
974
- const filePath = str(args.filePath);
975
- if (!filePath) return invalidArgs(kind);
976
- return {
977
- ok: true,
978
- pending: ops.pendingGenerations(filePath)
979
- };
980
- }
1286
+ if (kind === "pendingGenerations") return pendingKind(kind, args);
981
1287
  return {
982
1288
  ok: false,
983
1289
  code: "bad_request",
984
1290
  error: `unknown mulmoScript dispatch kind "${kind}"`
985
1291
  };
1292
+ }
1293
+ return async (args) => {
1294
+ const kind = str(args.kind);
1295
+ if (!kind) return invalidArgs("<missing>");
1296
+ const malformedRoot = guardSuppliedRoot(args.root);
1297
+ if (malformedRoot) return malformedRoot;
1298
+ return withRoot(await route(kind, args), str(args.root));
986
1299
  };
987
1300
  }
988
1301
  //#endregion