mulmoclaude 0.5.2 → 0.6.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 (486) hide show
  1. package/Dockerfile.sandbox +100 -0
  2. package/README.md +17 -4
  3. package/bin/mulmoclaude.js +46 -15
  4. package/bin/prepare-dist.js +18 -2
  5. package/client/assets/chunk-CernVdwh.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-C1eAZMzz.js +1 -0
  7. package/client/assets/html2canvas-CDGcmOD3-BbPeutDg.js +5 -0
  8. package/client/assets/index-BbgSjFQ8.js +4968 -0
  9. package/client/assets/index-ECD0lgIv.css +2 -0
  10. package/client/assets/{index.es-D4YyL_Dg-BgT6a3Nd.js → index.es-DqtpmBm8-DJdTPdnc.js} +5 -5
  11. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +1 -0
  13. package/client/assets/runtime-vue-BVUzgYGA.js +1 -0
  14. package/client/assets/typeof-DBp4T-Ny-C2xoZtcz.js +1 -0
  15. package/client/assets/vue-1e_vz2LW.js +1 -0
  16. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +4 -0
  17. package/client/index.html +33 -2
  18. package/package.json +20 -18
  19. package/sandbox-entrypoint.sh +106 -0
  20. package/server/accounting/accountNormalize.ts +32 -0
  21. package/server/accounting/defaultAccounts.ts +87 -0
  22. package/server/accounting/eventPublisher.ts +51 -0
  23. package/server/accounting/journal.ts +252 -0
  24. package/server/accounting/openingBalances.ts +114 -0
  25. package/server/accounting/report.ts +237 -0
  26. package/server/accounting/service.ts +718 -0
  27. package/server/accounting/snapshotCache.ts +333 -0
  28. package/server/accounting/timeSeries.ts +265 -0
  29. package/server/accounting/types.ts +148 -0
  30. package/server/agent/activeTools.ts +128 -0
  31. package/server/agent/attachmentConverter.ts +10 -5
  32. package/server/agent/backend/claude-code.ts +8 -2
  33. package/server/agent/backend/types.ts +1 -1
  34. package/server/agent/config.ts +101 -31
  35. package/server/agent/index.ts +45 -33
  36. package/server/agent/mcp-server.ts +146 -69
  37. package/server/agent/mcp-tools/index.ts +1 -5
  38. package/server/agent/mcp-tools/notify.ts +2 -22
  39. package/server/agent/mcp-tools/x.ts +0 -4
  40. package/server/agent/mcpHealth.ts +168 -0
  41. package/server/agent/plugin-names.ts +20 -77
  42. package/server/agent/prompt.ts +259 -51
  43. package/server/agent/resumeFailover.ts +1 -1
  44. package/server/agent/stream.ts +0 -1
  45. package/server/api/auth/bearerAuth.ts +5 -5
  46. package/server/api/csrfGuard.ts +1 -1
  47. package/server/api/routes/accounting.ts +366 -0
  48. package/server/api/routes/agent.ts +509 -46
  49. package/server/api/routes/attachment.ts +104 -0
  50. package/server/api/routes/chart.ts +2 -1
  51. package/server/api/routes/config.ts +12 -12
  52. package/server/api/routes/files.ts +105 -48
  53. package/server/api/routes/image.ts +70 -25
  54. package/server/api/routes/journal.ts +35 -0
  55. package/server/api/routes/mulmo-script.ts +358 -118
  56. package/server/api/routes/mulmoScriptValidate.ts +1 -1
  57. package/server/api/routes/news.ts +1 -1
  58. package/server/api/routes/notifications.ts +92 -22
  59. package/server/api/routes/notifier.ts +98 -0
  60. package/server/api/routes/pdf.ts +188 -48
  61. package/server/api/routes/plugins.ts +34 -14
  62. package/server/api/routes/presentHtml.ts +58 -3
  63. package/server/api/routes/roles.ts +1 -8
  64. package/server/api/routes/runtime-plugin.ts +224 -0
  65. package/server/api/routes/scheduler.ts +7 -5
  66. package/server/api/routes/schedulerHandlers.ts +1 -1
  67. package/server/api/routes/schedulerTasks.ts +8 -7
  68. package/server/api/routes/sessions.ts +234 -121
  69. package/server/api/routes/skills.ts +56 -51
  70. package/server/api/routes/sources.ts +52 -45
  71. package/server/api/routes/translation.ts +44 -0
  72. package/server/api/routes/wiki/frontmatter.ts +13 -65
  73. package/server/api/routes/wiki/history.ts +261 -0
  74. package/server/api/routes/wiki/pageIndex.ts +1 -1
  75. package/server/api/routes/wiki.ts +50 -26
  76. package/server/events/file-change.ts +83 -0
  77. package/server/events/notifications.ts +247 -91
  78. package/server/events/pub-sub/index.ts +1 -1
  79. package/server/events/relay-client.ts +5 -5
  80. package/server/events/scheduler-adapter.ts +2 -2
  81. package/server/events/session-store/index.ts +110 -22
  82. package/server/events/task-manager/index.ts +10 -9
  83. package/server/index.ts +509 -33
  84. package/server/notifier/engine.ts +419 -0
  85. package/server/notifier/legacy-adapters.ts +76 -0
  86. package/server/notifier/runtime-api.ts +74 -0
  87. package/server/notifier/store.ts +70 -0
  88. package/server/notifier/types.ts +121 -0
  89. package/server/plugins/dev-loader.ts +171 -0
  90. package/server/plugins/dev-watcher.ts +150 -0
  91. package/server/plugins/diagnostics.ts +188 -0
  92. package/server/plugins/preset-list.ts +52 -0
  93. package/server/plugins/preset-loader.ts +112 -0
  94. package/server/plugins/runtime-chat-api.ts +38 -0
  95. package/server/plugins/runtime-loader.ts +430 -0
  96. package/server/plugins/runtime-registry.ts +112 -0
  97. package/server/plugins/runtime-tasks-api.ts +50 -0
  98. package/server/plugins/runtime.ts +378 -0
  99. package/server/services/translation/cache.ts +72 -0
  100. package/server/services/translation/index.ts +106 -0
  101. package/server/services/translation/llm.ts +140 -0
  102. package/server/services/translation/types.ts +35 -0
  103. package/server/system/credentials.ts +13 -2
  104. package/server/system/env.ts +6 -1
  105. package/server/system/logger/formatters.ts +46 -4
  106. package/server/system/logger/index.ts +4 -4
  107. package/server/system/logger/sinks.ts +26 -5
  108. package/server/system/logger/types.ts +2 -2
  109. package/server/utils/dev-plugin-args.d.mts +11 -0
  110. package/server/utils/dev-plugin-args.mjs +43 -0
  111. package/server/utils/errors.ts +13 -4
  112. package/server/utils/files/accounting-io.ts +295 -0
  113. package/server/utils/files/atomic.ts +17 -49
  114. package/server/utils/files/attachment-store.ts +182 -0
  115. package/server/utils/files/html-io.ts +1 -7
  116. package/server/utils/files/html-store.ts +19 -0
  117. package/server/utils/files/image-store.ts +20 -22
  118. package/server/utils/files/index.ts +5 -15
  119. package/server/utils/files/journal-io.ts +7 -35
  120. package/server/utils/files/json.ts +2 -29
  121. package/server/utils/files/markdown-image-fill.ts +6 -37
  122. package/server/utils/files/markdown-store.ts +6 -21
  123. package/server/utils/files/naming.ts +3 -39
  124. package/server/utils/files/plugins-io.ts +100 -0
  125. package/server/utils/files/reference-dirs-io.ts +1 -9
  126. package/server/utils/files/roles-io.ts +2 -10
  127. package/server/utils/files/safe.ts +17 -19
  128. package/server/utils/files/scheduler-io.ts +1 -7
  129. package/server/utils/files/scheduler-overrides-io.ts +3 -12
  130. package/server/utils/files/session-io.ts +21 -30
  131. package/server/utils/files/spreadsheet-store.ts +9 -22
  132. package/server/utils/files/translation-io.ts +46 -0
  133. package/server/utils/files/user-tasks-io.ts +1 -7
  134. package/server/utils/files/workspace-io.ts +3 -79
  135. package/server/utils/gemini.ts +33 -11
  136. package/server/utils/html/htmlArtifactSplicer.ts +41 -0
  137. package/server/utils/markdown/frontmatter.ts +112 -0
  138. package/server/utils/regex.ts +56 -0
  139. package/server/utils/router.ts +41 -0
  140. package/server/utils/slug.ts +5 -3
  141. package/server/utils/time.ts +12 -0
  142. package/server/workspace/chat-index/indexer.ts +15 -2
  143. package/server/workspace/chat-index/summarizer.ts +1 -1
  144. package/server/workspace/custom-dirs.ts +1 -1
  145. package/server/workspace/helps/gemini.md +1 -1
  146. package/server/workspace/helps/guide.md +61 -0
  147. package/server/workspace/helps/index.md +4 -0
  148. package/server/workspace/helps/presenthtml.md +80 -0
  149. package/server/workspace/helps/sandbox.md +7 -0
  150. package/server/workspace/helps/storyteller.md +101 -0
  151. package/server/workspace/helps/telegram.md +1 -0
  152. package/server/workspace/helps/wiki.md +9 -7
  153. package/server/workspace/journal/archivist-cli.ts +7 -33
  154. package/server/workspace/journal/archivist-schemas.ts +5 -43
  155. package/server/workspace/journal/dailyPass.ts +34 -187
  156. package/server/workspace/journal/diff.ts +3 -28
  157. package/server/workspace/journal/index.ts +10 -81
  158. package/server/workspace/journal/indexFile.ts +3 -24
  159. package/server/workspace/journal/latestDaily.ts +51 -0
  160. package/server/workspace/journal/memoryExtractor.ts +4 -20
  161. package/server/workspace/journal/optimizationPass.ts +4 -21
  162. package/server/workspace/journal/paths.ts +4 -23
  163. package/server/workspace/journal/state.ts +6 -29
  164. package/server/workspace/memory/io.ts +213 -0
  165. package/server/workspace/memory/llm-classifier.ts +158 -0
  166. package/server/workspace/memory/migrate.ts +263 -0
  167. package/server/workspace/memory/run.ts +84 -0
  168. package/server/workspace/memory/topic-cluster.ts +218 -0
  169. package/server/workspace/memory/topic-detect.ts +67 -0
  170. package/server/workspace/memory/topic-index-hook.ts +128 -0
  171. package/server/workspace/memory/topic-io.ts +180 -0
  172. package/server/workspace/memory/topic-migrate.ts +248 -0
  173. package/server/workspace/memory/topic-run.ts +172 -0
  174. package/server/workspace/memory/topic-swap.ts +135 -0
  175. package/server/workspace/memory/topic-types.ts +142 -0
  176. package/server/workspace/memory/types.ts +83 -0
  177. package/server/workspace/news/reader.ts +4 -5
  178. package/server/workspace/paths.ts +124 -47
  179. package/server/workspace/roles.ts +2 -11
  180. package/server/workspace/skills/parser.ts +38 -55
  181. package/server/workspace/skills/user-tasks.ts +1 -2
  182. package/server/workspace/skills-preset/mc-library/SKILL.md +188 -0
  183. package/server/workspace/skills-preset.ts +196 -0
  184. package/server/workspace/sources/fetchers/githubIssues.ts +13 -11
  185. package/server/workspace/sources/fetchers/index.ts +1 -1
  186. package/server/workspace/sources/fetchers/rssParser.ts +1 -1
  187. package/server/workspace/sources/pipeline/index.ts +2 -2
  188. package/server/workspace/sources/pipeline/notify.ts +3 -3
  189. package/server/workspace/sources/pipeline/write.ts +2 -2
  190. package/server/workspace/sources/registry.ts +39 -61
  191. package/server/workspace/sources/robots.ts +1 -1
  192. package/server/workspace/tool-trace/classify.ts +2 -1
  193. package/server/workspace/tool-trace/index.ts +1 -1
  194. package/server/workspace/tool-trace/writeSearch.ts +6 -1
  195. package/server/workspace/wiki-backlinks/index.ts +19 -7
  196. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +1 -0
  197. package/server/workspace/wiki-history/hook/snapshot.mjs +98 -0
  198. package/server/workspace/wiki-history/hook/snapshot.ts +135 -0
  199. package/server/workspace/wiki-history/provision.ts +181 -0
  200. package/server/workspace/wiki-pages/io.ts +217 -0
  201. package/server/workspace/wiki-pages/snapshot.ts +380 -0
  202. package/server/workspace/workspace.ts +75 -13
  203. package/src/App.vue +115 -40
  204. package/src/_runtime/protocol-vue.ts +21 -0
  205. package/src/_runtime/vue.ts +22 -0
  206. package/src/components/ChatInput.vue +14 -10
  207. package/src/components/CopyChatButton.vue +76 -0
  208. package/src/components/FileContentRenderer.vue +67 -14
  209. package/src/components/FileTree.vue +2 -2
  210. package/src/components/FilesView.vue +17 -1
  211. package/src/components/NewsView.vue +16 -2
  212. package/src/components/NotificationBell.vue +320 -93
  213. package/src/components/PageChatComposer.vue +5 -4
  214. package/src/components/PluginLauncher.vue +42 -6
  215. package/src/components/PluginScopedRoot.vue +87 -0
  216. package/src/components/RoleSelector.vue +12 -1
  217. package/src/components/RolesView.vue +562 -0
  218. package/src/components/SentAttachmentChip.vue +102 -0
  219. package/src/components/SessionHistoryPanel.vue +109 -20
  220. package/src/components/SessionRoleIcon.vue +7 -4
  221. package/src/components/SessionSidebar.vue +20 -7
  222. package/src/components/SessionTabBar.vue +1 -1
  223. package/src/components/SettingsMcpTab.vue +4 -4
  224. package/src/components/SettingsModal.vue +2 -0
  225. package/src/components/SidebarHeader.vue +16 -5
  226. package/src/components/SourcesManager.vue +23 -9
  227. package/src/components/SourcesView.vue +1 -1
  228. package/src/components/StackView.vue +102 -6
  229. package/src/components/SuggestionsPanel.vue +105 -16
  230. package/src/components/SystemFileBanner.vue +1 -1
  231. package/src/components/TodoExplorer.vue +4 -5
  232. package/src/components/todo/TodoAddDialog.vue +2 -3
  233. package/src/components/todo/TodoEditDialog.vue +1 -2
  234. package/src/components/todo/TodoEditPanel.vue +2 -3
  235. package/src/components/todo/TodoKanbanView.vue +8 -5
  236. package/src/components/todo/TodoListView.vue +3 -5
  237. package/src/components/todo/TodoTableView.vue +7 -5
  238. package/src/composables/useAccountingChannel.ts +58 -0
  239. package/src/composables/useActiveSession.ts +4 -25
  240. package/src/composables/useAppApi.ts +6 -44
  241. package/src/composables/useClipboardCopy.ts +3 -20
  242. package/src/composables/useContentDisplay.ts +33 -2
  243. package/src/composables/useDevPluginReload.ts +23 -0
  244. package/src/composables/useDynamicFavicon.ts +5 -31
  245. package/src/composables/useEventListeners.ts +0 -20
  246. package/src/composables/useExpandedDirs.ts +4 -15
  247. package/src/composables/useFaviconState.ts +12 -46
  248. package/src/composables/useFileChange.ts +53 -0
  249. package/src/composables/useFreshPluginData.ts +6 -43
  250. package/src/composables/useHealth.ts +14 -43
  251. package/src/composables/useImageErrorRepair.ts +104 -0
  252. package/src/composables/useLatestDaily.ts +40 -0
  253. package/src/composables/useMarkdownDoc.ts +39 -0
  254. package/src/composables/useMarkdownLinkHandler.ts +1 -1
  255. package/src/composables/useMcpTools.ts +3 -16
  256. package/src/composables/useNotifications.ts +138 -112
  257. package/src/composables/usePdfDownload.ts +17 -3
  258. package/src/composables/usePendingCalls.ts +8 -26
  259. package/src/composables/usePluginErrorBoundary.ts +68 -0
  260. package/src/composables/usePubSub.ts +9 -17
  261. package/src/composables/useRunElapsed.ts +5 -22
  262. package/src/composables/useSandboxStatus.ts +4 -20
  263. package/src/composables/useSessionDerived.ts +7 -15
  264. package/src/composables/useSessionHistory.ts +70 -29
  265. package/src/composables/useSessionSync.ts +25 -3
  266. package/src/composables/useSkillsList.ts +59 -0
  267. package/src/composables/useTranslatedQueries.ts +109 -0
  268. package/src/config/apiRoutes.ts +181 -80
  269. package/src/config/historyFilters.ts +5 -3
  270. package/src/config/hostEvents.ts +17 -0
  271. package/src/config/mcpCatalog.ts +277 -5
  272. package/src/config/pubsubChannels.ts +134 -12
  273. package/src/config/roles.ts +212 -147
  274. package/src/config/systemFileDescriptors.ts +5 -5
  275. package/src/config/toolNames.ts +52 -30
  276. package/src/config/workspacePaths.ts +26 -2
  277. package/src/lang/de.ts +483 -27
  278. package/src/lang/en.ts +448 -27
  279. package/src/lang/es.ts +474 -27
  280. package/src/lang/fr.ts +476 -27
  281. package/src/lang/ja.ts +465 -27
  282. package/src/lang/ko.ts +466 -27
  283. package/src/lang/pt-BR.ts +473 -27
  284. package/src/lang/zh.ts +463 -27
  285. package/src/lib/vue-i18n.ts +1 -1
  286. package/src/lib/wiki-page/slug.ts +66 -0
  287. package/src/main.ts +85 -0
  288. package/src/plugins/_extras.ts +58 -0
  289. package/src/plugins/_generated/metas.ts +42 -0
  290. package/src/plugins/_generated/registrations.ts +44 -0
  291. package/src/plugins/_generated/server-bindings.ts +47 -0
  292. package/src/plugins/accounting/Preview.vue +106 -0
  293. package/src/plugins/accounting/View.vue +632 -0
  294. package/src/plugins/accounting/actions.ts +34 -0
  295. package/src/plugins/accounting/api.ts +301 -0
  296. package/src/plugins/accounting/components/AccountEditor.vue +250 -0
  297. package/src/plugins/accounting/components/AccountRow.vue +50 -0
  298. package/src/plugins/accounting/components/AccountsList.vue +102 -0
  299. package/src/plugins/accounting/components/AccountsModal.vue +300 -0
  300. package/src/plugins/accounting/components/BalanceSheet.vue +186 -0
  301. package/src/plugins/accounting/components/BookSettings.vue +284 -0
  302. package/src/plugins/accounting/components/BookSwitcher.vue +78 -0
  303. package/src/plugins/accounting/components/DateRangePicker.vue +140 -0
  304. package/src/plugins/accounting/components/JournalEntryForm.vue +504 -0
  305. package/src/plugins/accounting/components/JournalList.vue +553 -0
  306. package/src/plugins/accounting/components/Ledger.vue +206 -0
  307. package/src/plugins/accounting/components/NewBookForm.vue +211 -0
  308. package/src/plugins/accounting/components/OpeningBalancesForm.vue +271 -0
  309. package/src/plugins/accounting/components/ProfitLoss.vue +160 -0
  310. package/src/plugins/accounting/components/accountDraft.ts +13 -0
  311. package/src/plugins/accounting/components/accountNumbering.ts +103 -0
  312. package/src/plugins/accounting/components/accountValidation.ts +75 -0
  313. package/src/plugins/accounting/components/useLatestRequest.ts +44 -0
  314. package/src/plugins/accounting/countries.ts +158 -0
  315. package/src/plugins/accounting/currencies.ts +64 -0
  316. package/src/plugins/accounting/dates.ts +51 -0
  317. package/src/plugins/accounting/definition.ts +199 -0
  318. package/src/plugins/accounting/fiscalYear.ts +136 -0
  319. package/src/plugins/accounting/index.ts +49 -0
  320. package/src/plugins/accounting/meta.ts +91 -0
  321. package/src/plugins/accounting/timeSeriesEnums.ts +16 -0
  322. package/src/plugins/api.ts +125 -0
  323. package/src/plugins/canvas/View.vue +38 -28
  324. package/src/plugins/canvas/definition.ts +10 -8
  325. package/src/plugins/canvas/index.ts +15 -8
  326. package/src/plugins/canvas/meta.ts +12 -0
  327. package/src/plugins/chart/Preview.vue +1 -1
  328. package/src/plugins/chart/View.vue +2 -2
  329. package/src/plugins/chart/definition.ts +12 -2
  330. package/src/plugins/chart/index.ts +15 -7
  331. package/src/plugins/chart/meta.ts +18 -0
  332. package/src/plugins/editImages/definition.ts +44 -0
  333. package/src/plugins/editImages/index.ts +43 -0
  334. package/src/plugins/editImages/meta.ts +5 -0
  335. package/src/plugins/generateImage/View.vue +3 -1
  336. package/src/plugins/generateImage/definition.ts +2 -0
  337. package/src/plugins/generateImage/index.ts +13 -5
  338. package/src/plugins/generateImage/meta.ts +5 -0
  339. package/src/plugins/index.ts +35 -0
  340. package/src/plugins/manageRoles/Preview.vue +7 -4
  341. package/src/plugins/manageRoles/View.vue +12 -8
  342. package/src/plugins/manageRoles/definition.ts +6 -0
  343. package/src/plugins/manageRoles/index.ts +7 -6
  344. package/src/plugins/manageSkills/View.vue +11 -7
  345. package/src/plugins/manageSkills/definition.ts +4 -1
  346. package/src/plugins/manageSkills/index.ts +14 -7
  347. package/src/plugins/manageSkills/meta.ts +21 -0
  348. package/src/plugins/manageSource/definition.ts +4 -1
  349. package/src/plugins/manageSource/index.ts +15 -7
  350. package/src/plugins/manageSource/meta.ts +21 -0
  351. package/src/plugins/markdown/Preview.vue +10 -8
  352. package/src/plugins/markdown/View.vue +84 -17
  353. package/src/plugins/markdown/definition.ts +7 -1
  354. package/src/plugins/markdown/index.ts +15 -8
  355. package/src/plugins/markdown/meta.ts +16 -0
  356. package/src/plugins/meta-types.ts +97 -0
  357. package/src/plugins/metas.ts +224 -0
  358. package/src/plugins/presentForm/Preview.vue +4 -15
  359. package/src/plugins/presentForm/View.vue +35 -78
  360. package/src/plugins/presentForm/definition.ts +7 -6
  361. package/src/plugins/presentForm/index.ts +12 -5
  362. package/src/plugins/presentForm/meta.ts +11 -0
  363. package/src/plugins/presentForm/plugin.ts +8 -9
  364. package/src/plugins/presentForm/types.ts +0 -24
  365. package/src/plugins/presentHtml/Preview.vue +1 -8
  366. package/src/plugins/presentHtml/View.vue +401 -30
  367. package/src/plugins/presentHtml/definition.ts +8 -5
  368. package/src/plugins/presentHtml/index.ts +15 -8
  369. package/src/plugins/presentHtml/meta.ts +14 -0
  370. package/src/plugins/presentMulmoScript/View.vue +327 -107
  371. package/src/plugins/presentMulmoScript/definition.ts +34 -7
  372. package/src/plugins/presentMulmoScript/helpers.ts +4 -5
  373. package/src/plugins/presentMulmoScript/index.ts +20 -7
  374. package/src/plugins/presentMulmoScript/meta.ts +52 -0
  375. package/src/plugins/scheduler/AutomationsPreview.vue +2 -8
  376. package/src/plugins/scheduler/Preview.vue +5 -2
  377. package/src/plugins/scheduler/TasksTab.vue +16 -36
  378. package/src/plugins/scheduler/View.vue +22 -54
  379. package/src/plugins/scheduler/automationsDefinition.ts +14 -9
  380. package/src/plugins/scheduler/automationsMeta.ts +5 -0
  381. package/src/plugins/scheduler/calendarDefinition.ts +4 -7
  382. package/src/plugins/scheduler/calendarMeta.ts +28 -0
  383. package/src/plugins/scheduler/formatSchedule.ts +6 -24
  384. package/src/plugins/scheduler/index.ts +26 -52
  385. package/src/plugins/scope.ts +57 -0
  386. package/src/plugins/server-bindings-types.ts +38 -0
  387. package/src/plugins/server.ts +32 -0
  388. package/src/plugins/skill/Preview.vue +25 -0
  389. package/src/plugins/skill/View.vue +125 -0
  390. package/src/plugins/skill/definition.ts +23 -0
  391. package/src/plugins/skill/index.ts +36 -0
  392. package/src/plugins/skill/plugin.ts +31 -0
  393. package/src/plugins/skill/types.ts +21 -0
  394. package/src/plugins/spreadsheet/Preview.vue +1 -3
  395. package/src/plugins/spreadsheet/View.vue +29 -49
  396. package/src/plugins/spreadsheet/cellHighlights.ts +2 -3
  397. package/src/plugins/spreadsheet/definition.ts +5 -2
  398. package/src/plugins/spreadsheet/index.ts +15 -8
  399. package/src/plugins/spreadsheet/keyboardNav.ts +38 -0
  400. package/src/plugins/spreadsheet/meta.ts +14 -0
  401. package/src/plugins/textResponse/Preview.vue +9 -1
  402. package/src/plugins/textResponse/View.vue +59 -8
  403. package/src/plugins/textResponse/index.ts +11 -3
  404. package/src/plugins/textResponse/plugin.ts +8 -10
  405. package/src/plugins/textResponse/types.ts +28 -0
  406. package/src/plugins/wiki/Preview.vue +6 -4
  407. package/src/plugins/wiki/View.vue +463 -254
  408. package/src/plugins/wiki/components/WikiPageBody.vue +159 -0
  409. package/src/plugins/wiki/helpers.ts +17 -0
  410. package/src/plugins/wiki/history/HistoryDetail.vue +325 -0
  411. package/src/plugins/wiki/history/HistoryTab.vue +167 -0
  412. package/src/plugins/wiki/history/RestoreConfirm.vue +63 -0
  413. package/src/plugins/wiki/history/api.ts +52 -0
  414. package/src/plugins/wiki/history/diff.ts +145 -0
  415. package/src/plugins/wiki/index.ts +42 -32
  416. package/src/plugins/wiki/meta.ts +10 -0
  417. package/src/plugins/wiki/pageEditLoader.ts +53 -0
  418. package/src/plugins/wiki/route.ts +8 -0
  419. package/src/router/guards.ts +2 -1
  420. package/src/router/index.ts +19 -0
  421. package/src/router/pageRoutes.ts +1 -0
  422. package/src/tools/index.ts +50 -51
  423. package/src/tools/runtimeLoader.ts +141 -0
  424. package/src/tools/types.ts +44 -1
  425. package/src/types/notification.ts +23 -0
  426. package/src/types/pastedFile.ts +10 -0
  427. package/src/types/session.ts +61 -3
  428. package/src/types/sse.ts +21 -6
  429. package/src/utils/agent/eventDispatch.ts +12 -9
  430. package/src/utils/agent/pastedAttachment.ts +35 -0
  431. package/src/utils/agent/request.ts +32 -3
  432. package/src/utils/agent/toolCalls.ts +7 -1
  433. package/src/utils/api.ts +1 -1
  434. package/src/utils/chat/exportMarkdown.ts +243 -0
  435. package/src/utils/errors.ts +10 -2
  436. package/src/utils/files/expandedDirs.ts +1 -1
  437. package/src/utils/filesPreview/todoPreview.ts +13 -2
  438. package/src/utils/format/date.ts +1 -3
  439. package/src/utils/format/jsonSyntax.ts +5 -0
  440. package/src/utils/html/iframeHeightReporterScript.ts +62 -0
  441. package/src/utils/html/previewCsp.ts +29 -2
  442. package/src/utils/image/htmlSrcAttrs.ts +122 -0
  443. package/src/utils/image/imageRepairInlineScript.ts +115 -0
  444. package/src/utils/image/resolve.ts +17 -3
  445. package/src/utils/image/rewriteMarkdownImageRefs.ts +62 -9
  446. package/src/utils/markdown/frontmatter.ts +125 -0
  447. package/src/utils/markdown/taskList.ts +7 -2
  448. package/src/utils/plugin/runtime.ts +132 -0
  449. package/src/utils/session/mergeSessions.ts +40 -37
  450. package/src/utils/session/sessionEntries.ts +74 -18
  451. package/src/utils/session/sessionHelpers.ts +54 -10
  452. package/src/utils/tools/result.ts +76 -14
  453. package/src/vite-env.d.ts +6 -0
  454. package/client/assets/html2canvas-Cx501zZr-Bug0qRNv.js +0 -5
  455. package/client/assets/index-CY-WpQUm.css +0 -2
  456. package/client/assets/index-DbTz2Mfs.js +0 -4911
  457. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  458. package/server/api/routes/html.ts +0 -114
  459. package/server/api/routes/todos.ts +0 -293
  460. package/server/api/routes/todosColumnsHandlers.ts +0 -333
  461. package/server/api/routes/todosHandlers.ts +0 -274
  462. package/server/api/routes/todosItemsHandlers.ts +0 -386
  463. package/server/utils/files/todos-io.ts +0 -29
  464. package/src/components/NotificationToast.vue +0 -75
  465. package/src/plugins/editImage/definition.ts +0 -27
  466. package/src/plugins/editImage/index.ts +0 -37
  467. package/src/plugins/presentHtml/helpers.ts +0 -72
  468. package/src/plugins/scheduler/LegacySchedulerView.vue +0 -32
  469. package/src/plugins/scheduler/legacyShape.ts +0 -34
  470. package/src/plugins/todo/Preview.vue +0 -68
  471. package/src/plugins/todo/View.vue +0 -378
  472. package/src/plugins/todo/composables/useTodos.ts +0 -179
  473. package/src/plugins/todo/definition.ts +0 -45
  474. package/src/plugins/todo/index.ts +0 -62
  475. package/src/plugins/todo/labels.ts +0 -163
  476. package/src/plugins/todo/priority.ts +0 -98
  477. package/src/plugins/todo/viewModes.ts +0 -19
  478. package/src/plugins/wiki/definition.ts +0 -25
  479. package/src/tools/legacyPluginNames.ts +0 -13
  480. package/src/utils/format/frontmatter.ts +0 -80
  481. package/src/utils/image/rewriteHtmlImageRefs.ts +0 -50
  482. package/src/utils/notification/dispatch.ts +0 -58
  483. /package/client/assets/{purify.es-Fx1Nqyry-BwJECkqS.js → purify.es-Fx1Nqyry-BSVNht6S.js} +0 -0
  484. /package/src/plugins/{editImage → editImages}/Preview.vue +0 -0
  485. /package/src/plugins/{editImage → editImages}/View.vue +0 -0
  486. /package/src/{config/schedulerActions.ts → plugins/scheduler/actions.ts} +0 -0
