@mulmoclaude/mulmoscript-plugin 4.4.0 → 4.5.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.
Files changed (45) 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/{plugin-BOq5YkTl.cjs → plugin-C5JC2lNR.cjs} +51 -1
  13. package/dist/plugin-C5JC2lNR.cjs.map +1 -0
  14. package/dist/{plugin-CAhxJ5WM.js → plugin-Jo8iGv4K.js} +40 -2
  15. package/dist/plugin-Jo8iGv4K.js.map +1 -0
  16. package/dist/server/dispatch.d.ts +0 -6
  17. package/dist/server/dispatch.d.ts.map +1 -1
  18. package/dist/server/ops.d.ts +29 -18
  19. package/dist/server/ops.d.ts.map +1 -1
  20. package/dist/server/types.d.ts +75 -2
  21. package/dist/server/types.d.ts.map +1 -1
  22. package/dist/server.cjs +468 -110
  23. package/dist/server.cjs.map +1 -1
  24. package/dist/server.js +468 -110
  25. package/dist/server.js.map +1 -1
  26. package/dist/style.css +30 -18
  27. package/dist/vue/View.vue.d.ts.map +1 -1
  28. package/dist/vue/composables/useDeckEditor.d.ts.map +1 -1
  29. package/dist/vue/index.d.ts +1 -1
  30. package/dist/vue/index.d.ts.map +1 -1
  31. package/dist/vue/subscription.d.ts +16 -0
  32. package/dist/vue/subscription.d.ts.map +1 -0
  33. package/dist/vue/transport.d.ts +16 -5
  34. package/dist/vue/transport.d.ts.map +1 -1
  35. package/dist/vue.cjs +110 -21
  36. package/dist/vue.cjs.map +1 -1
  37. package/dist/vue.js +110 -21
  38. package/dist/vue.js.map +1 -1
  39. package/package.json +11 -11
  40. package/dist/contract-BhN3yKJC.cjs +0 -36
  41. package/dist/contract-BhN3yKJC.cjs.map +0 -1
  42. package/dist/contract-CIt1ZAtt.js +0 -19
  43. package/dist/contract-CIt1ZAtt.js.map +0 -1
  44. package/dist/plugin-BOq5YkTl.cjs.map +0 -1
  45. 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);
@@ -42,6 +42,29 @@ async function fileToDataUri(filePath, mimeType) {
42
42
  return `data:${mimeType};base64,${(await (0, node_fs_promises.readFile)(filePath)).toString("base64")}`;
43
43
  }
44
44
  //#endregion
45
+ //#region src/server/types.ts
46
+ /**
47
+ * Host capabilities the server ops run against. Only genuinely
48
+ * host-specific transport lives here — the mulmocast orchestration, path
49
+ * containment, and generation-state tracking are all in-package.
50
+ */
51
+ /**
52
+ * Which named-root capabilities a host has NOT wired.
53
+ *
54
+ * Pure and exported so the rule can be tested without building a server: the
55
+ * condition used to key on the root COUNT alone, so a host that HAD passed
56
+ * `artifactsFor` was told at every boot that its writes land in the default
57
+ * root — the opposite of what the code then did (#3022).
58
+ *
59
+ * Reads and uploads need nothing from the host, so they never appear here.
60
+ */
61
+ function missingRootCapabilities(backend) {
62
+ const missing = [];
63
+ if (backend.rootScopedGenerationState !== true) missing.push("GENERATION is refused until this host declares `rootScopedGenerationState` (its pending-generation state must carry the root, or it must keep none)");
64
+ if (backend.artifactsFor === void 0) missing.push("save/update land in the DEFAULT root until this host passes `artifactsFor`");
65
+ return missing;
66
+ }
67
+ //#endregion
45
68
  //#region src/server/mulmoErrorCapture.ts
46
69
  var capturedErrors = new node_async_hooks.AsyncLocalStorage();
47
70
  var loggerInstalled = false;
@@ -190,11 +213,17 @@ async function withBeatProgress(beats, onBeat, body) {
190
213
  }
191
214
  /** Map identity for the in-flight tracker. JSON array keeps the three
192
215
  * fields unambiguous (a human-visible delimiter could collide). */
