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
@@ -19,6 +19,9 @@ import { prependJournalPointer } from "../../agent/prompt.js";
19
19
  import { buildTranscriptPreamble, isStaleSessionError } from "../../agent/resumeFailover.js";
20
20
  import { getOrCreateSession, beginRun, endRun, cancelRun, pushSessionEvent, pushToolResult, getActiveSessionIds } from "../../events/session-store/index.js";
21
21
  import { workspacePath } from "../../workspace/workspace.js";
22
+ import { discoverSkills } from "../../workspace/skills/discovery.js";
23
+ import type { Skill } from "../../workspace/skills/types.js";
24
+ import { isRecord } from "../../utils/types.js";
22
25
  import { maybeRunJournal } from "../../workspace/journal/index.js";
23
26
  import { maybeIndexSession } from "../../workspace/chat-index/index.js";
24
27
  import { maybeAppendWikiBacklinks } from "../../workspace/wiki-backlinks/index.js";
@@ -41,7 +44,9 @@ import { isSessionOrigin, type SessionOrigin } from "../../../src/types/session.
41
44
  // import { publishNotification } from "../../events/notifications.js";
42
45
  import { env } from "../../system/env.js";
43
46
  import type { Attachment } from "@mulmobridge/protocol";
44
- import { parseDataUrl } from "@mulmobridge/client";
47
+ import { isImagePath, loadImageBase64 } from "../../utils/files/image-store.js";
48
+ import { isAttachmentPath, loadAttachmentBase64, inferMimeFromExtension, saveAttachment } from "../../utils/files/attachment-store.js";
49
+ import { errorMessage } from "../../utils/errors.js";
45
50
 
46
51
  const router = Router();
47
52
  const PORT = env.port;
@@ -73,20 +78,6 @@ router.post(API_ROUTES.agent.internal.toolResult, async (req: Request<object, un
73
78
  res.json({ ok: outcome.kind === "processed" });
74
79
  });
75
80
 
76
- // Called by the MCP server to trigger a role switch on the frontend
77
- interface SwitchRoleBody {
78
- roleId: string;
79
- }
80
-
81
- router.post(API_ROUTES.agent.internal.switchRole, async (req: Request<object, unknown, SwitchRoleBody>, res: Response<OkResponse>) => {
82
- const chatSessionId = getSessionQuery(req);
83
- pushSessionEvent(chatSessionId, {
84
- type: EVENT_TYPES.switchRole,
85
- roleId: req.body.roleId,
86
- });
87
- res.json({ ok: true });
88
- });
89
-
90
81
  // Cancel a running agent session by killing the Claude CLI process.