@@ -1,7 +1,8 @@
1
1
  import { Router, Request, Response } from "express";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
5
+ import { writeFileAtomic } from "../../utils/files/atomic.js";
5
6
  import { stripDataUri } from "../../utils/files/image-store.js";
6
7
  import { writeJsonAtomic } from "../../utils/files/json.js";
7
8
  import {
@@ -22,15 +23,16 @@ import {
22
23
  removeSessionProgressCallback,
23
24
  type MulmoScript,
24
25
  } from "mulmocast";
25
- import type { MulmoBeat, MulmoImagePromptMedia } from "@mulmocast/types";
26
+ import { mulmoScriptSchema, type MulmoBeat, type MulmoImagePromptMedia } from "@mulmocast/types";
26
27
  import { slugify } from "../../utils/slug.js";
27
28
  import { resolveWithinRoot } from "../../utils/files/safe.js";
28
29
  import { errorMessage } from "../../utils/errors.js";
29
30
  import { badRequest, notFound, serverError } from "../../utils/httpError.js";
30
- import { getOptionalStringQuery } from "../../utils/request.js";
31
+ import { getOptionalStringQuery, getSessionQuery } from "../../utils/request.js";
31
32
  import { log } from "../../system/logger/index.js";
32
33
  import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
33
34
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
35
+ import { bindRoute } from "../../utils/router.js";
34
36
  import { publishGeneration } from "../../events/session-store/index.js";
35
37
  import { GENERATION_KINDS } from "../../../src/types/events.js";
36
38
 
@@ -65,8 +67,10 @@ function ensureStoriesReal(): string | null {
65
67
  }
66
68
 
67
69
  interface SaveMulmoScriptBody {
68
- script: MulmoScript;
70
+ script?: MulmoScript;
69
71
  filename?: string;
72
+ filePath?: string;
73
+ autoGenerateMovie?: boolean;
70
74
  }
71
75
 
72
76
  interface RenderBeatBody {
@@ -82,7 +86,9 @@ interface UploadBeatImageBody {
82
86
  imageData: string; // base64 data URI
83
87
  }
84
88
 
85
- type ErrorResponse = { error: string };
89
+ interface ErrorResponse {
90
+ error: string;
91
+ }
86
92
 
87
93
  type BeatImageResponse = { image: string | null } | ErrorResponse;
88
94
  type BeatAudioResponse = { audio: string | null } | ErrorResponse;
@@ -98,31 +104,151 @@ interface FilePathQuery {
98
104
  filePath?: string;
99
105
  }
100
106
 
101
- router.post(API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
102
- const { script, filename } = req.body;
107
+ interface ScriptOutcome {
108
+ script: MulmoScript;
109
+ /** Workspace-relative wire form, e.g. "stories/foo.json". */
110
+ wireFilePath: string;
111
+ /** Realpath, used by movie generation and the dedup set. */
112
+ absoluteFilePath: string;
113
+ message: string;
114
+ }
103
115
 
104
- if (!script || !Array.isArray(script.beats)) {
105
- badRequest(res, "script with beats array is required");
116
+ // Unified entry point — save a fresh `script` OR re-display an existing
117
+ // one referenced by `filePath`. Folding both modes into one route lets
118
+ // the agent (MCP) and the GUI dispatcher hit the same endpoint without
119
+ // either side needing to know which mode the user picked. The MCP layer
120
+ // in `server/agent/plugin-names.ts` routes the tool name straight here,
121
+ // so any per-mode logic on the client would be invisible to it.
122
+ bindRoute(router, API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
123
+ const { script, filename, filePath, autoGenerateMovie } = req.body ?? {};
124
+
125
+ const hasScript = script !== undefined && script !== null;
126
+ const hasFilePath = typeof filePath === "string" && filePath !== "";
127
+ if (hasScript === hasFilePath) {
128
+ badRequest(
129
+ res,
130
+ hasScript ? "Provide either `script` or `filePath`, not both." : "Provide either `script` (new presentation) or `filePath` (existing presentation).",
131
+ );
106
132
  return;
107
133
  }
108
134
 
109
- mkdirSync(storiesDir, { recursive: true });
110
-
111
- const title = script.title || "untitled";
112
- const slug = filename ? filename.replace(/\.json$/, "") : slugify(title);
113
- const fname = `${slug}-${Date.now()}.json`;
114
- const filePath = path.join(storiesDir, fname);
135
+ const outcome = hasFilePath ? await loadScriptFromDisk(filePath, res) : await saveScriptToDisk(script as MulmoScript, filename, res);
136
+ if (!outcome) return; // helper already wrote a 4xx/5xx response
115
137
 
116
- await writeJsonAtomic(filePath, script);
138
+ if (autoGenerateMovie === true) {
139
+ triggerAutoBackgroundMovie(outcome.absoluteFilePath, outcome.wireFilePath, getSessionQuery(req) || undefined);
140
+ }
117
141
 
118
142
  res.json({
119
- data: { script, filePath: `stories/${fname}` },
120
- message: `Saved MulmoScript to stories/${fname}`,
143
+ data: { script: outcome.script, filePath: outcome.wireFilePath },
144
+ message: outcome.message,
121
145
  instructions: "Display the storyboard to the user.",
122
146
  });
123
147
  });
124
148
 
125
- router.post(API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
149
+ async function saveScriptToDisk(script: MulmoScript, filename: string | undefined, res: Response): Promise<ScriptOutcome | null> {
150
+ // Validate against the same schema the reopen path uses so /save and
151
+ // /load agree on what counts as a valid MulmoScript — otherwise
152
+ // /save could persist a script that /load would later refuse, and
153
+ // autoGenerateMovie could kick off against malformed input.
154
+ const validation = mulmoScriptSchema.safeParse(script);
155
+ if (!validation.success) {
156
+ badRequest(res, "script is not a valid MulmoScript");
157
+ return null;
158
+ }
159
+ const validatedScript = validation.data;
160
+
161
+ mkdirSync(storiesDir, { recursive: true });
162
+
163
+ const title = validatedScript.title || "untitled";
164
+ // slugify drops `/`, `\`, and `..`, so a hostile `filename` like
165
+ // "../../etc/passwd" can never escape storiesDir via the path.join
166
+ // below — defense in depth on top of the wire-side validation.
167
+ const slug = filename ? slugify(filename.replace(/\.json$/i, "")) : slugify(title);
168
+ const fname = `${slug}-${Date.now()}.json`;
169
+ const writePath = path.join(storiesDir, fname);
170
+
171
+ await writeJsonAtomic(writePath, validatedScript);
172
+
173
+ // Realpath-resolve the freshly written file so `inFlightMovies`
174
+ // entries created from this code path collide with ones produced by
175
+ // `resolveStoryPath` (used by reopen / SSE generateMovie / movie-
176
+ // status). Without this, a symlinked storiesDir would let two movie
177
+ // generations for the same physical file run concurrently because
178
+ // their dedup keys differ (path.join vs realpath).
179
+ let absoluteFilePath: string;
180
+ try {
181
+ absoluteFilePath = realpathSync(writePath);
182
+ } catch {
183
+ absoluteFilePath = writePath;
184
+ }
185
+
186
+ return {
187
+ script: validatedScript,
188
+ wireFilePath: `stories/${fname}`,
189
+ absoluteFilePath,
190
+ message: `Saved MulmoScript to stories/${fname}`,
191
+ };
192
+ }
193
+
194
+ async function loadScriptFromDisk(filePath: string, res: Response): Promise<ScriptOutcome | null> {
195
+ if (!filePath.toLowerCase().endsWith(".json")) {
196
+ badRequest(res, "filePath must point to a .json file");
197
+ return null;
198
+ }
199
+ const absoluteFilePath = resolveStoryPath(filePath, res);
200
+ if (!absoluteFilePath) return null; // resolveStoryPath already responded
201
+
202
+ let raw: string;
203
+ try {
204
+ raw = readFileSync(absoluteFilePath, "utf-8");
205
+ } catch (err) {
206
+ serverError(res, errorMessage(err));
207
+ return null;
208
+ }
209
+
210
+ let parsed: unknown;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ } catch (err) {
214
+ badRequest(res, `Invalid JSON: ${errorMessage(err)}`);
215
+ return null;
216
+ }
217
+
218
+ const validation = mulmoScriptSchema.safeParse(parsed);
219
+ if (!validation.success) {
220
+ badRequest(res, "File is not a valid MulmoScript");
221
+ return null;
222
+ }
223
+
224
+ // Canonicalize via the realpath-resolved absoluteFilePath so wire
225
+ // forms like "bar.json" or "stories/foo/../bar.json" all collapse
226
+ // to the same "stories/<rel>" key — pendingGenerations and movie-
227
+ // status lookups depend on that stability.
228
+ const wireFilePath = toStoryRef(absoluteFilePath);
229
+
230
+ return {
231
+ script: validation.data,
232
+ wireFilePath,
233
+ absoluteFilePath,
234
+ message: `Loaded MulmoScript from ${wireFilePath}`,
235
+ };
236
+ }
237
+
238
+ // Module-level dedup so a foreground SSE call and a fire-and-forget
239
+ // background call can't race on the same script. Keyed by the realpath
240
+ // (absoluteFilePath) so two different wire spellings of the same file
241
+ // still collide. The set is intentionally process-local — a multi-
242
+ // process deployment would need an external lock; that's out of scope.
243
+ const inFlightMovies = new Set<string>();
244
+
245
+ function triggerAutoBackgroundMovie(absoluteFilePath: string, wireFilePath: string, chatSessionId: string | undefined): void {
246
+ if (inFlightMovies.has(absoluteFilePath)) return;
247
+ inFlightMovies.add(absoluteFilePath);
248
+ void runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
249
+ }
250
+
251
+ bindRoute(router, API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
126
252
  const validation = validateUpdateBeatBody(req.body);
127
253
  if (!validation.ok) {
128
254
  badRequest(res, validation.error);
@@ -146,7 +272,7 @@ router.post(API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, objec
146
272
  res.json({ ok: true });
147
273
  });
148
274
 
149
- router.post(API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
275
+ bindRoute(router, API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
150
276
  const validation = validateUpdateScriptBody(req.body);
151
277
  if (!validation.ok) {
152
278
  badRequest(res, validation.error);
@@ -161,7 +287,7 @@ router.post(API_ROUTES.mulmoScript.updateScript, async (req: Request<object, obj
161
287
  res.json({ ok: true });
162
288
  });
163
289
 
164
- router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatImageResponse, object, BeatQuery>, res: Response<BeatImageResponse>) => {
290
+ bindRoute(router, API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatImageResponse, object, BeatQuery>, res: Response<BeatImageResponse>) => {
165
291
  const { filePath, beatIndex: beatIndexStr } = req.query;
166
292
  const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
167
293
 
@@ -180,42 +306,46 @@ router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatIma
180
306
  });
181
307
  });
182
308
 
183
- router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, MovieStatusResponse, object, FilePathQuery>, res: Response<MovieStatusResponse>) => {
184
- const { filePath } = req.query;
309
+ bindRoute(
310
+ router,
311
+ API_ROUTES.mulmoScript.movieStatus,
312
+ async (req: Request<object, MovieStatusResponse, object, FilePathQuery>, res: Response<MovieStatusResponse>) => {
313
+ const { filePath } = req.query;
185
314
 
186
- if (!filePath) {
187
- badRequest(res, "filePath is required");
188
- return;
189
- }
190
-
191
- const absoluteFilePath = resolveStoryPath(filePath, res);
192
- if (!absoluteFilePath) return;
193
-
194
- try {
195
- const context = await buildContext(absoluteFilePath);
196
- if (!context) {
197
- res.json({ moviePath: null });
315
+ if (!filePath) {
316
+ badRequest(res, "filePath is required");
198
317
  return;
199
318
  }
200
319
 
201
- const outputPath = movieFilePath(context);
202
- if (!existsSync(outputPath)) {
203
- res.json({ moviePath: null });
204
- return;
205
- }
320
+ const absoluteFilePath = resolveStoryPath(filePath, res);
321
+ if (!absoluteFilePath) return;
206
322
 
207
- const movieMtime = statSync(outputPath).mtimeMs;
208
- const sourceMtime = statSync(absoluteFilePath).mtimeMs;
209
- if (movieMtime < sourceMtime) {
210
- res.json({ moviePath: null });
211
- return;
212
- }
323
+ try {
324
+ const context = await buildContext(absoluteFilePath);
325
+ if (!context) {
326
+ res.json({ moviePath: null });
327
+ return;
328
+ }
213
329
 
214
- res.json({ moviePath: toStoryRef(outputPath) });
215
- } catch (err) {
216
- serverError(res, errorMessage(err));
217
- }
218
- });
330
+ const outputPath = movieFilePath(context);
331
+ if (!existsSync(outputPath)) {
332
+ res.json({ moviePath: null });
333
+ return;
334
+ }
335
+
336
+ const movieMtime = statSync(outputPath).mtimeMs;
337
+ const sourceMtime = statSync(absoluteFilePath).mtimeMs;
338
+ if (movieMtime < sourceMtime) {
339
+ res.json({ moviePath: null });
340
+ return;
341
+ }
342
+
343
+ res.json({ moviePath: toStoryRef(outputPath) });
344
+ } catch (err) {
345
+ serverError(res, errorMessage(err));
346
+ }
347
+ },
348
+ );
219
349
 
220
350
  function fileToDataUri(filePath: string, mimeType: string): string {
221
351
  const data = readFileSync(filePath);
@@ -250,7 +380,7 @@ function resolveStoryPath(filePath: string, res: Response): string | null {
250
380
  // Strip the optional "stories/" prefix so the remainder is a path
251
381
  // relative to storiesReal. Accepts both "stories/foo.json" (the
252
382
  // canonical caller convention) and bare "foo.json".
253
- const STORIES_PREFIX = "stories" + path.sep;
383
+ const STORIES_PREFIX = `stories${path.sep}`;
254
384
  const relFromStories =
255
385
  filePath === "stories" ? "" : filePath.startsWith(STORIES_PREFIX) || filePath.startsWith("stories/") ? filePath.slice("stories/".length) : filePath;
256
386
  // resolveWithinRoot enforces both the realpath boundary AND
@@ -357,7 +487,7 @@ export async function withStoryContext(
357
487
  }
358
488
  }
359
489
 
360
- router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAudioResponse, object, BeatQuery>, res: Response<BeatAudioResponse>) => {
490
+ bindRoute(router, API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAudioResponse, object, BeatQuery>, res: Response<BeatAudioResponse>) => {
361
491
  const { filePath, beatIndex: beatIndexStr } = req.query;
362
492
  const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
363
493
 
@@ -389,7 +519,8 @@ router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAud
389
519
  );
390
520
  });
391
521
 
392
- router.post(
522
+ bindRoute(
523
+ router,
393
524
  API_ROUTES.mulmoScript.generateBeatAudio,
394
525
  async (
395
526
  req: Request<
@@ -453,7 +584,7 @@ router.post(
453
584
  },
454
585
  );
455
586
 
456
- router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, object, RenderBeatBody>, res: Response) => {
587
+ bindRoute(router, API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, object, RenderBeatBody>, res: Response) => {
457
588
  const { filePath, beatIndex, force, chatSessionId } = req.body;
458
589
 
459
590
  if (!filePath || beatIndex === undefined) {
@@ -493,7 +624,68 @@ router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, objec
493
624
  }
494
625
  });
495
626
 
496
- router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response) => {
627
+ type MovieGenerationResult = { ok: true; outputPath: string } | { ok: false; error: string };
628
+
629
+ interface MovieProgressEvent {
630
+ kind: "image" | "audio";
631
+ beatIndex: number;
632
+ }
633
+
634
+ // Shared core for both the SSE-streaming `generateMovie` route and the
635
+ // fire-and-forget background path triggered by `autoGenerateMovie`.
636
+ // Builds the mulmo context, runs images→audio→movie, and reports
637
+ // per-beat progress through the supplied callback. Throws on
638
+ // unexpected pipeline errors; returns a structured failure when the
639
+ // pipeline runs to completion but the output file is missing.
640
+ async function runMovieGeneration(absoluteFilePath: string, onProgressEvent: (event: MovieProgressEvent) => void): Promise<MovieGenerationResult> {
641
+ const context = await buildContext(absoluteFilePath);
642
+ if (!context) return { ok: false, error: "Failed to initialize mulmo context" };
643
+
644
+ const idToIndex = new Map<string, number>();
645
+ (context.studio.script.beats as MulmoBeat[]).forEach((beat, index) => {
646
+ const key = beat.id ?? `__index__${index}`;
647
+ idToIndex.set(key, index);
648
+ });
649
+
650
+ // Known limitation: addSessionProgressCallback is global, so when two
651
+ // movie generations for *different* scripts run concurrently, both
652
+ // closures are invoked for every beat event and rely on idToIndex to
653
+ // filter out the other run's events. That filter is reliable only
654
+ // when each beat carries an explicit `id`. Beats without one fall
655
+ // back to "__index__${index}", and identical fallback ids across
656
+ // scripts collide → progress meant for script A surfaces on script B.
657
+ // Fixing this properly needs mulmocast to attach a per-run identifier
658
+ // to its progress events (or a global serialization gate); tracked
659
+ // separately, out of scope for this PR.
660
+ const onProgress = (event: { kind: string; sessionType: string; id?: string; inSession: boolean }) => {
661
+ if (event.kind !== "beat" || event.inSession || event.id === undefined) return;
662
+ const beatIndex = idToIndex.get(event.id);
663
+ if (beatIndex === undefined) return;
664
+ if (event.sessionType !== "image" && event.sessionType !== "audio") return;
665
+ onProgressEvent({ kind: event.sessionType, beatIndex });
666
+ };
667
+
668
+ addSessionProgressCallback(onProgress);
669
+ try {
670
+ // Order matters: audio() must run before images(). For html_tailwind
671
+ // beats with `animation: true`, mulmocast only emits the per-beat
672
+ // `_animated.mp4` when the beat's duration is already known (see
673
+ // processHtmlTailwindAnimated in mulmocast). Durations are populated
674
+ // by audio(), so running images() first leaves the .mp4 files
675
+ // missing and movie() then fails in validateBeatSource.
676
+ const audioContext = await audio(context);
677
+ const imagesContext = await images(audioContext);
678
+ await movie(imagesContext);
679
+
680
+ const outputPath = movieFilePath(imagesContext);
681
+ if (!existsSync(outputPath)) return { ok: false, error: "Movie was not generated" };
682
+ return { ok: true, outputPath };
683
+ } finally {
684
+ removeSessionProgressCallback(onProgress);
685
+ }
686
+ }
687
+
688
+ bindRoute(router, API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response) => {
497
689
  const { filePath, chatSessionId } = req.body;
498
690
 
499
691
  if (!filePath) {
@@ -504,66 +696,107 @@ router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, ob
504
696
  const absoluteFilePath = resolveStoryPath(filePath, res);
505
697
  if (!absoluteFilePath) return;
506
698
 
699
+ if (inFlightMovies.has(absoluteFilePath)) {
700
+ badRequest(res, "Movie generation is already in progress for this script");
701
+ return;
702
+ }
703
+
507
704
  res.setHeader("Content-Type", "text/event-stream");
508
705
  res.setHeader("Cache-Control", "no-cache");
509
706
  res.setHeader("Connection", "keep-alive");
510
707
 
511
708
  const send = (data: unknown) => res.write(`data: ${JSON.stringify(data)}\n\n`);
512
709
 
710
+ inFlightMovies.add(absoluteFilePath);
513
711
  publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", false);
514
712
  let genError: string | undefined;
515
713
  try {
516
- const context = await buildContext(absoluteFilePath);
517
- if (!context) {
518
- genError = "Failed to initialize mulmo context";
519
- send({ type: "error", message: genError });
520
- res.end();
521
- return;
522
- }
523
-
524
- // Build id → beatIndex map for the progress callback
525
- const idToIndex = new Map<string, number>();
526
- (context.studio.script.beats as MulmoBeat[]).forEach((beat, index) => {
527
- const key = beat.id ?? `__index__${index}`;
528
- idToIndex.set(key, index);
714
+ const result = await runMovieGeneration(absoluteFilePath, (event) => {
715
+ send({ type: `beat_${event.kind}_done`, beatIndex: event.beatIndex });
529
716
  });
530
-
531
- const onProgress = (event: { kind: string; sessionType: string; id?: string; inSession: boolean }) => {
532
- if (event.kind === "beat" && !event.inSession && event.id !== undefined) {
533
- const beatIndex = idToIndex.get(event.id);
534
- if (beatIndex !== undefined) {
535
- send({ type: `beat_${event.sessionType}_done`, beatIndex });
536
- }
537
- }
538
- };
539
-
540
- addSessionProgressCallback(onProgress);
541
- try {
542
- const imagesContext = await images(context);
543
- const audioContext = await audio(imagesContext);
544
- await movie(audioContext);
545
-
546
- const outputPath = movieFilePath(audioContext);
547
- if (!existsSync(outputPath)) {
548
- genError = "Movie was not generated";
549
- send({ type: "error", message: genError });
550
- res.end();
551
- return;
552
- }
553
-
554
- send({ type: "done", moviePath: toStoryRef(outputPath) });
555
- } finally {
556
- removeSessionProgressCallback(onProgress);
717
+ if (!result.ok) {
718
+ genError = result.error;
719
+ send({ type: "error", message: result.error });
720
+ return;
557
721
  }
722
+ send({ type: "done", moviePath: toStoryRef(result.outputPath) });
558
723
  } catch (err) {
559
724
  genError = errorMessage(err);
560
725
  send({ type: "error", message: genError });
561
726
  } finally {
727
+ inFlightMovies.delete(absoluteFilePath);
562
728
  publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", true, genError);
563
729
  res.end();
564
730
  }
565
731
  });
566
732
 
733
+ // Detached movie generation. Reports progress through the same session
734
+ // pendingGenerations channel that the View already watches — so a user
735
+ // opening the canvas mid-generation sees spinners, and a user opening
736
+ // it after completion sees the finished movie loaded from disk by the
737
+ // View's normal mount-time path. Errors are persisted to a
738
+ // `<filename>.error.txt` sidecar next to the script (no synchronous
739
+ // client to alert); any stale sidecar from a previous run is cleared on
740
+ // each new attempt. Triggered server-side from the unified save route
741
+ // when the caller passes `autoGenerateMovie: true`.
742
+ async function runBackgroundMovieGeneration(absoluteFilePath: string, wireFilePath: string, chatSessionId: string | undefined): Promise<void> {
743
+ const errorSidecarPath = `${absoluteFilePath}.error.txt`;
744
+ // Clear stale error from a previous failed run before starting; if it
745
+ // doesn't exist that's fine. Catch any unexpected fs errors silently —
746
+ // the worst case is the user sees an out-of-date error file later.
747
+ try {
748
+ unlinkSync(errorSidecarPath);
749
+ } catch {
750
+ // intentional: ENOENT is the common case, others non-fatal
751
+ }
752
+
753
+ publishGeneration(chatSessionId, GENERATION_KINDS.movie, wireFilePath, "", false);
754
+ let genError: string | undefined;
755
+ try {
756
+ const result = await runMovieGeneration(absoluteFilePath, (event) => {
757
+ // Mirror per-beat completions through the session channel so the
758
+ // View's pendingGenerations watcher reloads the asset off disk.
759
+ // We fire start+finish in two ticks — `setImmediate` lets the SSE
760
+ // writer flush the start event before the finish removes the
761
+ // entry, otherwise Vue's batched reactivity could see a net "no
762
+ // change" and skip the reload.
763
+ const eventKind = event.kind === "image" ? GENERATION_KINDS.beatImage : GENERATION_KINDS.beatAudio;
764
+ const key = String(event.beatIndex);
765
+ publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
766
+ setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
767
+ });
768
+
769
+ if (!result.ok) {
770
+ genError = result.error;
771
+ writeErrorSidecar(errorSidecarPath, result.error);
772
+ log.warn("mulmo-script", "background movie generation failed", { filePath: wireFilePath, error: result.error });
773
+ return;
774
+ }
775
+ log.info("mulmo-script", "background movie generation done", {
776
+ filePath: wireFilePath,
777
+ outputPath: result.outputPath,
778
+ });
779
+ } catch (err) {
780
+ genError = errorMessage(err);
781
+ writeErrorSidecar(errorSidecarPath, genError);
782
+ log.error("mulmo-script", "background movie generation crashed", { filePath: wireFilePath, error: genError });
783
+ } finally {
784
+ inFlightMovies.delete(absoluteFilePath);
785
+ publishGeneration(chatSessionId, GENERATION_KINDS.movie, wireFilePath, "", true, genError);
786
+ }
787
+ }
788
+
789
+ function writeErrorSidecar(errorSidecarPath: string, message: string): void {
790
+ try {
791
+ writeFileSync(errorSidecarPath, message);
792
+ } catch (writeErr) {
793
+ log.error("mulmo-script", "failed to write error sidecar", {
794
+ errorSidecarPath,
795
+ error: errorMessage(writeErr),
796
+ });
797
+ }
798
+ }
799
+
567
800
  interface CharacterImageQuery {
568
801
  filePath?: string;
569
802
  key?: string;
@@ -584,7 +817,8 @@ interface UploadCharacterImageBody {
584
817
 
585
818
  type CharacterImageResponse = { image: string | null } | ErrorResponse;
586
819
 
587
- router.get(
820
+ bindRoute(
821
+ router,
588
822
  API_ROUTES.mulmoScript.characterImage,
589
823
  async (req: Request<object, CharacterImageResponse, object, CharacterImageQuery>, res: Response<CharacterImageResponse>) => {
590
824
  const { filePath, key } = req.query;
@@ -605,26 +839,31 @@ router.get(
605
839
  },
606
840
  );
607
841
 
608
- router.post(API_ROUTES.mulmoScript.uploadBeatImage, async (req: Request<object, BeatImageResponse, UploadBeatImageBody>, res: Response<BeatImageResponse>) => {
609
- const { filePath, beatIndex, imageData } = req.body;
842
+ bindRoute(
843
+ router,
844
+ API_ROUTES.mulmoScript.uploadBeatImage,
845
+ async (req: Request<object, BeatImageResponse, UploadBeatImageBody>, res: Response<BeatImageResponse>) => {
846
+ const { filePath, beatIndex, imageData } = req.body;
610
847
 
611
- if (!filePath || beatIndex === undefined || !imageData) {
612
- badRequest(res, "filePath, beatIndex, and imageData are required");
613
- return;
614
- }
615
-
616
- await withStoryContext(res, filePath, {}, async ({ context }) => {
617
- const { imagePath } = getBeatPngImagePath(context, beatIndex);
618
- mkdirSync(path.dirname(imagePath), { recursive: true });
848
+ if (!filePath || beatIndex === undefined || !imageData) {
849
+ badRequest(res, "filePath, beatIndex, and imageData are required");
850
+ return;
851
+ }
619
852
 
620
- const base64 = stripDataUri(imageData);
621
- writeFileSync(imagePath, Buffer.from(base64, "base64"));
853
+ await withStoryContext(res, filePath, {}, async ({ context }) => {
854
+ const { imagePath } = getBeatPngImagePath(context, beatIndex);
855
+ // writeFileAtomic creates parent dirs and prevents a half-
856
+ // written PNG from surviving a crash mid-write (#881 v2).
857
+ const base64 = stripDataUri(imageData);
858
+ await writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
622
859
 
623
- res.json({ image: fileToDataUri(imagePath, "image/png") });
624
- });
625
- });
860
+ res.json({ image: fileToDataUri(imagePath, "image/png") });
861
+ });
862
+ },
863
+ );
626
864
 
627
- router.post(
865
+ bindRoute(
866
+ router,
628
867
  API_ROUTES.mulmoScript.renderCharacter,
629
868
  async (req: Request<object, CharacterImageResponse, RenderCharacterBody>, res: Response<CharacterImageResponse>) => {
630
869
  const { filePath, key, force, chatSessionId } = req.body;
@@ -675,7 +914,8 @@ router.post(
675
914
  },
676
915
  );
677
916
 
678
- router.post(
917
+ bindRoute(
918
+ router,
679
919
  API_ROUTES.mulmoScript.uploadCharacterImage,
680
920
  async (req: Request<object, CharacterImageResponse, UploadCharacterImageBody>, res: Response<CharacterImageResponse>) => {
681
921
  const { filePath, key, imageData } = req.body;
@@ -687,17 +927,17 @@ router.post(
687
927
 
688
928
  await withStoryContext(res, filePath, {}, async ({ context }) => {
689
929
  const imagePath = getReferenceImagePath(context, key, "png");
690
- mkdirSync(path.dirname(imagePath), { recursive: true });
691
-
930
+ // writeFileAtomic creates parent dirs and prevents a half-
931
+ // written PNG from surviving a crash mid-write (#881 v2).
692
932
  const base64 = stripDataUri(imageData);
693
- writeFileSync(imagePath, Buffer.from(base64, "base64"));
933
+ await writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
694
934
 
695
935
  res.json({ image: fileToDataUri(imagePath, "image/png") });
696
936
  });
697
937
  },
698
938
  );
699
939
 
700
- router.get(API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
940
+ bindRoute(router, API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
701
941
  const moviePath = getOptionalStringQuery(req, "moviePath");
702
942
 
703
943
  if (!moviePath) {
@@ -17,7 +17,7 @@ function formatZodIssues(
17
17
  // Zod's `$ZodIssue.path` is `PropertyKey[]` (includes `symbol`).
18
18
  // Accept the wider type so callers can pass `safeParse().error.issues`
19
19
  // directly; stringify any non-string/number segments at format time.
20
- issues: ReadonlyArray<{ message: string; path: ReadonlyArray<PropertyKey> }>,
20
+ issues: readonly { message: string; path: readonly PropertyKey[] }[],
21
21
  ): string {
22
22
  if (issues.length === 0) return "invalid shape";
23
23
  const head = issues
@@ -111,7 +111,7 @@ router.get(API_ROUTES.news.readState, (_req: Request, res: Response<ReadState |
111
111
  });
112
112
 
113
113
  router.put(API_ROUTES.news.readState, async (req: Request, res: Response<ReadState | { error: string }>) => {
114
- const body = req.body;
114
+ const { body } = req;
115
115
  if (!isRecord(body) || !Array.isArray(body.readIds)) {
116
116
  badRequest(res, "expected { readIds: string[] }");
117
117
  return;