mulmoclaude 0.6.2 → 0.6.3

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 (94) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/chunk-D8eiyYIV-CW0rPbG2.js +1 -0
  4. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-BjwfzAN8.js} +1 -1
  5. package/client/assets/index-Bp1owZ-i.js +5101 -0
  6. package/client/assets/index-c63H1pnd.css +2 -0
  7. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DudYPW7R.js} +1 -1
  8. package/client/assets/material-symbols-outlined-C0dZ3SlO.woff2 +0 -0
  9. package/client/assets/runtime-protocol-vue-BUk5WXSy.js +1 -0
  10. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  11. package/client/assets/{vue-C8UuIO9J.js → vue-Kqzpl9Vx.js} +1 -1
  12. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  13. package/client/index.html +9 -11
  14. package/package.json +5 -4
  15. package/server/agent/backend/claude-code.ts +34 -0
  16. package/server/agent/backend/fake-echo.ts +370 -0
  17. package/server/agent/backend/index.ts +16 -1
  18. package/server/agent/config.ts +8 -1
  19. package/server/agent/mcpFailureMonitor.ts +167 -0
  20. package/server/agent/mcpPreflight.ts +185 -0
  21. package/server/agent/stream.ts +12 -1
  22. package/server/api/routes/mulmo-script.ts +19 -1
  23. package/server/api/routes/schedulerHandlers.ts +52 -4
  24. package/server/api/routes/sessions.ts +15 -0
  25. package/server/api/routes/skills.ts +263 -0
  26. package/server/events/notifications.ts +19 -91
  27. package/server/index.ts +87 -9
  28. package/server/notifier/macosReminderAdapter.ts +30 -0
  29. package/server/system/announceOptionalDeps.ts +50 -0
  30. package/server/system/config.ts +8 -1
  31. package/server/system/docker.ts +14 -6
  32. package/server/system/env.ts +18 -5
  33. package/server/system/optionalDeps.ts +129 -0
  34. package/server/utils/cli-flags.d.mts +14 -0
  35. package/server/utils/cli-flags.mjs +53 -0
  36. package/server/utils/time.ts +6 -0
  37. package/server/workspace/helps/business.md +2 -2
  38. package/server/workspace/helps/mulmoscript.md +3 -3
  39. package/server/workspace/helps/sandbox.md +2 -2
  40. package/server/workspace/hooks/dispatcher.mjs +1 -1
  41. package/server/workspace/paths.ts +13 -4
  42. package/server/workspace/skills/catalog.ts +355 -0
  43. package/server/workspace/skills/external/catalog.ts +283 -0
  44. package/server/workspace/skills/external/clone.ts +129 -0
  45. package/server/workspace/skills/external/id.ts +194 -0
  46. package/server/workspace/skills/external/install.ts +417 -0
  47. package/server/workspace/skills/external/presets.ts +50 -0
  48. package/server/workspace/skills-preset.ts +29 -17
  49. package/server/workspace/workspace.ts +10 -5
  50. package/src/App.vue +19 -8
  51. package/src/components/RightSidebar.vue +19 -0
  52. package/src/components/StackView.vue +10 -1
  53. package/src/config/apiRoutes.ts +0 -6
  54. package/src/config/roles.ts +2 -0
  55. package/src/lang/de.ts +50 -1
  56. package/src/lang/en.ts +49 -1
  57. package/src/lang/es.ts +49 -1
  58. package/src/lang/fr.ts +49 -1
  59. package/src/lang/ja.ts +49 -1
  60. package/src/lang/ko.ts +49 -1
  61. package/src/lang/pt-BR.ts +49 -1
  62. package/src/lang/zh.ts +49 -1
  63. package/src/plugins/manageSkills/View.vue +795 -30
  64. package/src/plugins/manageSkills/categories.ts +125 -0
  65. package/src/plugins/manageSkills/meta.ts +30 -0
  66. package/src/plugins/markdown/definition.ts +3 -3
  67. package/src/plugins/meta-types.ts +5 -0
  68. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  69. package/src/plugins/presentMulmoScript/View.vue +157 -33
  70. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  71. package/src/plugins/scheduler/View.vue +45 -9
  72. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  73. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  74. package/src/plugins/spreadsheet/View.vue +3 -3
  75. package/src/types/notification.ts +1 -1
  76. package/src/types/session.ts +6 -0
  77. package/src/types/sse.ts +5 -0
  78. package/src/types/toolCallHistory.ts +7 -0
  79. package/src/utils/agent/eventDispatch.ts +26 -5
  80. package/src/utils/agent/mcpHint.ts +50 -0
  81. package/src/utils/session/sessionEntries.ts +8 -32
  82. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  83. package/client/assets/chunk-CernVdwh.js +0 -1
  84. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  85. package/client/assets/index-BwrlMMHr.js +0 -5005
  86. package/client/assets/index-CvvNuegU.css +0 -2
  87. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  88. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  89. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  90. package/server/api/routes/notifications.ts +0 -195
  91. package/server/notifier/legacy-adapters.ts +0 -76
  92. package/src/composables/useSelectedResult.ts +0 -49
  93. /package/client/assets/{purify.es-Fx1Nqyry-Dwtk-9WZ.js → purify.es-Fx1Nqyry-B3aL7Uvj.js} +0 -0
  94. /package/client/assets/{typeof-DBp4T-Ny-CSr8wx1e.js → typeof-DBp4T-Ny-Bef7RiR_.js} +0 -0