193
- function generationMapKey(kind, filePath, key) {
194
- return JSON.stringify([
216
+ function generationMapKey(kind, filePath, key, root) {
217
+ const normalized = require_contract.normalizeRoot(root);
218
+ return normalized === "" ? JSON.stringify([
195
219
  kind,
196
220
  filePath,
197
221
  key
222
+ ]) : JSON.stringify([
223
+ kind,
224
+ filePath,
225
+ key,
226
+ normalized
198
227
  ]);
199
228
  }
200
229
  /**
@@ -205,19 +234,60 @@ function generationMapKey(kind, filePath, key) {
205
234
  function createMulmoScriptServerOps(backend) {
206
235
  const log = backend.log ?? NOOP_LOG;
207
236
  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;
237
+ const rootDirs = /* @__PURE__ */ new Map([["", path.default.resolve(backend.storiesDir)]]);
238
+ for (const [id, dir] of Object.entries(backend.extraRoots ?? {})) {
239
+ const trimmed = id.trim();
240
+ if (trimmed === "") throw new Error("mulmoScript: extraRoots key must not be empty — the empty id is reserved for the default stories root");
241
+ if (rootDirs.has(trimmed)) throw new Error(`mulmoScript: extraRoots keys must be distinct after trimming — "${trimmed}" is registered twice`);
242
+ rootDirs.set(trimmed, path.default.resolve(dir));
243
+ }
244
+ warnAboutUnwiredRoots();
245
+ /**
246
+ * Tell a host which named-root capabilities it has not wired — and nothing
247
+ * when it has wired them all.
248
+ *
249
+ * The condition used to be the root COUNT alone, so a host that had passed
250
+ * `artifactsFor` was still told, at every boot, that its writes land in the
251
+ * default root. That is not a stale wording: it is the opposite of what the
252
+ * code then does, read by the hosts that got the wiring RIGHT (#3022, from
253
+ * the consuming host).
254
+ *
255
+ * Silence when nothing is missing, because a warning that always fires is
256
+ * one people learn to skip — and then the host that really did forget
257
+ * `artifactsFor` cannot tell either. Each clause is emitted only when that
258
+ * capability is actually absent, so the message says what is true for THIS
259
+ * host rather than what was true when it was written.
260
+ */
261
+ function warnAboutUnwiredRoots() {
262
+ if (rootDirs.size <= 1) return;
263
+ const missing = missingRootCapabilities(backend);
264
+ if (missing.length === 0) return;
265
+ log.warn(`extra stories roots registered — reads and uploads work, but ${missing.join(", and ")} (#3019)`, { roots: [...rootDirs.keys()].filter((id) => id !== "") });
266
+ }
267
+ /** The registered directory for a wire `root`, or null when the host never
268
+ * registered it. Null is a REJECTION, not a fallback to the default: an
269
+ * unknown root must not quietly read the workspace's file of the same
270
+ * name. */
271
+ function rootDir(root) {
272
+ return rootDirs.get(require_contract.normalizeRoot(root)) ?? null;
273
+ }
274
+ function toStoryRef(absolutePath, root) {
275
+ const dir = rootDir(root);
276
+ if (dir === null) return null;
277
+ const base = ensureStoriesReal(root) ?? dir;
278
+ return require_plugin.storyRefWithin(base, absolutePath, path.default);
279
+ }
280
+ const storiesRealCache = /* @__PURE__ */ new Map();
281
+ function ensureStoriesReal(root) {
282
+ const dir = rootDir(root);
283
+ if (dir === null) return null;
284
+ const cached = storiesRealCache.get(dir);
285
+ if (cached) return cached;
217
286
  try {
218
- (0, fs.mkdirSync)(storiesDir, { recursive: true });
219
- storiesRealCache = (0, fs.realpathSync)(storiesDir);
220
- return storiesRealCache;
287
+ if (require_contract.normalizeRoot(root) === "") (0, fs.mkdirSync)(dir, { recursive: true });
288
+ const real = (0, fs.realpathSync)(dir);
289
+ storiesRealCache.set(dir, real);
290
+ return real;
221
291
  } catch {
222
292
  return null;
223
293
  }
@@ -233,8 +303,9 @@ function createMulmoScriptServerOps(backend) {
233
303
  * stories/ is a regular directory or a legitimate symlink to another
234
304
  * location. ENOENT and traversal are distinguished (404 vs 400).
235
305
  */
236
- function resolveStory(filePath) {
237
- const storiesReal = ensureStoriesReal();
306
+ function resolveStory(filePath, root) {
307
+ if (rootDir(root) === null) return opBadRequest("Unknown stories root");
308
+ const storiesReal = ensureStoriesReal(root);
238
309
  if (!storiesReal) return opServerError("stories directory not available");
239
310
  if (path.default.isAbsolute(filePath)) return opBadRequest("Invalid filePath");
240
311
  const ARTIFACTS_STORIES = "artifacts/stories";
@@ -254,6 +325,19 @@ function createMulmoScriptServerOps(backend) {
254
325
  };
255
326
  }
256
327
  /**
328
+ * Whether this root is one the host registered.
329
+ *
330
+ * Every other root-aware op learns this from `resolveStory`, which needs a
331
+ * file. `pendingGenerations` needs none — it only filters an in-memory map —
332
+ * so an unregistered root produced `{ ok: true, pending: [] }`, and a host
333
+ * typo or a stale card read back as "no work is running" (Codex P2 on
334
+ * #3015). An unknown root is a question this package cannot answer, and the
335
+ * answer it must not give is a confident empty one.
336
+ */
337
+ function guardStoryRootRegistered(root) {
338
+ return rootDir(root) === null ? opBadRequest(`unknown stories root "${require_contract.normalizeRoot(root)}"`) : null;
339
+ }
340
+ /**
257
341
  * Realpath containment pre-guard for wire paths handed to the phase-1
258
342
  * core's save/reopen/update executes. The core's own path guard is
259
343
  * lexical (it runs against the generic FileOps, whose read/write follows
@@ -265,11 +349,121 @@ function createMulmoScriptServerOps(backend) {
265
349
  * validation (including the script-vs-filePath mode check) belongs to
266
350
  * the core.
267
351
  */
268
- function guardStoryWirePath(filePath) {
352
+ function guardStoryWirePath(filePath, root) {
269
353
  if (typeof filePath !== "string" || filePath === "") return null;
270
- const resolved = resolveStory(filePath);
354
+ const resolved = resolveStory(filePath, root);
271
355
  return resolved.ok ? null : resolved;
272
356
  }
357
+ /**
358
+ * Whether a GENERATION may run in this root.
359
+ *
360
+ * The pair identity is complete inside this package but stops at the host
361
+ * boundary: a host's per-session store keys pending work by
362
+ * `(kind, filePath, key)` — `generationKey` in `@mulmobridge/protocol`,
363
+ * which bridges also consume. Two roots running the same generation in one
364
+ * session would collapse to one entry, and either completion would clear the
365
+ * other root's indicator (Codex P1 on #3015).
366
+ *
367
+ * Whose hazard it is decides who answers: a host declares
368
+ * `rootScopedGenerationState` when its own pending state carries the root —
369
+ * or when it keeps none, which is MulmoTerminal's case. It was refused for a
370
+ * collision it cannot have (#3019).
371
+ *
372
+ * Absent still refuses, so a host that has not thought about this keeps the
373
+ * shipped behaviour rather than being quietly opened up.
374
+ */
375
+ function guardStoryGenerationRoot(root) {
376
+ if (require_contract.normalizeRoot(root) === "") return null;
377
+ const registered = guardStoryRootRegistered(root);
378
+ if (registered) return registered;
379
+ if (backend.rootScopedGenerationState === true) return null;
380
+ return opBadRequest("generating in a non-default stories root is not supported yet");
381
+ }
382
+ /**
383
+ * The write half of the same fail-closed rule.
384
+ *
385
+ * It lives in the ops that write rather than at their dispatch sites,
386
+ * because a per-site guard is a list someone has to remember to extend:
387
+ * `save` / `updateBeat` / `updateScript` were guarded and the two upload
388
+ * kinds were not, so an image could still land in a named root
389
+ * (CodeRabbit on #3015). `save` / `update* ` cannot follow suit — they run
390
+ * through the package executors, not through these ops — so
391
+ * `test_server_roots.ts` walks the whole ops surface and fails on any op
392
+ * that is neither in the read-only allowlist nor refusing.
393
+ */
394
+ /**
395
+ * Whether a WRITE may target this root.
396
+ *
397
+ * Reads are root-aware; writes are not. `executeMulmoScriptSave` and the
398
+ * update executors run against one `FileOps`, bound by the host to the
399
+ * default root, so a write naming another root would rewrite the DEFAULT
400
+ * root's identically-named file and then announce the other one as changed
401
+ * (#3015 review G1). Closing it was fail-closed: "readable but not yet
402
+ * writable" beats "wrote somewhere else and said so".
403
+ *
404
+ * It opens per root, not globally: the host answers `artifactsFor` for the
405
+ * roots it can serve, and a root it cannot is still refused. A host that
406
+ * wires nothing keeps the shipped refusal (#3019).
407
+ */
408
+ function guardStoryWriteRoot(root) {
409
+ if (require_contract.normalizeRoot(root) === "") return null;
410
+ const registered = guardStoryRootRegistered(root);
411
+ if (registered) return registered;
412
+ return artifactsForRoot(root) === null ? opBadRequest("writing to a non-default stories root is not supported yet") : null;
413
+ }
414
+ /**
415
+ * The FileOps a write to this root must go through.
416
+ *
417
+ * The default root keeps the single `artifacts` it always had, byte for
418
+ * byte. A named root is served only when the host both REGISTERED it
419
+ * (`extraRoots` — the containment boundary, so a host cannot widen the
420
+ * addressable set through this back door) and can supply a FileOps for it.
421
+ */
422
+ function artifactsForRoot(root) {
423
+ const normalized = require_contract.normalizeRoot(root);
424
+ if (normalized === "") return backend.artifacts;
425
+ if (rootDir(normalized) === null) return null;
426
+ const hostOps = backend.artifactsFor?.(normalized);
427
+ return hostOps === void 0 || hostOps === null ? null : storiesScoped(hostOps);
428
+ }
429
+ /**
430
+ * Address a named root's FileOps the way the READ side addresses it.
431
+ *
432
+ * The two sides disagreed about one path segment, and the disagreement was
433
+ * silent. A read strips `stories/` and resolves under the registered
434
+ * directory (`<root>/<rel>`); a write handed the executors' wire path
435
+ * straight to the host's FileOps, which is rooted at that same registered
436
+ * directory, so the bytes landed in `<root>/stories/<rel>`. `save` then
437
+ * REPORTED SUCCESS and returned a wire path that could not be read back —
438
+ * a card pointing at nothing (#3020 review H1, from the consuming host).
439
+ *
440
+ * Stripping here makes "the directory you registered is the directory your
441
+ * FileOps is rooted at" true, which is what a host writes without being
442
+ * told. The default root is untouched: its FileOps is rooted one level up,
443
+ * at `<workspace>/artifacts`, and is shared with other plugins.
444
+ *
445
+ * A path that is not a stories wire path throws rather than passing
446
+ * through. Everything reaching here has been through `normalizeStoryPath`
447
+ * or `storyFilePath`, so one that has not is a bug — and letting it through
448
+ * would write it somewhere nobody can read, which is the failure this whole
449
+ * wrapper exists to end.
450
+ */
451
+ function storiesScoped(inner) {
452
+ const within = (wirePath) => {
453
+ const relative = require_plugin.storiesRelativePath(wirePath);
454
+ if (relative === null) throw new Error(`mulmoScript: "${wirePath}" is not a stories path — a named root's FileOps takes stories paths only`);
455
+ return relative;
456
+ };
457
+ return {
458
+ read: async (wirePath) => inner.read(within(wirePath)),
459
+ readBytes: async (wirePath) => inner.readBytes(within(wirePath)),
460
+ write: async (wirePath, data) => inner.write(within(wirePath), data),
461
+ readDir: async (wirePath) => inner.readDir(within(wirePath)),
462
+ stat: async (wirePath) => inner.stat(within(wirePath)),
463
+ exists: async (wirePath) => inner.exists(within(wirePath)),
464
+ unlink: async (wirePath) => inner.unlink(within(wirePath))
465
+ };
466
+ }
273
467
  function ffmpegGuard() {
274
468
  if (backend.isFfmpegAvailable?.() === false) return {
275
469
  ok: false,
@@ -279,6 +473,13 @@ function createMulmoScriptServerOps(backend) {
279
473
  return null;
280
474
  }
281
475
  const inFlightGenerations = /* @__PURE__ */ new Map();
476
+ /** Emit `root` only when it names a non-default one: an event carrying
477
+ * `root: ""` and one carrying nothing must stay indistinguishable to every
478
+ * pre-#3014 consumer. */
479
+ function rootField(root) {
480
+ const normalized = require_contract.normalizeRoot(root);
481
+ return normalized === "" ? {} : { root: normalized };
482
+ }
282
483
  /** Tracker state and events key on the canonical `stories/<rel>` wire
283
484
  * form: subscribers (the View's pubsub filter, `pendingGenerations`
284
485
  * callers) match by exact string, so the accepted alias spellings
@@ -288,9 +489,23 @@ function createMulmoScriptServerOps(backend) {
288
489
  function canonicalWirePath(filePath) {
289
490
  return require_plugin.normalizeStoryPath(filePath) ?? filePath;
290
491
  }
291
- function publishGeneration(chatSessionId, kind, filePath, key, finished, error) {
492
+ /**
493
+ * `error` and `root` travel in an options object, NOT as two trailing
494
+ * optional positionals.
495
+ *
496
+ * They were positional once, and appending `root` to the sixteen call sites
497
+ * put it in `error`'s slot at the nine that pass no error: every start event
498
+ * carried `error: "<root>"`, the root was dropped from the key, the tracker
499
+ * entry was filed under the default root — and because the matching finish
500
+ * DID pass both, its key differed and the entry was never deleted, leaking
501
+ * one row per generation for the life of the process. Two adjacent optional
502
+ * strings cannot be told apart by the type checker, so the shape is the only
503
+ * thing that can prevent it (#3015 review).
504
+ */
505
+ function publishGeneration(chatSessionId, kind, filePath, key, finished, opts = {}) {
506
+ const { error, root } = opts;
292
507
  const wirePath = canonicalWirePath(filePath);
293
- const mapKey = generationMapKey(kind, wirePath, key);
508
+ const mapKey = generationMapKey(kind, wirePath, key, root);
294
509
  const existing = inFlightGenerations.get(mapKey);
295
510
  if (finished) {
296
511
  if (existing && existing.count > 1) {
@@ -307,6 +522,7 @@ function createMulmoScriptServerOps(backend) {
307
522
  kind,
308
523
  filePath: wirePath,
309
524
  key,
525
+ root: require_contract.normalizeRoot(root),
310
526
  count: 1
311
527
  });
312
528
  }
@@ -315,7 +531,8 @@ function createMulmoScriptServerOps(backend) {
315
531
  filePath: wirePath,
316
532
  key,
317
533
  done: finished,
318
- ...error ? { error } : {}
534
+ ...error ? { error } : {},
535
+ ...rootField(root)
319
536
  };
320
537
  backend.onGenerationEvent?.(chatSessionId, event);
321
538
  }
@@ -326,21 +543,31 @@ function createMulmoScriptServerOps(backend) {
326
543
  * save — reloading there would rebuild the element the caret is in, mid-keystroke. A write
327
544
  * from the agent carries no origin, so everyone reloads.
328
545
  */
329
- function publishScriptChanged(filePath, origin) {
546
+ function publishScriptChanged(filePath, origin, root) {
330
547
  backend.onScriptChanged?.({
331
548
  filePath: canonicalWirePath(filePath),
332
- ...origin === void 0 ? {} : { origin }
549
+ ...origin === void 0 ? {} : { origin },
550
+ ...rootField(root)
333
551
  });
334
552
  }
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) {
553
+ /**
554
+ * Snapshot of generations currently in flight for one script — the View's
555
+ * mount-time catch-up.
556
+ *
557
+ * Filtered on the PAIR. Filtering on `filePath` alone hands a View watching
558
+ * `repoB/stories/deck.json` the run started for `repoA`'s identically-named
559
+ * deck, and the caller cannot discard it because the returned event would
560
+ * carry no root either (Codex P1 / CodeRabbit on #3015).
561
+ */
562
+ function pendingGenerations(filePath, root) {
338
563
  const wirePath = canonicalWirePath(filePath);
339
- return [...inFlightGenerations.values()].filter((entry) => entry.filePath === wirePath).map(({ kind, key }) => ({
564
+ const wanted = require_contract.normalizeRoot(root);
565
+ return [...inFlightGenerations.values()].filter((entry) => entry.filePath === wirePath && entry.root === wanted).map(({ kind, key }) => ({
340
566
  kind,
341
567
  filePath: wirePath,
342
568
  key,
343
- done: false
569
+ done: false,
570
+ ...rootField(root)
344
571
  }));
345
572
  }
346
573
  /**
@@ -352,7 +579,7 @@ function createMulmoScriptServerOps(backend) {
352
579
  async function runStoryOp(filePath, options, handler, deps = {}) {
353
580
  const resolver = deps.resolveStory ?? resolveStory;
354
581
  const build = deps.buildContext ?? buildContext;
355
- const resolved = resolver(filePath);
582
+ const resolved = resolver(filePath, options.root);
356
583
  if (!resolved.ok) return resolved;
357
584
  try {
358
585
  const context = await build(resolved.absolutePath, options.force ?? false);
@@ -373,8 +600,11 @@ function createMulmoScriptServerOps(backend) {
373
600
  return opServerError(require_plugin.errorMessage(err));
374
601
  }
375
602
  }
376
- async function beatImageOp(filePath, beatIndex) {
377
- return runStoryOp(filePath, { operation: "beat-image" }, async ({ context }) => {
603
+ async function beatImageOp(filePath, beatIndex, root) {
604
+ return runStoryOp(filePath, {
605
+ operation: "beat-image",
606
+ root
607
+ }, async ({ context }) => {
378
608
  const { imagePath } = (0, mulmocast.getBeatPngImagePath)(context, beatIndex);
379
609
  if (!(0, fs.existsSync)(imagePath)) return {
380
610
  ok: true,
@@ -386,9 +616,10 @@ function createMulmoScriptServerOps(backend) {
386
616
  };
387
617
  });
388
618
  }
389
- async function beatAudioOp(filePath, beatIndex) {
619
+ async function beatAudioOp(filePath, beatIndex, root) {
390
620
  return runStoryOp(filePath, {
391
621
  operation: "beat-audio",
622
+ root,
392
623
  onContextMissing: () => ({
393
624
  ok: true,
394
625
  audio: null
@@ -410,8 +641,11 @@ function createMulmoScriptServerOps(backend) {
410
641
  };
411
642
  });
412
643
  }
413
- async function beatMovieOp(filePath, beatIndex) {
414
- return runStoryOp(filePath, { operation: "beat-movie" }, async ({ context }) => {
644
+ async function beatMovieOp(filePath, beatIndex, root) {
645
+ return runStoryOp(filePath, {
646
+ operation: "beat-movie",
647
+ root
648
+ }, async ({ context }) => {
415
649
  const { movieFile, soundEffectFile, lipSyncFile } = (0, mulmocast.getBeatMoviePaths)(context, beatIndex);
416
650
  const existing = [
417
651
  lipSyncFile,
@@ -421,12 +655,15 @@ function createMulmoScriptServerOps(backend) {
421
655
  ].find((candidate) => (0, fs.existsSync)(candidate));
422
656
  return {
423
657
  ok: true,
424
- moviePath: existing ? toStoryRef(existing) : null
658
+ moviePath: existing ? toStoryRef(existing, root) : null
425
659
  };
426
660
  });
427
661
  }
428
- async function characterImageOp(filePath, key) {
429
- return runStoryOp(filePath, { operation: "character-image" }, async ({ context }) => {
662
+ async function characterImageOp(filePath, key, root) {
663
+ return runStoryOp(filePath, {
664
+ operation: "character-image",
665
+ root
666
+ }, async ({ context }) => {
430
667
  const imagePath = (0, mulmocast.getReferenceImagePath)(context, key, "png");
431
668
  if (!(0, fs.existsSync)(imagePath)) return {
432
669
  ok: true,
@@ -441,46 +678,51 @@ function createMulmoScriptServerOps(backend) {
441
678
  /** Shared "output exists and is newer than the source script" gate for
442
679
  * movie / PDF status. A stale artifact (script edited after it was
443
680
  * generated) reports null so the UI re-offers the Generate button. */
444
- function freshOutputRef(outputPath, absoluteFilePath) {
681
+ function freshOutputRef(outputPath, absoluteFilePath, root) {
445
682
  if (!(0, fs.existsSync)(outputPath)) return null;
446
683
  if ((0, fs.statSync)(outputPath).mtimeMs < (0, fs.statSync)(absoluteFilePath).mtimeMs) return null;
447
- return toStoryRef(outputPath);
684
+ return toStoryRef(outputPath, root);
448
685
  }
449
- async function movieStatusOp(filePath) {
686
+ async function movieStatusOp(filePath, root) {
450
687
  return runStoryOp(filePath, {
451
688
  operation: "movie-status",
689
+ root,
452
690
  onContextMissing: () => ({
453
691
  ok: true,
454
692
  moviePath: null
455
693
  })
456
694
  }, async ({ absoluteFilePath, context }) => ({
457
695
  ok: true,
458
- moviePath: freshOutputRef((0, mulmocast.movieFilePath)(context), absoluteFilePath)
696
+ moviePath: freshOutputRef((0, mulmocast.movieFilePath)(context), absoluteFilePath, root)
459
697
  }));
460
698
  }
461
- async function pdfStatusOp(filePath) {
699
+ async function pdfStatusOp(filePath, root) {
462
700
  return runStoryOp(filePath, {
463
701
  operation: "pdf-status",
702
+ root,
464
703
  onContextMissing: () => ({
465
704
  ok: true,
466
705
  pdfPath: null
467
706
  })
468
707
  }, async ({ absoluteFilePath, context }) => ({
469
708
  ok: true,
470
- pdfPath: freshOutputRef((0, mulmocast.pdfFilePath)(context, PDF_MODE), absoluteFilePath)
709
+ pdfPath: freshOutputRef((0, mulmocast.pdfFilePath)(context, PDF_MODE), absoluteFilePath, root)
471
710
  }));
472
711
  }
473
712
  async function renderBeatOp(args) {
474
- const { filePath, beatIndex, force, chatSessionId } = args;
713
+ const { filePath, beatIndex, force, chatSessionId, root } = args;
714
+ const rootGuard = guardStoryGenerationRoot(root);
715
+ if (rootGuard) return rootGuard;
475
716
  const ffmpeg = ffmpegGuard();
476
717
  if (ffmpeg) return ffmpeg;
477
718
  const mapKey = String(beatIndex);
478
- publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false);
719
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false, { root });
479
720
  let genError;
480
721
  try {
481
722
  const result = await runStoryOp(filePath, {
482
723
  force,
483
- operation: "render-beat"
724
+ operation: "render-beat",
725
+ root
484
726
  }, async ({ context }) => {
485
727
  await (0, mulmocast.generateBeatImage)({
486
728
  index: beatIndex,
@@ -497,18 +739,24 @@ function createMulmoScriptServerOps(backend) {
497
739
  if (!result.ok) genError = result.error;
498
740
  return result;
499
741
  } finally {
500
- publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, genError);
742
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, {
743
+ error: genError,
744
+ root
745
+ });
501
746
  }
502
747
  }
503
748
  async function generateBeatAudioOp(args) {
504
- const { filePath, beatIndex, force, chatSessionId } = args;
749
+ const { filePath, beatIndex, force, chatSessionId, root } = args;
750
+ const rootGuard = guardStoryGenerationRoot(root);
751
+ if (rootGuard) return rootGuard;
505
752
  const mapKey = String(beatIndex);
506
- publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false);
753
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false, { root });
507
754
  let genError;
508
755
  try {
509
756
  const result = await runStoryOp(filePath, {
510
757
  force,
511
- operation: "generate-beat-audio"
758
+ operation: "generate-beat-audio",
759
+ root
512
760
  }, async ({ context }) => {
513
761
  await (0, mulmocast.generateBeatAudio)(beatIndex, context, { settings: process.env });
514
762
  const beat = context.studio.script.beats[beatIndex];
@@ -531,17 +779,23 @@ function createMulmoScriptServerOps(backend) {
531
779
  if (!result.ok) genError = result.error;
532
780
  return result;
533
781
  } finally {
534
- publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, genError);
782
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, {
783
+ error: genError,
784
+ root
785
+ });
535
786
  }
536
787
  }
537
788
  async function renderCharacterOp(args) {
538
- const { filePath, key, force, chatSessionId } = args;
539
- publishGeneration(chatSessionId, "characterImage", filePath, key, false);
789
+ const { filePath, key, force, chatSessionId, root } = args;
790
+ const rootGuard = guardStoryGenerationRoot(root);
791
+ if (rootGuard) return rootGuard;
792
+ publishGeneration(chatSessionId, "characterImage", filePath, key, false, { root });
540
793
  let genError;
541
794
  try {
542
795
  const result = await runStoryOp(filePath, {
543
796
  force,
544
- operation: "render-character"
797
+ operation: "render-character",
798
+ root
545
799
  }, async ({ context }) => {
546
800
  const imageEntries = context.studio.script.imageParams?.images ?? {};
547
801
  const imageEntry = imageEntries[key];
@@ -565,11 +819,17 @@ function createMulmoScriptServerOps(backend) {
565
819
  if (!result.ok) genError = result.error;
566
820
  return result;
567
821
  } finally {
568
- publishGeneration(chatSessionId, "characterImage", filePath, key, true, genError);
822
+ publishGeneration(chatSessionId, "characterImage", filePath, key, true, {
823
+ error: genError,
824
+ root
825
+ });
569
826
  }
570
827
  }
571
- async function uploadBeatImageOp(filePath, beatIndex, imageData) {
572
- return runStoryOp(filePath, { operation: "upload-beat-image" }, async ({ context }) => {
828
+ async function uploadBeatImageOp(filePath, beatIndex, imageData, root) {
829
+ return runStoryOp(filePath, {
830
+ operation: "upload-beat-image",
831
+ root
832
+ }, async ({ context }) => {
573
833
  const { imagePath } = (0, mulmocast.getBeatPngImagePath)(context, beatIndex);
574
834
  const base64 = stripDataUri(imageData);
575
835
  await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
@@ -579,8 +839,11 @@ function createMulmoScriptServerOps(backend) {
579
839
  };
580
840
  });
581
841
  }
582
- async function uploadCharacterImageOp(filePath, key, imageData) {
583
- return runStoryOp(filePath, { operation: "upload-character-image" }, async ({ context }) => {
842
+ async function uploadCharacterImageOp(filePath, key, imageData, root) {
843
+ return runStoryOp(filePath, {
844
+ operation: "upload-character-image",
845
+ root
846
+ }, async ({ context }) => {
584
847
  const imagePath = (0, mulmocast.getReferenceImagePath)(context, key, "png");
585
848
  const base64 = stripDataUri(imageData);
586
849
  await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
@@ -629,54 +892,68 @@ function createMulmoScriptServerOps(backend) {
629
892
  * initiating View (and any other mounted View) reloads assets off disk
630
893
  * as they land — the successor of the SSE per-beat events.
631
894
  */
632
- async function generateMovieOp(filePath, chatSessionId) {
895
+ async function generateMovieOp(filePath, chatSessionId, root) {
896
+ const rootGuard = guardStoryGenerationRoot(root);
897
+ if (rootGuard) return rootGuard;
633
898
  const ffmpeg = ffmpegGuard();
634
899
  if (ffmpeg) return ffmpeg;
635
- const resolved = resolveStory(filePath);
900
+ const resolved = resolveStory(filePath, root);
636
901
  if (!resolved.ok) return resolved;
637
902
  const absoluteFilePath = resolved.absolutePath;
638
903
  if (inFlightMovies.has(absoluteFilePath)) return opBadRequest("Movie generation is already in progress for this script");
639
904
  inFlightMovies.add(absoluteFilePath);
640
- publishGeneration(chatSessionId, "movie", filePath, "", false);
905
+ publishGeneration(chatSessionId, "movie", filePath, "", false, { root });
641
906
  let genError;
642
907
  try {
643
908
  const result = await runMovieGeneration(absoluteFilePath, (event) => {
644
- publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true);
909
+ publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true, { root });
645
910
  });
646
911
  if (!result.ok) {
647
912
  genError = result.error;
648
913
  return opServerError(result.error);
649
914
  }
915
+ const movieRef = toStoryRef(result.outputPath, root);
916
+ if (movieRef === null) return opServerError("generated movie is outside the registered stories root");
650
917
  return {
651
918
  ok: true,
652
- moviePath: toStoryRef(result.outputPath)
919
+ moviePath: movieRef
653
920
  };
654
921
  } catch (err) {
655
922
  genError = require_plugin.errorMessage(err);
656
923
  return opServerError(genError);
657
924
  } finally {
658
925
  inFlightMovies.delete(absoluteFilePath);
659
- publishGeneration(chatSessionId, "movie", filePath, "", true, genError);
926
+ publishGeneration(chatSessionId, "movie", filePath, "", true, {
927
+ error: genError,
928
+ root
929
+ });
660
930
  }
661
931
  }
662
- function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId) {
932
+ function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId, root) {
933
+ if (guardStoryGenerationRoot(root)) {
934
+ log.warn("refused an auto background movie in a non-default stories root", {
935
+ filePath: wireFilePath,
936
+ root
937
+ });
938
+ return;
939
+ }
663
940
  if (inFlightMovies.has(absoluteFilePath)) return;
664
941
  inFlightMovies.add(absoluteFilePath);
665
- runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
942
+ runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root);
666
943
  }
667
- async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId) {
944
+ async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root) {
668
945
  const errorSidecarPath = `${absoluteFilePath}.error.txt`;
669
946
  try {
670
947
  (0, fs.unlinkSync)(errorSidecarPath);
671
948
  } catch {}
672
- publishGeneration(chatSessionId, "movie", wireFilePath, "", false);
949
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", false, { root });
673
950
  let genError;
674
951
  try {
675
952
  const result = await runMovieGeneration(absoluteFilePath, (event) => {
676
953
  const eventKind = event.kind === "image" ? "beatImage" : "beatAudio";
677
954
  const key = String(event.beatIndex);
678
- publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
679
- setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
955
+ publishGeneration(chatSessionId, eventKind, wireFilePath, key, false, { root });
956
+ setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true, { root }));
680
957
  });
681
958
  if (!result.ok) {
682
959
  genError = result.error;
@@ -700,7 +977,10 @@ function createMulmoScriptServerOps(backend) {
700
977
  });
701
978
  } finally {
702
979
  inFlightMovies.delete(absoluteFilePath);
703
- publishGeneration(chatSessionId, "movie", wireFilePath, "", true, genError);
980
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", true, {
981
+ error: genError,
982
+ root
983
+ });
704
984
  }
705
985
  }
706
986
  async function writeErrorSidecar(errorSidecarPath, message) {
@@ -736,15 +1016,17 @@ function createMulmoScriptServerOps(backend) {
736
1016
  }
737
1017
  /** Long-held foreground PDF generation (the package View's `generatePdf`
738
1018
  * dispatch) — the PDF sibling of `generateMovieOp`. */
739
- async function generatePdfOp(filePath, chatSessionId) {
1019
+ async function generatePdfOp(filePath, chatSessionId, root) {
1020
+ const rootGuard = guardStoryGenerationRoot(root);
1021
+ if (rootGuard) return rootGuard;
740
1022
  const ffmpeg = ffmpegGuard();
741
1023
  if (ffmpeg) return ffmpeg;
742
- const resolved = resolveStory(filePath);
1024
+ const resolved = resolveStory(filePath, root);
743
1025
  if (!resolved.ok) return resolved;
744
1026
  const absoluteFilePath = resolved.absolutePath;
745
1027
  if (inFlightPdfs.has(absoluteFilePath)) return opBadRequest("PDF generation is already in progress for this script");
746
1028
  inFlightPdfs.add(absoluteFilePath);
747
- publishGeneration(chatSessionId, "pdf", filePath, "", false);
1029
+ publishGeneration(chatSessionId, "pdf", filePath, "", false, { root });
748
1030
  let genError;
749
1031
  try {
750
1032
  const context = await buildContext(absoluteFilePath);
@@ -753,22 +1035,27 @@ function createMulmoScriptServerOps(backend) {
753
1035
  return opServerError(genError);
754
1036
  }
755
1037
  const result = await runPdfGeneration(context, (beatIndex) => {
756
- publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true);
1038
+ publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true, { root });
757
1039
  });
758
1040
  if (!result.ok) {
759
1041
  genError = result.error;
760
1042
  return opServerError(result.error);
761
1043
  }
1044
+ const pdfRef = toStoryRef(result.outputPath, root);
1045
+ if (pdfRef === null) return opServerError("generated PDF is outside the registered stories root");
762
1046
  return {
763
1047
  ok: true,
764
- pdfPath: toStoryRef(result.outputPath)
1048
+ pdfPath: pdfRef
765
1049
  };
766
1050
  } catch (err) {
767
1051
  genError = require_plugin.errorMessage(err);
768
1052
  return opServerError(genError);
769
1053
  } finally {
770
1054
  inFlightPdfs.delete(absoluteFilePath);
771
- publishGeneration(chatSessionId, "pdf", filePath, "", true, genError);
1055
+ publishGeneration(chatSessionId, "pdf", filePath, "", true, {
1056
+ error: genError,
1057
+ root
1058
+ });
772
1059
  }
773
1060
  }
774
1061
  return {
@@ -776,6 +1063,10 @@ function createMulmoScriptServerOps(backend) {
776
1063
  toStoryRef,
777
1064
  resolveStory,
778
1065
  guardStoryWirePath,
1066
+ guardStoryRootRegistered,
1067
+ guardStoryWriteRoot,
1068
+ artifactsForRoot,
1069
+ guardStoryGenerationRoot,
779
1070
  ffmpegGuard,
780
1071
  runStoryOp,
781
1072
  publishGeneration,
@@ -874,12 +1165,56 @@ var UPLOAD_KINDS = /* @__PURE__ */ new Set(["uploadBeatImage", "uploadCharacterI
874
1165
  * FileOps, guarded by the instance's realpath containment
875
1166
  * (`guardStoryWirePath`) — the core's own guard is lexical.
876
1167
  */
1168
+ /**
1169
+ * Stamp the root a successful result acted in.
1170
+ *
1171
+ * Only on success: a failure carries `code` and `error`, and adding a root to
1172
+ * it would invite a reader to treat the pair as addressable when the call did
1173
+ * not happen. Only when NON-default, so a result for a call that named no root
1174
+ * stays byte-identical to what this package returned before roots existed —
1175
+ * which is what keeps every existing card working untouched.
1176
+ */
1177
+ function withRoot(result, root) {
1178
+ const normalized = require_contract.normalizeRoot(root);
1179
+ if (normalized === "") return result;
1180
+ if (!isRecord(result) || result.ok !== true) return result;
1181
+ return {
1182
+ ...result,
1183
+ root: normalized
1184
+ };
1185
+ }
1186
+ /** `undefined` when `root` is absent or a string; a failure envelope otherwise. */
1187
+ function guardSuppliedRoot(root) {
1188
+ if (root === void 0 || typeof root === "string") return void 0;
1189
+ return {
1190
+ ok: false,
1191
+ code: "bad_request",
1192
+ error: `mulmoScript root must be a string, got ${typeof root}`
1193
+ };
1194
+ }
877
1195
  function createMulmoScriptDispatchHandler(ops) {
878
- const executeContext = { files: { artifacts: ops.backend.artifacts } };
1196
+ /**
1197
+ * The executor context for a write, bound to the root it names.
1198
+ *
1199
+ * One `FileOps` was held for the whole handler, so the executors — which
1200
+ * take a WIRE path (`stories/…`) and resolve it against whatever FileOps
1201
+ * they are given — wrote a named root's script into the DEFAULT root's
1202
+ * identically-named file. Choosing here keeps `MulmoScriptExecuteContext`
1203
+ * and every executor unchanged: the root never reaches them, only the right
1204
+ * FileOps does (#3019).
1205
+ */
1206
+ function executeContextFor(root) {
1207
+ const artifacts = ops.artifactsForRoot(root);
1208
+ return artifacts === null ? null : { files: { artifacts } };
1209
+ }
879
1210
  async function saveKind(args) {
880
- const guard = ops.guardStoryWirePath(args.filePath);
1211
+ const rootGuard = ops.guardStoryWriteRoot(str(args.root));
1212
+ if (rootGuard) return fromOpFailure(rootGuard);
1213
+ const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
881
1214
  if (guard) return fromOpFailure(guard);
882
- const outcome = await require_plugin.executeMulmoScriptSave(executeContext, {
1215
+ const context = executeContextFor(str(args.root));
1216
+ if (context === null) return invalidArgs("save");
1217
+ const outcome = await require_plugin.executeMulmoScriptSave(context, {
883
1218
  script: args.script,
884
1219
  filename: str(args.filename),
885
1220
  filePath: str(args.filePath)
@@ -893,11 +1228,15 @@ function createMulmoScriptDispatchHandler(ops) {
893
1228
  };
894
1229
  }
895
1230
  async function updateKind(kind, args) {
896
- const guard = ops.guardStoryWirePath(args.filePath);
1231
+ const rootGuard = ops.guardStoryWriteRoot(str(args.root));
1232
+ if (rootGuard) return fromOpFailure(rootGuard);
1233
+ const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
897
1234
  if (guard) return fromOpFailure(guard);
898
- const outcome = kind === "updateBeat" ? await require_plugin.executeUpdateBeat(executeContext, args) : await require_plugin.executeUpdateScript(executeContext, args);
1235
+ const context = executeContextFor(str(args.root));
1236
+ if (context === null) return invalidArgs(kind);
1237
+ const outcome = kind === "updateBeat" ? await require_plugin.executeUpdateBeat(context, args) : await require_plugin.executeUpdateScript(context, args);
899
1238
  if (!outcome.ok) return fromPackageFailure(outcome);
900
- ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin));
1239
+ ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin), str(args.root));
901
1240
  return { ok: true };
902
1241
  }
903
1242
  const STATUS_OPS = {
@@ -913,30 +1252,37 @@ function createMulmoScriptDispatchHandler(ops) {
913
1252
  const statusOp = STATUS_OPS[kind];
914
1253
  if (statusOp) {
915
1254
  const filePath = str(args.filePath);
916
- return filePath ? envelope(await statusOp(filePath)) : invalidArgs(kind);
1255
+ return filePath ? envelope(await statusOp(filePath, str(args.root))) : invalidArgs(kind);
917
1256
  }
918
1257
  if (kind === "characterImage") {
919
1258
  const parsed = keyArgs(args);
920
- return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key)) : invalidArgs(kind);
1259
+ return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key, str(args.root))) : invalidArgs(kind);
921
1260
  }
922
1261
  const parsed = beatArgs(args);
923
1262
  if (!parsed) return invalidArgs(kind);
924
- return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex));
1263
+ return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex, str(args.root)));
1264
+ }
1265
+ /** Movie and PDF take the whole script; the other generate kinds take a beat
1266
+ * or a character within it. */
1267
+ async function wholeScriptGenerationKind(kind, args) {
1268
+ const filePath = str(args.filePath);
1269
+ if (!filePath) return invalidArgs(kind);
1270
+ const chatSessionId = str(args.chatSessionId);
1271
+ const root = str(args.root);
1272
+ return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId, root) : await ops.generatePdfOp(filePath, chatSessionId, root));
925
1273
  }
926
1274
  async function generateKind(kind, args) {
1275
+ if (kind === "generateMovie" || kind === "generatePdf") return wholeScriptGenerationKind(kind, args);
927
1276
  const chatSessionId = str(args.chatSessionId);
1277
+ const root = str(args.root);
928
1278
  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
1279
  if (kind === "renderCharacter") {
935
1280
  const parsed = keyArgs(args);
936
1281
  return parsed ? envelope(await ops.renderCharacterOp({
937
1282
  ...parsed,
938
1283
  force,
939
- chatSessionId
1284
+ chatSessionId,
1285
+ root
940
1286
  })) : invalidArgs(kind);
941
1287
  }
942
1288
  const parsed = beatArgs(args);
@@ -944,11 +1290,13 @@ function createMulmoScriptDispatchHandler(ops) {
944
1290
  return envelope(kind === "renderBeat" ? await ops.renderBeatOp({
945
1291
  ...parsed,
946
1292
  force,
947
- chatSessionId
1293
+ chatSessionId,
1294
+ root
948
1295
  }) : await ops.generateBeatAudioOp({
949
1296
  ...parsed,
950
1297
  force,
951
- chatSessionId
1298
+ chatSessionId,
1299
+ root
952
1300
  }));
953
1301
  }
954
1302
  async function uploadKind(kind, args) {
@@ -956,33 +1304,43 @@ function createMulmoScriptDispatchHandler(ops) {
956
1304
  if (!imageData) return invalidArgs(kind);
957
1305
  if (kind === "uploadCharacterImage") {
958
1306
  const parsed = keyArgs(args);
959
- return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData)) : invalidArgs(kind);
1307
+ return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData, str(args.root))) : invalidArgs(kind);
960
1308
  }
961
1309
  const parsed = beatArgs(args);
962
1310
  if (!parsed) return invalidArgs(kind);
963
- return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData));
1311
+ return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData, str(args.root)));
964
1312
  }
965
- return async (args) => {
966
- const kind = str(args.kind);
967
- if (!kind) return invalidArgs("<missing>");
1313
+ async function pendingKind(kind, args) {
1314
+ const filePath = str(args.filePath);
1315
+ if (!filePath) return invalidArgs(kind);
1316
+ const root = str(args.root);
1317
+ const rootGuard = ops.guardStoryRootRegistered(root);
1318
+ if (rootGuard) return fromOpFailure(rootGuard);
1319
+ return {
1320
+ ok: true,
1321
+ pending: ops.pendingGenerations(filePath, root)
1322
+ };
1323
+ }
1324
+ /** Which handler serves this kind. Routing only — the caller tags the answer. */
1325
+ async function route(kind, args) {
968
1326
  if (kind === "save") return saveKind(args);
969
1327
  if (kind === "updateBeat" || kind === "updateScript") return updateKind(kind, args);
970
1328
  if (PROBE_KINDS.has(kind)) return probeKind(kind, args);
971
1329
  if (GENERATE_KINDS.has(kind)) return generateKind(kind, args);
972
1330
  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
- }
1331
+ if (kind === "pendingGenerations") return pendingKind(kind, args);
981
1332
  return {
982
1333
  ok: false,
983
1334
  code: "bad_request",
984
1335
  error: `unknown mulmoScript dispatch kind "${kind}"`
985
1336
  };
1337
+ }
1338
+ return async (args) => {
1339
+ const kind = str(args.kind);
1340
+ if (!kind) return invalidArgs("<missing>");
1341
+ const malformedRoot = guardSuppliedRoot(args.root);
1342
+ if (malformedRoot) return malformedRoot;
1343
+ return withRoot(await route(kind, args), str(args.root));
986
1344
  };
987
1345
  }
988
1346
  //#endregion