@mulmoclaude/mulmoscript-plugin 4.3.0 → 4.5.0

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