@@ -0,0 +1,125 @@
1
+ // Pure helpers behind the /skills page sidebar. Lifted out of View.vue
2
+ // so the section-collapse state and the provenance rule (mc- prefix
3
+ // split, user/project source mapping) live in exactly one place and can
4
+ // be unit-tested in node:test without a DOM or a Vue runtime.
5
+
6
+ import type { SkillSummary } from "./index";
7
+
8
+ // categorizeSkill / pickInitialSelection only care about name + source,
9
+ // not description. Exposing a narrower input type lets unit tests build
10
+ // fixtures without padding placeholder descriptions everywhere.
11
+ export type SkillIdentity = Pick<SkillSummary, "name" | "source">;
12
+
13
+ // `mc-` is the launcher-managed namespace (see
14
+ // server/workspace/skills-preset.ts). Skills under this prefix ship
15
+ // with mulmoclaude and are overwritten on every boot, so the UI treats
16
+ // them as the read-only "system" provenance and gates editing
17
+ // accordingly. This is NOT the sidebar grouping axis — provenance only
18
+ // drives the per-row badge tooltip and the edit/delete gate. The
19
+ // sidebar groups by section (active vs catalog), see SKILL_SECTION_KEYS.
20
+ export const SYSTEM_SKILL_PREFIX = "mc-";
21
+ export type SkillProvenance = "system" | "project" | "user";
22
+
23
+ /** Map a skill to its provenance bucket (badge + edit-gate, not layout). */
24
+ export function categorizeSkill(skill: SkillIdentity): SkillProvenance {
25
+ if (skill.source === "user") return "user";
26
+ if (skill.name.startsWith(SYSTEM_SKILL_PREFIX)) return "system";
27
+ return "project";
28
+ }
29
+
30
+ // Sidebar collapsible sections, aligned with the #1335 catalog/active
31
+ // model: "active" = skills in `.claude/skills/` (discovered by Claude
32
+ // Code, loaded into the system prompt); "catalog" = launcher-managed
33
+ // presets the user can browse / ★ star / ▶ run once without bloating
34
+ // the prompt. Provenance (system/project/user) is shown as a per-row
35
+ // badge inside the Active section, not as its own collapsible group.
36
+ export const SKILL_SECTION_KEYS = ["active", "catalog"] as const;
37
+ export type SkillSectionKey = (typeof SKILL_SECTION_KEYS)[number];
38
+
39
+ export const SECTION_LABEL_KEYS: Record<SkillSectionKey, string> = {
40
+ active: "pluginManageSkills.sectionActive",
41
+ catalog: "pluginManageSkills.sectionCatalog",
42
+ };
43
+
44
+ // Both sections open by default — #1335 shows Active and Catalog
45
+ // expanded; the user collapses whichever they don't want to see.
46
+ export const DEFAULT_CLOSED_SECTIONS: readonly SkillSectionKey[] = [];
47
+ export const COLLAPSED_SECTIONS_STORAGE_KEY = "skills:sectionCollapsed";
48
+
49
+ /**
50
+ * @internal exported only so the unit tests can target the type guard
51
+ * directly. Call sites should reach it via loadCollapsedSections.
52
+ */
53
+ export function isSkillSectionKey(value: unknown): value is SkillSectionKey {
54
+ return typeof value === "string" && (SKILL_SECTION_KEYS as readonly string[]).includes(value);
55
+ }
56
+
57
+ /** Read the persisted collapse state, falling back to defaults on any error. */
58
+ export function loadCollapsedSections(): Set<SkillSectionKey> {
59
+ const defaults = new Set<SkillSectionKey>(DEFAULT_CLOSED_SECTIONS);
60
+ if (typeof window === "undefined") return defaults;
61
+ try {
62
+ const raw = window.localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY);
63
+ if (raw === null) return defaults;
64
+ const parsed: unknown = JSON.parse(raw);
65
+ if (!Array.isArray(parsed)) return defaults;
66
+ return new Set<SkillSectionKey>(parsed.filter(isSkillSectionKey));
67
+ } catch {
68
+ return defaults;
69
+ }
70
+ }
71
+
72
+ /** Persist the collapse state. Failures (e.g. localStorage disabled) are swallowed. */
73
+ export function persistCollapsedSections(state: ReadonlySet<SkillSectionKey>): void {
74
+ if (typeof window === "undefined") return;
75
+ try {
76
+ window.localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify([...state]));
77
+ } catch {
78
+ // localStorage may be unavailable (private mode) — swallow silently.
79
+ }
80
+ }
81
+
82
+ // Per-external-repo collapse state (#1383 PR-C2). Distinct storage key
83
+ // from the section-level state above: the section axis is a fixed
84
+ // 2-value union (active/catalog), whereas repo ids are open-ended
85
+ // (one per installed external repo), so this set is validated as
86
+ // plain strings rather than against a key union. Default: every repo
87
+ // EXPANDED (absent = open) — a freshly installed repo should show its
88
+ // skills without a click.
89
+ export const REPO_COLLAPSED_STORAGE_KEY = "skills:repoCollapsed";
90
+
91
+ /** Read the persisted per-repo collapse set, defaulting to empty
92
+ * (all repos expanded) on any error. */
93
+ export function loadRepoCollapsed(): Set<string> {
94
+ if (typeof window === "undefined") return new Set();
95
+ try {
96
+ const raw = window.localStorage.getItem(REPO_COLLAPSED_STORAGE_KEY);
97
+ if (raw === null) return new Set();
98
+ const parsed: unknown = JSON.parse(raw);
99
+ if (!Array.isArray(parsed)) return new Set();
100
+ return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
101
+ } catch {
102
+ return new Set();
103
+ }
104
+ }
105
+
106
+ /** Persist the per-repo collapse set. Failures are swallowed. */
107
+ export function persistRepoCollapsed(state: ReadonlySet<string>): void {
108
+ if (typeof window === "undefined") return;
109
+ try {
110
+ window.localStorage.setItem(REPO_COLLAPSED_STORAGE_KEY, JSON.stringify([...state]));
111
+ } catch {
112
+ // localStorage may be unavailable (private mode) — swallow silently.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Auto-select the first active skill so the right pane isn't empty on
118
+ * open. Returns null when the Active section is collapsed (don't select
119
+ * a row the user can't see) or when there are no active skills.
120
+ */
121
+ export function pickInitialSelection(skillList: readonly SkillIdentity[], collapsed: ReadonlySet<SkillSectionKey>): string | null {
122
+ if (skillList.length === 0) return null;
123
+ if (collapsed.has("active")) return null;
124
+ return skillList[0].name;
125
+ }
@@ -16,6 +16,36 @@ export const META = definePluginMeta({
16
16
  update: { method: "PUT", path: "/:name" },
17
17
  /** DELETE /api/skills/:name — delete a project-scope skill. */
18
18
  remove: { method: "DELETE", path: "/:name" },
19
+ /** GET /api/skills/catalog — list catalog entries (preset for
20
+ * now; anthropic / community land in #1335 PR-C). Catalog
21
+ * entries are NOT in `.claude/skills/` and don't enter the
22
+ * Claude Code system prompt until the user ★ Stars them. */
23
+ catalogList: { method: "GET", path: "/catalog" },
24
+ /** POST /api/skills/catalog/star — body `{ source, slug }`.
25
+ * Copies the catalog entry into `.claude/skills/<slug>/` so
26
+ * Claude Code's slash-command resolver picks it up. */
27
+ catalogStar: { method: "POST", path: "/catalog/star" },
28
+ /** GET /api/skills/catalog/preview?source=&slug= — returns one
29
+ * catalog entry's description + body. Used by the 📖 Preview
30
+ * modal and the ▶ Run once action (which feeds `body` into a
31
+ * fresh chat as user input). For external source, pass `repoId`
32
+ * + `skillFolder` instead of `slug`. */
33
+ catalogPreview: { method: "GET", path: "/catalog/preview" },
34
+ /** GET /api/skills/external/suggestions — bundled list of repo
35
+ * URLs the launcher recommends as starting points (Anthropic
36
+ * skills + community picks). */
37
+ externalSuggestions: { method: "GET", path: "/external/suggestions" },
38
+ /** GET /api/skills/external/repos — list installed external
39
+ * repos with their recorded URL / subpath / SHA. */
40
+ externalReposList: { method: "GET", path: "/external/repos" },
41
+ /** POST /api/skills/external/repos — body `{ url, subpath?, ref? }`.
42
+ * Clone (or refresh) the repo, copy each discovered SKILL.md
43
+ * into the catalog under `<external>/<repoId>/`. */
44
+ externalReposInstall: { method: "POST", path: "/external/repos" },
45
+ /** DELETE /api/skills/external/repos/:repoId — remove the
46
+ * catalog dir + scratch clone for one external repo. Active
47
+ * copies under `.claude/skills/` are NOT touched (star = fork). */
48
+ externalReposRemove: { method: "DELETE", path: "/external/repos/:repoId" },
19
49
  },
20
50
  mcpDispatch: "create",
21
51
  });
@@ -17,9 +17,9 @@ export interface MarkdownToolData {
17
17
  /** True when the `markdown` field is a workspace-relative file path
18
18
  * rather than inline content. Accepts only the canonical
19
19
  * `artifacts/documents/*.md` prefix now that server-side
20
- * `isMarkdownPath` agrees. Any legacy `markdowns/*.md` references
21
- * in old session JSONL must be migrated via
22
- * `scripts/migrate-legacy-artifact-paths.ts` (#773). */
20
+ * `isMarkdownPath` agrees. Legacy `markdowns/*.md` references in
21
+ * old session JSONL are no longer auto-resolved — those sessions
22
+ * render their markdown content as plain text. */
23
23
  export function isFilePath(value: string): boolean {
24
24
  if (!value.endsWith(".md")) return false;
25
25
  return value.startsWith("artifacts/documents/");
@@ -80,6 +80,11 @@ export interface PluginMeta {
80
80
  * live as separate named exports in the plugin's `meta.ts`
81
81
  * because their signatures are plugin-specific. */
82
82
  readonly staticChannels?: Readonly<Record<string, string>>;
83
+ /** Optional host binaries this plugin's features need (ids from
84
+ * the optional-deps registry, e.g. `["ffmpeg"]`). When any is
85
+ * missing the host warns the user once and the plugin's
86
+ * dependency-bound features degrade gracefully — see #1385. */
87
+ readonly requires?: readonly string[];
83
88
  }
84
89
 
85
90
  /** Substitute `:param` placeholders in a route URL with caller-
@@ -1,9 +1,9 @@
1
1
  <template>
2
- <div class="p-2 text-sm">
3
- <div class="font-medium text-gray-700 truncate mb-1">
2
+ <div class="p-2 text-sm" data-testid="mulmo-script-preview">
3
+ <div class="font-medium text-gray-700 truncate mb-1" data-testid="mulmo-script-preview-title">
4
4
  {{ title }}
5
5
  </div>
6
- <div v-if="description" class="text-xs text-gray-500 leading-relaxed">
6
+ <div v-if="description" class="text-xs text-gray-500 leading-relaxed" data-testid="mulmo-script-preview-description">
7
7
  {{ description }}
8
8
  </div>
9
9
  </div>
@@ -3,10 +3,10 @@
3
3
  <!-- Header -->
4
4
  <div class="flex items-start justify-between px-6 py-4 border-b border-gray-100 shrink-0">
5
5
  <div class="min-w-0 flex-1">
6
- <h2 class="text-lg font-semibold text-gray-800 truncate">
6
+ <h2 class="text-lg font-semibold text-gray-800 truncate" data-testid="mulmo-script-title">
7
7
  {{ script.title || "Untitled Script" }}
8
8
  </h2>
9
- <p v-if="script.description" class="text-sm text-gray-500 mt-0.5 truncate">
9
+ <p v-if="script.description" class="text-sm text-gray-500 mt-0.5 truncate" data-testid="mulmo-script-description">
10
10
  {{ script.description }}
11
11
  </p>
12
12
  <div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
@@ -89,6 +89,33 @@
89
89
  </div>
90
90
  </div>
91
91
 
92
+ <!--
93
+ Inline error chip for movie-generation failures (#1197).
94
+ Previously the catch arm of `generateMovie` raised an `alert()` —
95
+ blocking, no retry path, and many users just dismissed the modal
96
+ and saw a stalled spinner with no explanation. The chip stays
97
+ visible until the next generate attempt clears it.
98
+ -->
99
+ <div
100
+ v-if="movieError"
101
+ data-testid="mulmo-script-movie-error-chip"
102
+ class="bg-red-50 border border-red-200 text-red-800 text-xs px-3 py-2 mx-4 mt-3 mb-1 rounded flex items-start gap-2"
103
+ >
104
+ <span class="material-icons text-base shrink-0 mt-px">error_outline</span>
105
+ <div class="flex-1 min-w-0">
106
+ <div class="font-medium">{{ t("pluginMulmoScript.movieGenerationFailed") }}</div>
107
+ <div class="break-words whitespace-pre-wrap mt-0.5">{{ movieError }}</div>
108
+ </div>
109
+ <button
110
+ class="shrink-0 h-7 px-2 text-xs rounded border border-red-300 text-red-700 hover:bg-red-100 disabled:opacity-50"
111
+ :disabled="movieGenerating"
112
+ data-testid="mulmo-script-movie-retry-button"
113
+ @click="generateMovie"
114
+ >
115
+ {{ t("pluginMulmoScript.retry") }}
116
+ </button>
117
+ </div>
118
+
92
119
  <!-- Characters section -->
93
120
  <div v-if="characterKeys.length > 0" class="border-b border-gray-100 shrink-0 px-4 py-3">
94
121
  <div class="flex items-center justify-between mb-2">
@@ -459,6 +486,11 @@ interface Beat {
459
486
  id?: string;
460
487
  imagePrompt?: string;
461
488
  image?: { type: string; [key: string]: unknown };
489
+ /** Beat duration in seconds. The mulmocast schema notes this is
490
+ * "Used only when the text is empty" — when there's no TTS audio
491
+ * to drive playback, the Play loop uses this as the auto-advance
492
+ * timer (#1073). */
493
+ duration?: number;
462
494
  }
463
495
 
464
496
  interface ImageEntry {
@@ -510,11 +542,27 @@ const localOverrides = reactive<Record<number, Beat>>({});
510
542
  const movieGenerating = ref(false);
511
543
  const movieDownloading = ref(false);
512
544
  const moviePath = ref<string | null>(null);
545
+ // Persists the most-recent movie-generation failure so the spinner
546
+ // area can surface it inline with a retry button (#1197). Cleared
547
+ // at the start of every generate / regenerate attempt.
548
+ const movieError = ref<string | null>(null);
513
549
  const beatAudios = reactive<Record<number, string>>({});
514
550
  const audioState = reactive<Record<number, "generating" | "done" | "error">>({});
515
551
  const audioErrors = reactive<Record<number, string>>({});
516
552
  const playingAudio = ref<{ index: number; audio: HTMLAudioElement } | null>(null);
553
+ // Tracks the auto-advance timer running on a silent beat
554
+ // (`beat.text === ""`). Beats without text generate no audio, so the
555
+ // Play loop falls back to a `setTimeout(beat.duration)` for cues —
556
+ // without this, Play would stall on the first silent beat (#1073).
557
+ const silentPlaybackTimer = ref<{ index: number; timer: ReturnType<typeof setTimeout> } | null>(null);
517
558
  const audioProgress = ref(0);
559
+
560
+ // Default duration (seconds) for a silent beat whose script doesn't
561
+ // set `duration` either. Picked to roughly match the time it takes a
562
+ // reader to scan a `textSlide` — long enough to read, short enough
563
+ // not to feel stuck. The script's own `duration` always wins.
564
+ const SILENT_BEAT_DEFAULT_SEC = 3;
565
+ const MS_PER_SECOND = 1000;
518
566
  const beatListEl = ref<HTMLElement | null>(null);
519
567
  const lightbox = ref<{
520
568
  src: string;
@@ -564,10 +612,11 @@ function characterPrompt(key: string): string {
564
612
  }
565
613
 
566
614
  function stopPlayingAudio() {
567
- if (!playingAudio.value) return;
568
- playingAudio.value.audio.pause();
569
- playingAudio.value = null;
570
- audioProgress.value = 0;
615
+ // Single helper that clears both the audio path and the silent
616
+ // auto-advance timer — callers (lightbox open / arrow nav / Stop
617
+ // button) get consistent behaviour without remembering which
618
+ // playback mode the current beat was using (#1073).
619
+ stopAllPlayback();
571
620
  }
572
621
 
573
622
  function openLightbox(index: number) {
@@ -614,7 +663,76 @@ const isPlayReady = computed<boolean>(() => {
614
663
  function playPresentation() {
615
664
  if (!isPlayReady.value) return;
616
665
  openLightbox(0);
617
- if (beatAudios[0]) playAudio(0);
666
+ playBeat(0);
667
+ }
668
+
669
+ // Stop whichever playback handle is active. Idempotent. Called by
670
+ // openLightbox, manual stop / pause buttons, and by `playBeat`
671
+ // before kicking off a new beat so we never double-schedule. (#1073)
672
+ function stopAllPlayback(): void {
673
+ if (playingAudio.value) {
674
+ playingAudio.value.audio.pause();
675
+ playingAudio.value = null;
676
+ audioProgress.value = 0;
677
+ }
678
+ if (silentPlaybackTimer.value) {
679
+ clearTimeout(silentPlaybackTimer.value.timer);
680
+ silentPlaybackTimer.value = null;
681
+ }
682
+ }
683
+
684
+ // Single entry point for "start playback at beat <index>". Routes
685
+ // on what the script DECLARED, not on what's currently hydrated:
686
+ //
687
+ // - `text` empty → silent path (`scheduleSilentAdvance`). The
688
+ // schema says no audio is generated for empty-text beats, so
689
+ // `duration` drives auto-advance.
690
+ // - `text` present + audio loaded → audio path. `audio.ended`
691
+ // chains via `advanceFromBeat`.
692
+ // - `text` present + audio NOT loaded → stop. The Play button's
693
+ // `isPlayReady` gate prevented this for beat 0, but mid-stream
694
+ // a transient fetch miss must not silently skip the narration
695
+ // by falling through to the silent timer (Codex review on
696
+ // #1073 — gating on `beatAudios[index]` would do exactly that).
697
+ //
698
+ // Either path chains to the next beat via `advanceFromBeat`, so a
699
+ // run of silent beats — or audio / silent / audio sequences —
700
+ // plays through without manual interaction.
701
+ function playBeat(index: number): void {
702
+ stopAllPlayback();
703
+ const hasText = Boolean(effectiveBeat(index).text);
704
+ if (!hasText) {
705
+ scheduleSilentAdvance(index);
706
+ return;
707
+ }
708
+ if (beatAudios[index]) {
709
+ playAudio(index);
710
+ }
711
+ // Text beat with no audio yet → stop. The user can re-click Play
712
+ // once the audio finishes hydrating.
713
+ }
714
+
715
+ function scheduleSilentAdvance(index: number): void {
716
+ // Defensively narrow the script-supplied duration. A bad value
717
+ // (zero, negative, NaN, non-number) would otherwise collapse to
718
+ // an immediate timeout and the Play loop would race through every
719
+ // silent beat in a single tick (Codex review iter-5 on #1365).
720
+ // Falling back to the default keeps the presentation watchable.
721
+ const raw = effectiveBeat(index).duration;
722
+ const seconds = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : SILENT_BEAT_DEFAULT_SEC;
723
+ const timer = setTimeout(() => {
724
+ if (silentPlaybackTimer.value?.index !== index) return;
725
+ silentPlaybackTimer.value = null;
726
+ if (lightbox.value?.index === index) advanceFromBeat(index);
727
+ }, seconds * MS_PER_SECOND);
728
+ silentPlaybackTimer.value = { index, timer };
729
+ }
730
+
731
+ function advanceFromBeat(fromIndex: number): void {
732
+ lightboxMove(1);
733
+ const nextIndex = lightbox.value?.index;
734
+ if (nextIndex === undefined || nextIndex === fromIndex) return;
735
+ playBeat(nextIndex);
618
736
  }
619
737
 
620
738
  const hasPrev = computed(() => {
@@ -637,11 +755,12 @@ function jumpToBeat(index: number) {
637
755
  if (!lightbox.value) return;
638
756
  if (index === lightbox.value.index) return;
639
757
  if (!renderedImages[index]) return;
640
- const wasPlaying = playingAudio.value !== null;
758
+ // Carry the playback mode forward (audio OR silent timer) so a
759
+ // user clicking the beat-strip thumbnail mid-playback keeps the
760
+ // presentation rolling (#1073).
761
+ const wasPlaying = playingAudio.value !== null || silentPlaybackTimer.value !== null;
641
762
  openLightbox(index);
642
- if (wasPlaying && beatAudios[index]) {
643
- playAudio(index);
644
- }
763
+ if (wasPlaying) playBeat(index);
645
764
  }
646
765
 
647
766
  function beatTooltip(index: number): string {
@@ -652,20 +771,19 @@ function beatTooltip(index: number): string {
652
771
  function lightboxMove(delta: number) {
653
772
  if (!lightbox.value) return;
654
773
  const total = beats.value.length;
655
- // If audio was playing when the user clicked the arrow, carry the
656
- // playback over to the next beat that has audio. openLightbox()
657
- // unconditionally stops any active audio, so we capture the flag
658
- // BEFORE that and replay AFTER. The on-ended auto-advance path
659
- // already nulls playingAudio before calling lightboxMove, so this
660
- // branch won't double-fire there.
661
- const wasPlaying = playingAudio.value !== null;
774
+ // If a playback was in progress when the user clicked the arrow,
775
+ // carry it forward to whichever beat we land on — `playBeat`
776
+ // picks audio vs silent automatically. `openLightbox` stops the
777
+ // current playback, so capture the flag BEFORE that and chain
778
+ // AFTER. The on-ended / silent-advance paths already null their
779
+ // own state before calling `lightboxMove`, so this branch won't
780
+ // double-fire there.
781
+ const wasPlaying = playingAudio.value !== null || silentPlaybackTimer.value !== null;
662
782
  let i = lightbox.value.index + delta;
663
783
  while (i >= 0 && i < total) {
664
784
  if (renderedImages[i]) {
665
785
  openLightbox(i);
666
- if (wasPlaying && beatAudios[i]) {
667
- playAudio(i);
668
- }
786
+ if (wasPlaying) playBeat(i);
669
787
  return;
670
788
  }
671
789
  i += delta;
@@ -911,13 +1029,7 @@ function playAudio(index: number) {
911
1029
  if (playingAudio.value?.index !== index) return;
912
1030
  playingAudio.value = null;
913
1031
  audioProgress.value = 0;
914
- if (lightbox.value?.index === index) {
915
- lightboxMove(1);
916
- const nextIndex = lightbox.value?.index;
917
- if (nextIndex !== undefined && nextIndex !== index && beatAudios[nextIndex]) {
918
- playAudio(nextIndex);
919
- }
920
- }
1032
+ if (lightbox.value?.index === index) advanceFromBeat(index);
921
1033
  });
922
1034
  audio.play();
923
1035
  }
@@ -1019,10 +1131,9 @@ async function onCharDrop(event: DragEvent, key: string) {
1019
1131
  }
1020
1132
 
1021
1133
  function openCharacterLightbox(key: string) {
1022
- if (playingAudio.value) {
1023
- playingAudio.value.audio.pause();
1024
- playingAudio.value = null;
1025
- }
1134
+ // Stop both audio and silent timer — character lightbox is
1135
+ // outside the play loop (#1073).
1136
+ stopAllPlayback();
1026
1137
  lightbox.value = {
1027
1138
  src: charImages[key],
1028
1139
  text: key,
@@ -1135,6 +1246,15 @@ async function refreshScriptFromDisk(): Promise<void> {
1135
1246
  }
1136
1247
 
1137
1248
  async function initializeScript() {
1249
+ // Stop any in-flight playback BEFORE we tear down per-script state
1250
+ // — a pending `silentPlaybackTimer` or running audio from the
1251
+ // previous script would otherwise fire `advanceFromBeat()` against
1252
+ // the new script's lightbox / beat list and either crash or
1253
+ // silently jump the new presentation forward. Also close any open
1254
+ // lightbox so the user lands on the clean View for the new result
1255
+ // (Codex review iter-4 on #1365).
1256
+ stopAllPlayback();
1257
+ lightbox.value = null;
1138
1258
  // Reset scroll position so new results start at the top
1139
1259
  if (beatListEl.value) beatListEl.value.scrollTop = 0;
1140
1260
  // Reset per-script state
@@ -1275,6 +1395,7 @@ async function refreshMoviePath(): Promise<void> {
1275
1395
 
1276
1396
  async function generateMovie() {
1277
1397
  movieGenerating.value = true;
1398
+ movieError.value = null;
1278
1399
  try {
1279
1400
  const res = await apiFetchRaw(endpoints.generateMovie.url, {
1280
1401
  method: "POST",
@@ -1296,7 +1417,10 @@ async function generateMovie() {
1296
1417
  },
1297
1418
  });
1298
1419
  } catch (err) {
1299
- alert(extractErrorMessage(err));
1420
+ // Surface inline (instead of `alert()` which blocks + has no
1421
+ // retry affordance). The error chip with a retry button lives
1422
+ // next to the generate button in the template (#1197).
1423
+ movieError.value = extractErrorMessage(err);
1300
1424
  } finally {
1301
1425
  movieGenerating.value = false;
1302
1426
  }
@@ -49,4 +49,8 @@ export const META = definePluginMeta({
49
49
  downloadMovie: { method: "GET", path: "/download-movie" },
50
50
  },
51
51
  mcpDispatch: "save",
52
+ // mulmocast shells out to ffmpeg for movie/beat rendering. Without
53
+ // it the editor still works but render/generate-movie degrades —
54
+ // the host warns once at boot (#1385).
55
+ requires: ["ffmpeg"],
52
56
  });
@@ -127,12 +127,13 @@
127
127
  <div
128
128
  v-for="item in itemsForDay(day)"
129
129
  :key="item.id"
130
- class="text-xs px-1.5 py-0.5 rounded cursor-pointer truncate"
131
- :class="selectedId === item.id ? 'bg-blue-500 text-white' : 'bg-blue-100 text-blue-800 hover:bg-blue-200'"
132
- :title="item.title"
130
+ class="text-xs px-1.5 py-0.5 cursor-pointer truncate"
131
+ :class="[segmentClasses(item, day), selectedId === item.id ? 'bg-blue-500 text-white' : chipColorClasses(item)]"
132
+ :title="chipTitle(item)"
133
133
  @click="selectItem(item)"
134
134
  >
135
- <span v-if="itemTime(item)" class="font-medium">{{ itemTime(item) }} </span>{{ item.title }}
135
+ <span v-if="isBrokenChip(item)" class="font-medium">⚠ </span><span v-else-if="itemTime(item)" class="font-medium">{{ itemTime(item) }} </span
136
+ >{{ item.title }}
136
137
  </div>
137
138
  </div>
138
139
  </div>
@@ -173,12 +174,12 @@
173
174
  <div
174
175
  v-for="item in itemsForDay(day).slice(0, MAX_MONTH_ITEMS)"
175
176
  :key="item.id"
176
- class="text-[10px] leading-tight px-1 py-0.5 rounded cursor-pointer truncate"
177
- :class="selectedId === item.id ? 'bg-blue-500 text-white' : 'bg-blue-100 text-blue-800 hover:bg-blue-200'"
178
- :title="item.title"
177
+ class="text-[10px] leading-tight px-1 py-0.5 cursor-pointer truncate"
178
+ :class="[segmentClasses(item, day), selectedId === item.id ? 'bg-blue-500 text-white' : chipColorClasses(item)]"
179
+ :title="chipTitle(item)"
179
180
  @click="selectItem(item)"
180
181
  >
181
- {{ item.title }}
182
+ <span v-if="isBrokenChip(item)" class="font-medium">⚠ </span>{{ item.title }}
182
183
  </div>
183
184
  <div v-if="itemsForDay(day).length > MAX_MONTH_ITEMS" class="text-[10px] text-gray-400 px-1">
184
185
  {{ t("pluginScheduler.moreCount", { count: itemsForDay(day).length - MAX_MONTH_ITEMS }) }}
@@ -261,6 +262,7 @@ import TasksTab from "./TasksTab.vue";
261
262
  import { isToday, formatShortDate, formatMonthYear } from "../../utils/format/date";
262
263
  import { errorMessage } from "../../utils/errors";
263
264
  import { SCHEDULER_VIEW, SCHEDULER_VIEW_MODES as VIEW_MODES, SCHEDULER_TAB, type SchedulerViewMode as ViewMode, type SchedulerTab } from "./viewModes";
265
+ import { coversDay, eventColorClasses, isMalformedRange, segmentPosition, type SegmentPosition } from "./multiDayHelpers";
264
266
 
265
267
  const { t } = useI18n();
266
268
 
@@ -367,7 +369,41 @@ function toDateString(date: Date): string {
367
369
 
368
370
  function itemsForDay(day: Date): ScheduledItem[] {
369
371
  const dateStr = toDateString(day);
370
- return items.value.filter((item) => String(item.props.date) === dateStr);
372
+ return items.value.filter((item) => coversDay(item, dateStr));
373
+ }
374
+
375
+ const SEGMENT_BASE: Record<SegmentPosition, string> = {
376
+ only: "rounded",
377
+ start: "rounded-l",
378
+ middle: "",
379
+ end: "rounded-r",
380
+ };
381
+
382
+ function segmentClasses(item: ScheduledItem, day: Date): string {
383
+ const pos = segmentPosition(item, toDateString(day));
384
+ return pos ? SEGMENT_BASE[pos] : "rounded";
385
+ }
386
+
387
+ // Red dashed outline + warning-amber background screams "this is
388
+ // wrong, click and fix it." Returns a class string when the event
389
+ // has a broken range; empty when the event is well-formed and the
390
+ // per-event palette colour should apply.
391
+ const BROKEN_CLASSES = "bg-red-50 text-red-900 hover:bg-red-100 border border-dashed border-red-400";
392
+
393
+ function chipColorClasses(item: ScheduledItem): string {
394
+ if (isMalformedRange(item)) return BROKEN_CLASSES;
395
+ return eventColorClasses(item.id);
396
+ }
397
+
398
+ function chipTitle(item: ScheduledItem): string {
399
+ if (isMalformedRange(item)) {
400
+ return `⚠ ${t("pluginScheduler.invalidRange", { endDate: String(item.props.endDate) })} — ${item.title}`;
401
+ }
402
+ return item.title;
403
+ }
404
+
405
+ function isBrokenChip(item: ScheduledItem): boolean {
406
+ return isMalformedRange(item);
371
407
  }
372
408
 
373
409
  const unscheduledItems = computed(() => items.value.filter((item) => !item.props.date));
@@ -11,9 +11,10 @@ const toolDefinition: ToolDefinition = {
11
11
  prompt:
12
12
  "When users mention calendar events, appointments, meetings, or one-off reminders that have a date/time, use manageCalendar. " +
13
13
  "Use show to display the calendar, add to create an event, update to edit one, delete to remove one. " +
14
+ "Multi-day events (trips, conferences, vacations) set both `date` (start, inclusive) and `endDate` (end, inclusive) in `props`, both as `YYYY-MM-DD`. " +
14
15
  "For recurring automated tasks driven by a schedule (e.g. 'every morning at 8 fetch news'), use manageAutomations instead.",
15
16
  description:
16
- "Manage the user's calendar — show / add / update / delete dated calendar items. Calendar items have a title and free-form properties (date, time, location, …).",
17
+ "Manage the user's calendar — show / add / update / delete dated calendar items. Calendar items have a title and free-form properties (date, time, location, …); multi-day events also set endDate.",
17
18
  parameters: {
18
19
  type: "object",
19
20
  properties: {
@@ -32,7 +33,10 @@ const toolDefinition: ToolDefinition = {
32
33
  },
33
34
  props: {
34
35
  type: "object",
35
- description: "For 'add': initial properties (e.g. { date, time, location }). For 'update': properties to merge in; set a key to null to remove it.",
36
+ description:
37
+ "For 'add': initial properties (e.g. { date, time, location, endDate }). " +
38
+ "`date` and `endDate` are ISO `YYYY-MM-DD`; `endDate` is the inclusive last day of a multi-day event (omit for single-day events). " +
39
+ "For 'update': properties to merge in; set a key to null to remove it.",
36
40
  additionalProperties: true,
37
41
  },
38
42
  },