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