91
82
  interface CancelBody {
92
83
  chatSessionId: string;
@@ -111,6 +102,18 @@ export interface StartChatParams {
111
102
  message: string;
112
103
  roleId: string;
113
104
  chatSessionId: string;
105
+ /** Bridge-only legacy carrier for "the user picked this image".
106
+ * No in-tree bridge sets it today; it remains on the type so
107
+ * external bridge clients that populate it from older protocol
108
+ * versions continue to work. Only workspace paths
109
+ * (`data/attachments/...` or `artifacts/images/...`) are accepted
110
+ * — `data:` URLs are no longer supported and are dropped with a
111
+ * warn. Bridges that need to ship raw bytes should use the
112
+ * modern `attachments[]` field with `{ mimeType, data }` entries;
113
+ * those get persisted to `data/attachments/YYYY/MM/` server-side
114
+ * and rewritten as path-bearing attachments. The Vue UI never
115
+ * sets this — paste/drop and sidebar picks ride on
116
+ * `attachments[]` as path-only entries directly. */
114
117
  selectedImageData?: string;
115
118
  attachments?: Attachment[];
116
119
  /** Where this session originates (#486). Accepts string for
@@ -133,6 +136,10 @@ export type StartChatResult = { kind: "started"; chatSessionId: string } | { kin
133
136
 
134
137
  export async function startChat(params: StartChatParams): Promise<StartChatResult> {
135
138
  const { message, roleId, chatSessionId, selectedImageData, attachments, userTimezone } = params;
139
+ // Bridge-only compat: external bridge clients may still populate
140
+ // `selectedImageData`. Fold it into `attachments` so the rest of
141
+ // this function only deals with one input shape.
142
+ const normalisedAttachments = mergeBridgeSelectedImage(selectedImageData, attachments);
136
143
 
137
144
  if (!message || !roleId || !chatSessionId) {
138
145
  return {
@@ -159,7 +166,6 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
159
166
  getOrCreateSession(chatSessionId, {
160
167
  roleId,
161
168
  resultsFilePath,
162
- selectedImageData,
163
169
  startedAt: now,
164
170
  updatedAt: now,
165
171
  hasUnread: persistedHasUnread,
@@ -176,10 +182,40 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
176
182
  return { kind: "error", error: "Session is already running", status: 409 };
177
183
  }
178
184
 
179
- // Run is committed. Now persist the user message so callers (and
180
- // other tabs) see the turn. Metadata first it powers the sidebar
181
- // title cache; the append follows so the jsonl is always a
182
- // superset of what metadata advertised.
185
+ // Run is committed. Process attachments next so any failure here
186
+ // rolls the run back via `endRun` before we persist or broadcast a
187
+ // user message leaving an orphan turn on disk when the request
188
+ // ultimately rejects would mislead every viewer of this session.
189
+ // Three things happen in this block, all guarded together:
190
+ // 1. Bridge inline-bytes (`{ data, mimeType }`) get saved to
191
+ // `data/attachments/YYYY/MM/` and rewritten as path-bearing
192
+ // attachments. After this every Attachment has a `path`.
193
+ // 2. `collectAttachedPaths` extracts the workspace paths to
194
+ // persist on the user JSONL line and broadcast on the SSE
195
+ // event so the UI can render chips for the turn.
196
+ // 3. `prepareRequestExtras` loads bytes off disk for the LLM
197
+ // request. With (1) above this is now a pure path-only walk.
198
+ // A malformed body (e.g. `attachments` not an array) or a
199
+ // filesystem I/O failure is logged and converted to a 400 here;
200
+ // beginRun is rolled back so subsequent turns aren't rejected with
201
+ // 409 forever.
202
+ let attachedPaths: string[];
203
+ let extras: RequestExtras;
204
+ try {
205
+ const persistedAttachments = await persistInlineBytesAsPaths(normalisedAttachments);
206
+ attachedPaths = collectAttachedPaths(persistedAttachments);
207
+ extras = await prepareRequestExtras(persistedAttachments);
208
+ } catch (err) {
209
+ log.warn("agent", "attachment processing failed — rolling back run", { chatSessionId, error: errorMessage(err) });
210
+ abortController.abort();
211
+ endRun(chatSessionId);
212
+ return { kind: "error", error: "Invalid attachments payload", status: 400 };
213
+ }
214
+
215
+ // Now persist the user message so callers (and other tabs) see the
216
+ // turn. Metadata first — it powers the sidebar title cache; the
217
+ // append follows so the jsonl is always a superset of what metadata
218
+ // advertised.
183
219
  const validOrigin = isSessionOrigin(params.origin) ? params.origin : undefined;
184
220
  if (isFirstTurn) {
185
221
  await createSessionMeta(chatSessionId, roleId, message, undefined, validOrigin);
@@ -191,7 +227,10 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
191
227
  }
192
228
 
193
229
  // Append user message for this turn
194
- await appendSessionLine(chatSessionId, JSON.stringify({ source: "user", type: EVENT_TYPES.text, message }));
230
+ await appendSessionLine(
231
+ chatSessionId,
232
+ JSON.stringify({ source: "user", type: EVENT_TYPES.text, message, ...(attachedPaths.length > 0 ? { attachments: attachedPaths } : {}) }),
233
+ );
195
234
 
196
235
  // Broadcast the user message so other tabs viewing this session
197
236
  // see the input in real time. Runs AFTER beginRun so a 409 never
@@ -200,6 +239,7 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
200
239
  type: EVENT_TYPES.text,
201
240
  source: "user",
202
241
  message,
242
+ ...(attachedPaths.length > 0 ? { attachments: attachedPaths } : {}),
203
243
  });
204
244
 
205
245
  const role = getRole(roleId);
@@ -213,7 +253,8 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
213
253
  resumed: Boolean(claudeSessionId),
214
254
  });
215
255
 
216
- const decoratedMessage = claudeSessionId ? message : prependJournalPointer(message, workspacePath);
256
+ const baseMessage = claudeSessionId ? message : prependJournalPointer(message, workspacePath);
257
+ const decoratedMessage = withAttachedFileMarker(baseMessage, extras.attachedFilePaths);
217
258
 
218
259
  runAgentInBackground({
219
260
  decoratedMessage,
@@ -224,7 +265,7 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
224
265
  resultsFilePath,
225
266
  requestStartedAt,
226
267
  toolArgsCache: createArgsCache(),
227
- attachments: mergeAttachments(selectedImageData, attachments),
268
+ attachments: extras.attachments,
228
269
  userTimezone,
229
270
  origin: validOrigin,
230
271
  });
@@ -234,35 +275,244 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
234
275
 
235
276
  // ── Helpers ──────────────────────────────────────────────────────────
236
277
 
237
- /** Convert legacy `selectedImageData` (data URL from the Vue UI) into
238
- * the generic Attachment format, then merge with any explicitly-
239
- * provided attachments from the bridge protocol. Returns undefined
240
- * when there's nothing to attach. */
241
- function mergeAttachments(selectedImageData: string | undefined, explicit: Attachment[] | undefined): Attachment[] | undefined {
278
+ interface RequestExtras {
279
+ attachments: Attachment[] | undefined;
280
+ /** Workspace-relative paths of every file the user attached or
281
+ * selected for this turn, in declaration order. Surfaced to the
282
+ * LLM via one `[Attached file: <path>]` line per entry, prepended
283
+ * to the user message so path-passing tools (e.g. `editImages`)
284
+ * and the LLM itself can reference each file by path.
285
+ * `persistInlineBytesAsPaths` ensures every well-formed attachment
286
+ * carries a path before this runs, so this is empty only when the
287
+ * request had no attachments at all (or every entry was malformed
288
+ * and dropped). */
289
+ attachedFilePaths: string[];
290
+ }
291
+
292
+ /** Pluck workspace-relative paths out of `attachments[]`. Used for
293
+ * persistence + broadcast of the user message: the Vue UI renders
294
+ * these as attachment chips next to the chat bubble.
295
+ * `persistInlineBytesAsPaths` runs first, so by the time we get
296
+ * here every well-formed entry already carries a `path` and chips
297
+ * round-trip for bridge attachments too — not just Vue uploads.
298
+ * Order matches declaration order so chip order matches the order
299
+ * the user attached them.
300
+ *
301
+ * Each path is validated against the same allow-list `loadFromPath`
302
+ * uses (`data/attachments/...` or `artifacts/images/...png`). A
303
+ * request can otherwise pin a bogus path on the chat record + SSE
304
+ * + LLM marker even though `loadFromPath` would refuse to read it
305
+ * (#1052 review).
306
+ *
307
+ * Defensive: `Array.isArray` guards against a malformed HTTP body
308
+ * where `attachments` is a truthy non-array. Without it `for...of`
309
+ * would throw and bypass the rollback path that calls `endRun`,
310
+ * leaving the session locked as running (#1052 review). */
311
+ export function collectAttachedPaths(attachments: Attachment[] | undefined): string[] {
312
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
313
+ const paths: string[] = [];
314
+ for (const att of attachments) {
315
+ if (typeof att.path !== "string" || att.path.length === 0) continue;
316
+ if (!isAttachmentPath(att.path) && !isImagePath(att.path)) continue;
317
+ paths.push(att.path);
318
+ }
319
+ return paths;
320
+ }
321
+
322
+ /** Bridge-only compat: external bridge clients may still ship a
323
+ * picked image via `StartChatParams.selectedImageData`. Convert
324
+ * that single value to a synthetic `Attachment` and prepend it to
325
+ * the explicit `attachments` array so downstream code only has to
326
+ * understand one input shape. The Vue UI never reaches this branch
327
+ * — it sends path-only attachments directly. */
328
+ function mergeBridgeSelectedImage(selectedImageData: string | undefined, attachments: Attachment[] | undefined): Attachment[] | undefined {
329
+ const synthetic = synthesiseBridgeAttachment(selectedImageData);
330
+ if (!synthetic) return Array.isArray(attachments) ? attachments : undefined;
331
+ return Array.isArray(attachments) && attachments.length > 0 ? [synthetic, ...attachments] : [synthetic];
332
+ }
333
+
334
+ /** Convert a legacy `selectedImageData` carrier to an `Attachment`.
335
+ * Only workspace paths (`data/attachments/...` or `artifacts/images/
336
+ * ...`) are accepted — `data:` URLs are no longer supported. A
337
+ * bridge that still wants to ship raw bytes should populate the
338
+ * modern `attachments[]` field with `{ mimeType, data }` instead;
339
+ * `persistInlineBytesAsPaths` then writes those to
340
+ * `data/attachments/YYYY/MM/` and turns them into path-bearing
341
+ * entries before any other processing. */
342
+ function synthesiseBridgeAttachment(selectedImageData: string | undefined): Attachment | undefined {
343
+ if (!selectedImageData) return undefined;
344
+ if (isAttachmentPath(selectedImageData) || isImagePath(selectedImageData)) {
345
+ return { path: selectedImageData };
346
+ }
347
+ log.warn("agent", "bridge selectedImageData is not a workspace path — dropping (data: URLs are no longer supported)", {
348
+ valuePreview: selectedImageData.slice(0, 64),
349
+ });
350
+ return undefined;
351
+ }
352
+
353
+ /** Persist any inline-bytes attachment to disk as a path-bearing
354
+ * entry. Bridges over the socket transport (Telegram, LINE, ...)
355
+ * ship raw bytes via `Attachment.data` + `Attachment.mimeType`; the
356
+ * Vue UI uploads to disk before posting and already carries a
357
+ * `path`. By rewriting inline bytes into
358
+ * `data/attachments/YYYY/MM/<id>.<ext>` here we get two properties
359
+ * the rest of the pipeline relies on:
360
+ *
361
+ * 1. Every well-formed attachment carries a workspace path, so
362
+ * chips can round-trip for bridge turns the same way they do
363
+ * for Vue paste/drop turns (no `data:` chips, no special
364
+ * cases downstream).
365
+ * 2. `prepareRequestExtras` becomes a path-only walk — the inline
366
+ * (`{ data, mimeType }`) shape no longer flows past this layer.
367
+ *
368
+ * Defensive: `Array.isArray` mirrors the guard in
369
+ * `collectAttachedPaths` so a malformed payload doesn't throw and
370
+ * bypass `endRun`. A failed save bubbles up so the caller can
371
+ * reject the turn — silently dropping the file would persist the
372
+ * user message without the attachment they sent and breaks the
373
+ * persistence/broadcast contract this layer is enforcing (#1052
374
+ * review). The caller's try/catch wraps the whole attachment-prep
375
+ * block and rolls the run back via `endRun`, so the failure path
376
+ * is well-defined: the user gets a 400, the session unlocks, and
377
+ * no orphan turn lands in jsonl. Entries with neither path nor
378
+ * inline bytes are still dropped (warn) — that's a malformed entry,
379
+ * not an I/O failure. */
380
+ async function persistInlineBytesAsPaths(attachments: Attachment[] | undefined): Promise<Attachment[] | undefined> {
381
+ if (!Array.isArray(attachments) || attachments.length === 0) return undefined;
242
382
  const result: Attachment[] = [];
243
- if (selectedImageData) {
244
- const parsed = parseDataUrl(selectedImageData);
245
- if (parsed) {
246
- result.push({ mimeType: parsed.mimeType, data: parsed.data });
383
+ for (const att of attachments) {
384
+ if (typeof att.path === "string" && att.path.length > 0) {
385
+ result.push(att);
386
+ continue;
247
387
  }
248
- }
249
- if (explicit) {
250
- result.push(...explicit);
388
+ if (typeof att.data === "string" && att.data.length > 0 && typeof att.mimeType === "string" && att.mimeType.length > 0) {
389
+ const saved = await saveAttachment(att.data, att.mimeType);
390
+ result.push({ path: saved.relativePath, mimeType: saved.mimeType });
391
+ continue;
392
+ }
393
+ log.warn("agent", "attachment has neither path nor inline bytes — dropping");
251
394
  }
252
395
  return result.length > 0 ? result : undefined;
253
396
  }
254
397
 
398
+ /** Walk `attachments[]` once, loading bytes from disk for every
399
+ * path-bearing entry, and collect every path so the caller can emit
400
+ * one `[Attached file: <path>]` marker per file. Two path roots
401
+ * are accepted:
402
+ *
403
+ * - `data/attachments/...` — paste/drop/file-picker uploads (any
404
+ * MIME type from the chat input's accept list) and the persisted
405
+ * form of bridge inline-bytes attachments. MIME is inferred from
406
+ * the extension chosen at save time.
407
+ * - `artifacts/images/...png` — generated / canvas / edited images
408
+ * a user picked from the sidebar. Always image/png.
409
+ *
410
+ * Bytes are loaded so Claude still "sees" each file as a content
411
+ * block on this turn, AND every path is returned separately so the
412
+ * caller marks them in the LLM-bound message. If a file can't be
413
+ * read, its path hint is still emitted — the LLM knows what was
414
+ * attached and can call Read to load it. Multi-file flows (e.g.
415
+ * paste one image + pick another in the sidebar → "combine these")
416
+ * rely on every path showing up in the marker so `editImages` can
417
+ * receive the full list in `imagePaths`.
418
+ *
419
+ * Inline (`{ data, mimeType }`) entries no longer reach this layer —
420
+ * `persistInlineBytesAsPaths` rewrites them as path-bearing entries
421
+ * before this runs. */
422
+ export async function prepareRequestExtras(attachments: Attachment[] | undefined): Promise<RequestExtras> {
423
+ if (!Array.isArray(attachments) || attachments.length === 0) {
424
+ return { attachments: undefined, attachedFilePaths: [] };
425
+ }
426
+ const result: Attachment[] = [];
427
+ const attachedFilePaths: string[] = [];
428
+ for (const att of attachments) {
429
+ if (typeof att.path !== "string" || att.path.length === 0) {
430
+ log.warn("agent", "attachment has no path after normalisation — dropping");
431
+ continue;
432
+ }
433
+ const resolved = await loadFromPath(att.path, att.mimeType);
434
+ if (!resolved) continue;
435
+ // Only emit the `[Attached file: …]` marker when the file was
436
+ // actually loaded — otherwise the LLM gets told a bogus path
437
+ // exists (Codex review on PR #1084 follow-up to #1052).
438
+ result.push(resolved);
439
+ attachedFilePaths.push(att.path);
440
+ }
441
+ return {
442
+ attachments: result.length > 0 ? result : undefined,
443
+ attachedFilePaths,
444
+ };
445
+ }
446
+
447
+ async function loadFromPath(value: string, declaredMimeType: string | undefined): Promise<Attachment | undefined> {
448
+ if (isAttachmentPath(value)) return loadAttachmentFromPath(value, declaredMimeType);
449
+ if (isImagePath(value)) return loadImageFromPath(value, declaredMimeType);
450
+ log.warn("agent", "attachment path is outside allowed roots — dropping", { path: value });
451
+ return undefined;
452
+ }
453
+
454
+ async function loadAttachmentFromPath(value: string, declaredMimeType: string | undefined): Promise<Attachment | undefined> {
455
+ const mimeType = declaredMimeType ?? inferMimeFromExtension(value);
456
+ if (!mimeType) {
457
+ log.warn("agent", "attachment path has unknown extension — skipping bytes", { path: value });
458
+ return undefined;
459
+ }
460
+ try {
461
+ const data = await loadAttachmentBase64(value);
462
+ return { mimeType, data, path: value };
463
+ } catch (err) {
464
+ log.warn("agent", "failed to load attachment bytes from path", { path: value, error: errorMessage(err) });
465
+ return undefined;
466
+ }
467
+ }
468
+
469
+ async function loadImageFromPath(value: string, declaredMimeType: string | undefined): Promise<Attachment | undefined> {
470
+ try {
471
+ const data = await loadImageBase64(value);
472
+ return { mimeType: declaredMimeType ?? "image/png", data, path: value };
473
+ } catch (err) {
474
+ log.warn("agent", "failed to load selected-image bytes from path", { path: value, error: errorMessage(err) });
475
+ return undefined;
476
+ }
477
+ }
478
+
479
+ // Drop any path containing characters that could break the
480
+ // `[Attached file: <path>]` marker line (`\r`, `\n`) or its closing
481
+ // bracket (`]`). Such a path would let request-controlled input
482
+ // inject arbitrary text into the privileged prompt prefix — the
483
+ // path itself reaches the marker straight from the request body.
484
+ // CodeRabbit review on #1045.
485
+ const UNSAFE_MARKER_CHARS_RE = /[\r\n\]]/;
486
+
487
+ /** Marker prepended to the LLM-bound user message that tells the
488
+ * model which workspace files are attached / selected for this turn.
489
+ * One `[Attached file: <path>]` line is emitted per path so multi-
490
+ * file flows (e.g. paste one image + pick another → "combine these")
491
+ * surface every path to the model — `editImages` then receives the
492
+ * full list in `imagePaths`. The user's persisted (jsonl) and
493
+ * broadcast (UI) message is the raw text — these marker lines are
494
+ * added strictly on the path to Claude. The system prompt teaches
495
+ * the model how to interpret them. */
496
+ export function withAttachedFileMarker(message: string, attachedFilePaths: string[]): string {
497
+ const safePaths = attachedFilePaths.filter((relPath) => !UNSAFE_MARKER_CHARS_RE.test(relPath));
498
+ if (safePaths.length === 0) return message;
499
+ const markerLines = safePaths.map((relPath) => `[Attached file: ${relPath}]`).join("\n");
500
+ return `${markerLines}\n\n${message}`;
501
+ }
502
+
255
503
  // ── HTTP route ──────────────────────────────────────────────────────
256
504
 
257
- // HTTP route body — used by the Vue UI only. `selectedImageData` is
258
- // the legacy data-URL path; new bridge clients send `attachments`
259
- // via the socket relay instead. mergeAttachments() unifies both
260
- // paths inside startChat(). See #382 for the rationale.
505
+ // HTTP route body — used by the Vue UI only. Paste/drop and sidebar
506
+ // pick both ride on `attachments[]` as path-only entries; the server
507
+ // reads bytes from disk and emits the `[Attached file: <path>]`
508
+ // marker. Bridges go through the socket relay (see chat-service)
509
+ // and supply attachments with inline base64 bytes; both shapes
510
+ // share the same `Attachment` type. See plans/done/refactor-edit-images-array.md.
261
511
  interface AgentBody {
262
512
  message: string;
263
513
  roleId: string;
264
514
  chatSessionId: string;
265
- selectedImageData?: string;
515
+ attachments?: Attachment[];
266
516
  userTimezone?: string;
267
517
  }
268
518
 
@@ -308,13 +558,77 @@ interface BackgroundRunParams {
308
558
  // `textAccumulator` collects streaming text chunks so we write
309
559
  // one consolidated line to the jsonl instead of per-chunk lines
310
560
  // (which would appear as separate cards on session reload).
561
+ //
562
+ // `pendingSkill` is set when a `tool_call` with `toolName === "Skill"`
563
+ // arrives. The next non-empty text flush IS the SKILL.md body that
564
+ // Claude CLI synthesises — gets tagged as `type: "skill"` instead of
565
+ // `type: "text"` and consumes the flag. (#1218)
566
+ //
567
+ // `toolUseId` is tracked alongside the slug so we can recognise the
568
+ // matching `tool_call_result` (which Claude CLI emits between the
569
+ // Skill tool_call and the body) and let it pass without clearing.
570
+ // Any OTHER non-text event between the Skill tool_call and the body
571
+ // flush is treated as a sequence break and clears the flag — covers
572
+ // the leak path Codex iter-2 flagged where a tool_call_result with
573
+ // a different `toolUseId` (or a `claudeSessionId`, or a flush at
574
+ // run-end) would otherwise leave `pendingSkill` set so a much-later
575
+ // unrelated assistant text gets mis-tagged as `type: "skill"`.
311
576
  interface EventContext {
312
577
  chatSessionId: string;
313
578
  resultsFilePath: string;
314
579
  toolArgsCache: ReturnType<typeof createArgsCache>;
315
580
  textAccumulator: string[];
581
+ pendingSkill: { skillName: string; toolUseId: string } | null;
316
582
  }
317
583
 
584
+ const CLAUDE_CLI_SKILL_BODY_PREFIX = "Base directory for this skill: ";
585
+
586
+ // #1218 — state-machine helpers for `pendingSkill`. Extracted so
587
+ // `handleAgentEvent` stays under the cognitive-complexity cap and so
588
+ // the leak-fix logic (Codex iter-2: clear on mismatched
589
+ // tool_call_result + claudeSessionId, in addition to the iter-1
590
+ // "non-Skill tool_call" clear) lives in one named place.
591
+ //
592
+ // When the model invokes a skill, Claude CLI emits the SKILL.md
593
+ // body as the next assistant text. We track that expectation here:
594
+ // the structural signal is `toolName === "Skill"` + `args.skill`
595
+ // slug, not a body-text prefix, so it survives any rewording Claude
596
+ // CLI might do to the synthesised body.
597
+
598
+ // Narrow the helpers to only the slot they read/mutate so the
599
+ // state-machine unit test in test/agent/ doesn't have to construct
600
+ // the full `EventContext` (chatSessionId, resultsFilePath, etc).
601
+ type PendingSkillSlot = Pick<EventContext, "pendingSkill">;
602
+
603
+ function updatePendingSkillOnToolCall(ctx: PendingSkillSlot, event: { toolName: string; toolUseId: string; args: unknown }): void {
604
+ // Any non-Skill tool_call resets the pending state. Without this,
605
+ // a Skill call followed by another tool (Bash, etc.) without a
606
+ // body flush in between would leak `pendingSkill` and miscategorise
607
+ // a later unrelated assistant text as a skill body.
608
+ if (event.toolName !== "Skill") {
609
+ ctx.pendingSkill = null;
610
+ return;
611
+ }
612
+ const skillSlug = isRecord(event.args) && typeof event.args.skill === "string" ? event.args.skill : null;
613
+ ctx.pendingSkill = skillSlug ? { skillName: skillSlug, toolUseId: event.toolUseId } : null;
614
+ }
615
+
616
+ // The Skill's own tool_call_result (matching toolUseId) carries
617
+ // "Launching skill: X" content; the body follows in the next text
618
+ // event so we leave `pendingSkill` set. A tool_call_result with any
619
+ // OTHER id means a different tool's result interleaved before the
620
+ // body — sequence broken, clear the flag so a later unrelated
621
+ // assistant text isn't mis-tagged as `type: "skill"`.
622
+ function updatePendingSkillOnToolCallResult(ctx: PendingSkillSlot, toolUseId: string): void {
623
+ if (ctx.pendingSkill && toolUseId !== ctx.pendingSkill.toolUseId) {
624
+ ctx.pendingSkill = null;
625
+ }
626
+ }
627
+
628
+ // Exported for the unit test in test/agent/test_pendingSkillStateMachine.ts.
629
+ export const _updatePendingSkillOnToolCallForTest = updatePendingSkillOnToolCall;
630
+ export const _updatePendingSkillOnToolCallResultForTest = updatePendingSkillOnToolCallResult;
631
+
318
632
  // Returns true if the event was handled "out of band" (no pub-sub
319
633
  // broadcast, no jsonl append). Right now only `claudeSessionId`
320
634
  // events fall into that bucket — they update meta and are otherwise
@@ -323,6 +637,10 @@ interface EventContext {
323
637
  async function handleAgentEvent(event: Awaited<ReturnType<typeof runAgent>> extends AsyncGenerator<infer E> ? E : never, ctx: EventContext): Promise<void> {
324
638
  if (event.type === EVENT_TYPES.claudeSessionId) {
325
639
  await flushTextAccumulator(ctx);
640
+ // claudeSessionId is a meta event — never part of a Skill→body
641
+ // sequence. Clear pendingSkill so a flag set earlier in the run
642
+ // can't leak into a later unrelated assistant text.
643
+ ctx.pendingSkill = null;
326
644
  await setClaudeId(ctx.chatSessionId, event.id);
327
645
  return;
328
646
  }
@@ -340,6 +658,7 @@ async function handleAgentEvent(event: Awaited<ReturnType<typeof runAgent>> exte
340
658
  // lose already-streamed text.
341
659
  await flushTextAccumulator(ctx);
342
660
  if (event.type === EVENT_TYPES.toolCall) {
661
+ updatePendingSkillOnToolCall(ctx, event);
343
662
  log.info("agent-tool", "call", {
344
663
  chatSessionId: ctx.chatSessionId,
345
664
  toolName: event.toolName,
@@ -347,6 +666,7 @@ async function handleAgentEvent(event: Awaited<ReturnType<typeof runAgent>> exte
347
666
  argsPreview: previewJson(event.args),
348
667
  });
349
668
  } else if (event.type === EVENT_TYPES.toolCallResult) {
669
+ updatePendingSkillOnToolCallResult(ctx, event.toolUseId);
350
670
  // Look up the toolName from the cache *before* recordToolEvent
351
671
  // runs (it deletes the cache entry on result).
352
672
  const cached = ctx.toolArgsCache.get(event.toolUseId);
@@ -373,11 +693,26 @@ async function handleAgentEvent(event: Awaited<ReturnType<typeof runAgent>> exte
373
693
  // jsonl line. Called at the end of each agent run (success or error)
374
694
  // so the session transcript has exactly one assistant text entry
375
695
  // per response, not N per-chunk entries.
696
+ //
697
+ // When `ctx.pendingSkill` is set (preceding tool_call had
698
+ // `toolName === "Skill"`), the flushed text is the SKILL.md body
699
+ // Claude CLI synthesised — write it as `type: "skill"` instead of
700
+ // `type: "text"` and consume the flag (#1218).
376
701
  async function flushTextAccumulator(ctx: EventContext): Promise<void> {
377
702
  if (ctx.textAccumulator.length === 0) return;
378
703
  const fullText = ctx.textAccumulator.join("");
379
704
  ctx.textAccumulator.length = 0;
380
705
  if (!fullText) return;
706
+
707
+ // Empty-string flushes (already handled above) don't consume
708
+ // pendingSkill — only the actual skill body should clear it.
709
+ const skill = ctx.pendingSkill;
710
+ ctx.pendingSkill = null;
711
+
712
+ if (skill) {
713
+ await writeSkillEntry(ctx, skill.skillName, fullText);
714
+ return;
715
+ }
381
716
  await appendSessionLine(
382
717
  ctx.chatSessionId,
383
718
  JSON.stringify({
@@ -388,6 +723,133 @@ async function flushTextAccumulator(ctx: EventContext): Promise<void> {
388
723
  );
389
724
  }
390
725
 
726
+ // Resolve the loaded skill against `discoverSkills()` to attach
727
+ // scope + path metadata, then write the consolidated assistant entry
728
+ // as `type: "skill"`. The body's full text is preserved in `message`
729
+ // (archival + the canvas's expand-on-click affordance). A live SSE
730
+ // `type: "skill"` event is also broadcast so observing tabs can
731
+ // replace the streamed text bubble with a collapsed skill card
732
+ // without waiting for a session reload.
733
+ //
734
+ // Claude CLI sometimes concatenates the LLM's actual reply to the
735
+ // synthesised SKILL.md body in the same text block (no `tool_call`
736
+ // or `content_block_stop` boundary between them — see PR #1220
737
+ // comment for the shiritori reproducer). We split that here using
738
+ // the SKILL.md body on disk as a structural delimiter; the reply
739
+ // portion gets persisted as a SECOND entry of `type: "text"` so it
740
+ // stays visible after the user collapses the skill card.
741
+ async function writeSkillEntry(ctx: EventContext, skillName: string, body: string): Promise<void> {
742
+ const resolved = await resolveSkillMetadata(skillName);
743
+ // Canary: skill detection is sequence-based (not body-prefix based),
744
+ // but we still cross-check the prefix as a format-drift signal.
745
+ // If Claude CLI ever changes its synthesised body shape, this warn
746
+ // surfaces before any user-visible regression.
747
+ if (!body.startsWith(CLAUDE_CLI_SKILL_BODY_PREFIX)) {
748
+ log.warn("agent", "Skill tool followed by text NOT starting with the expected Claude CLI prefix — body shape may have changed", {
749
+ skillName,
750
+ expectedPrefix: CLAUDE_CLI_SKILL_BODY_PREFIX,
751
+ actualPreview: body.slice(0, 80),
752
+ });
753
+ }
754
+ // A second canary: the SKILL.md body should appear verbatim inside
755
+ // the synthesised text. Failure means either discovery missed the
756
+ // skill (already logged by `resolveSkillMetadata`) or Claude CLI
757
+ // changed how it inlines the body (worth investigating).
758
+ if (resolved.body && !body.includes(resolved.body.trim())) {
759
+ log.warn("agent", "Claude CLI text does not contain the SKILL.md body verbatim — body split may be incorrect", {
760
+ skillName,
761
+ bodyBytes: body.length,
762
+ skillFileBytes: resolved.body.length,
763
+ });
764
+ }
765
+ const { skillPart, replyPart } = splitSkillAndReply(body, resolved.body);
766
+
767
+ const skillPayload = {
768
+ source: "assistant",
769
+ type: EVENT_TYPES.skill,
770
+ skillName,
771
+ skillScope: resolved.scope,
772
+ skillPath: resolved.path,
773
+ skillDescription: resolved.description,
774
+ message: skillPart,
775
+ };
776
+ pushSessionEvent(ctx.chatSessionId, skillPayload as Record<string, unknown>);
777
+ await appendSessionLine(ctx.chatSessionId, JSON.stringify(skillPayload));
778
+
779
+ if (replyPart) {
780
+ const textPayload = { source: "assistant", type: EVENT_TYPES.text, message: replyPart };
781
+ pushSessionEvent(ctx.chatSessionId, textPayload as Record<string, unknown>);
782
+ await appendSessionLine(ctx.chatSessionId, JSON.stringify(textPayload));
783
+ }
784
+ }
785
+
786
+ interface SkillMetadata {
787
+ scope: "user" | "project" | "unknown";
788
+ path: string | null;
789
+ /** From the SKILL.md frontmatter `description:` field. Used by the
790
+ * host's collapsed-skill card — Claude CLI strips frontmatter from
791
+ * the synthesised body, so the renderer can't re-extract this from
792
+ * `message`. Resolved here from `discoverSkills()` instead. */
793
+ description: string | null;
794
+ /** SKILL.md body (frontmatter already stripped by `discoverSkills`).
795
+ * Used as a structural delimiter to split the text Claude CLI
796
+ * emits — the body Claude CLI inlines is character-for-character
797
+ * this same string, with the LLM's actual reply concatenated
798
+ * after. */
799
+ body: string | null;
800
+ }
801
+
802
+ async function resolveSkillMetadata(skillName: string): Promise<SkillMetadata> {
803
+ try {
804
+ const skills: Skill[] = await discoverSkills({ workspaceRoot: workspacePath });
805
+ const found = skills.find((skill) => skill.name === skillName);
806
+ if (!found) return { scope: "unknown", path: null, description: null, body: null };
807
+ return { scope: found.source, path: found.path, description: found.description, body: found.body };
808
+ } catch (err) {
809
+ // Discovery failure is benign — keep tagging the entry so the UI
810
+ // can still collapse it; just leave metadata empty.
811
+ log.warn("agent", "skill metadata lookup failed — emitting entry without scope/path/description/body", {
812
+ skillName,
813
+ error: err instanceof Error ? err.message : String(err),
814
+ });
815
+ return { scope: "unknown", path: null, description: null, body: null };
816
+ }
817
+ }
818
+
819
+ /** Split the consolidated text Claude CLI emits after a `Skill`
820
+ * tool_call into the SKILL.md body half (synthesised by Claude CLI)
821
+ * and the LLM's actual reply half. Without this, the entire blob
822
+ * gets tagged `type: "skill"`, so when the user collapses the
823
+ * card their actual reply disappears (#1218 reproducer: shiritori
824
+ * skill, where the body ends with a "respond now" instruction and
825
+ * the LLM's first move is concatenated to the same text block).
826
+ *
827
+ * Structural split: the SKILL.md body is on disk, available via
828
+ * `discoverSkills()`. We find that exact substring inside the
829
+ * message and slice. Optional `ARGUMENTS: <user_input>` line that
830
+ * Claude CLI appends when the SKILL.md uses `{{ARGUMENTS}}` is
831
+ * consumed too. Returns the whole message as `skillPart` with
832
+ * empty `replyPart` when `skillBody` is empty (discovery missed)
833
+ * or not found verbatim (Claude CLI changed the inlining format —
834
+ * the canary log warn fires in that case). */
835
+ function splitSkillAndReply(message: string, skillBody: string | null): { skillPart: string; replyPart: string } {
836
+ if (!skillBody) return { skillPart: message, replyPart: "" };
837
+ const trimmedBody = skillBody.trim();
838
+ if (!trimmedBody) return { skillPart: message, replyPart: "" };
839
+ const idx = message.indexOf(trimmedBody);
840
+ if (idx < 0) return { skillPart: message, replyPart: "" };
841
+ let cursor = idx + trimmedBody.length;
842
+ // Skip the optional ARGUMENTS line + trailing whitespace.
843
+ const argMatch = /^\s*ARGUMENTS:[^\n]*\n?/.exec(message.slice(cursor));
844
+ if (argMatch) cursor += argMatch[0].length;
845
+ const skillPart = message.slice(0, cursor).trimEnd();
846
+ const replyPart = message.slice(cursor).replace(/^\s+/, "");
847
+ return { skillPart, replyPart };
848
+ }
849
+
850
+ // Exported for the unit test in test/agent/test_skillBodySplit.ts.
851
+ export const _splitSkillAndReplyForTest = splitSkillAndReply;
852
+
391
853
  // Helper kept commented (instead of deleted) alongside the
392
854
  // publishNotification call below — see the duplicate-notification
393
855
  // comment near `endRun()` in `runAgentInBackground` for context.
@@ -418,6 +880,7 @@ async function runAgentInBackground(params: BackgroundRunParams): Promise<void>
418
880
  resultsFilePath,
419
881
  toolArgsCache,
420
882
  textAccumulator: [],
883
+ pendingSkill: null,
421
884
  };
422
885
 
423
886
  // Retry budget for the stale `--resume` id fail-over (#211). Only
@@ -431,17 +894,17 @@ async function runAgentInBackground(params: BackgroundRunParams): Promise<void>
431
894
  try {
432
895
  while (true) {
433
896
  let staleSessionDetected = false;
434
- for await (const event of runAgent(
435
- currentMessage,
897
+ for await (const event of runAgent({
898
+ message: currentMessage,
436
899
  role,
437
900
  workspacePath,
438
- chatSessionId,
439
- PORT,
440
- currentClaudeSessionId,
901
+ sessionId: chatSessionId,
902
+ port: PORT,
903
+ claudeSessionId: currentClaudeSessionId,
441
904
  abortSignal,
442
905
  attachments,
443
906
  userTimezone,
444
- )) {
907
+ })) {
445
908
  if (failoverAttemptsRemaining > 0 && event.type === EVENT_TYPES.error && typeof event.message === "string" && isStaleSessionError(event.message)) {
446
909
  // Swallow the error — we're about to recover. `break`
447
910
  // abandons the current generator; since the event is only