mulmoclaude 0.3.0 → 0.4.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 (185) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/index.ts +9 -3
  10. package/server/agent/mcp-tools/index.ts +6 -6
  11. package/server/agent/mcp-tools/x.ts +2 -1
  12. package/server/agent/prompt.ts +187 -26
  13. package/server/agent/resumeFailover.ts +5 -5
  14. package/server/agent/sandboxMounts.ts +3 -3
  15. package/server/api/auth/bearerAuth.ts +3 -3
  16. package/server/api/auth/token.ts +2 -2
  17. package/server/api/routes/agent.ts +21 -3
  18. package/server/api/routes/config.ts +1 -1
  19. package/server/api/routes/files.ts +13 -12
  20. package/server/api/routes/html.ts +2 -2
  21. package/server/api/routes/image.ts +7 -7
  22. package/server/api/routes/mulmo-script.ts +33 -31
  23. package/server/api/routes/pdf.ts +2 -2
  24. package/server/api/routes/plugins.ts +16 -6
  25. package/server/api/routes/roles.ts +2 -2
  26. package/server/api/routes/scheduler.ts +8 -6
  27. package/server/api/routes/schedulerTasks.ts +5 -3
  28. package/server/api/routes/sessions.ts +2 -2
  29. package/server/api/routes/sessionsCursor.ts +4 -4
  30. package/server/api/routes/skills.ts +5 -5
  31. package/server/api/routes/sources.ts +3 -3
  32. package/server/api/routes/todosHandlers.ts +1 -1
  33. package/server/api/routes/todosItemsHandlers.ts +14 -14
  34. package/server/api/routes/wiki.ts +22 -8
  35. package/server/api/sandboxStatus.ts +1 -1
  36. package/server/events/notifications.ts +6 -6
  37. package/server/events/pub-sub/index.ts +3 -3
  38. package/server/events/relay-client.ts +17 -16
  39. package/server/index.ts +40 -46
  40. package/server/system/config.ts +5 -5
  41. package/server/system/credentials.ts +7 -5
  42. package/server/system/env.ts +5 -5
  43. package/server/utils/files/atomic.ts +11 -11
  44. package/server/utils/files/image-store.ts +17 -6
  45. package/server/utils/files/journal-io.ts +2 -2
  46. package/server/utils/files/json.ts +5 -5
  47. package/server/utils/files/markdown-store.ts +4 -4
  48. package/server/utils/files/reference-dirs-io.ts +3 -3
  49. package/server/utils/files/roles-io.ts +4 -4
  50. package/server/utils/files/safe.ts +14 -14
  51. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  52. package/server/utils/files/spreadsheet-store.ts +5 -5
  53. package/server/utils/files/workspace-io.ts +12 -12
  54. package/server/utils/gemini.ts +2 -2
  55. package/server/utils/gitignore.ts +9 -9
  56. package/server/utils/json.ts +5 -5
  57. package/server/utils/logBackgroundError.ts +12 -3
  58. package/server/utils/markdown.ts +5 -5
  59. package/server/utils/port.d.mts +6 -0
  60. package/server/utils/port.mjs +48 -0
  61. package/server/utils/request.ts +12 -6
  62. package/server/utils/spawn.ts +1 -1
  63. package/server/utils/types.ts +2 -2
  64. package/server/workspace/chat-index/summarizer.ts +4 -4
  65. package/server/workspace/custom-dirs.ts +5 -5
  66. package/server/workspace/journal/diff.ts +2 -2
  67. package/server/workspace/journal/index.ts +4 -4
  68. package/server/workspace/journal/optimizationPass.ts +2 -2
  69. package/server/workspace/journal/state.ts +6 -6
  70. package/server/workspace/paths.ts +3 -3
  71. package/server/workspace/reference-dirs.ts +3 -3
  72. package/server/workspace/skills/parser.ts +6 -6
  73. package/server/workspace/skills/scheduler.ts +3 -3
  74. package/server/workspace/skills/writer.ts +3 -3
  75. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  76. package/server/workspace/sources/fetchers/rss.ts +5 -5
  77. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  78. package/server/workspace/sources/interests.ts +3 -3
  79. package/server/workspace/sources/paths.ts +6 -6
  80. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  81. package/server/workspace/sources/pipeline/index.ts +2 -7
  82. package/server/workspace/sources/pipeline/notify.ts +3 -3
  83. package/server/workspace/sources/pipeline/plan.ts +11 -9
  84. package/server/workspace/sources/pipeline/write.ts +5 -5
  85. package/server/workspace/sources/rateLimiter.ts +1 -1
  86. package/server/workspace/sources/sourceState.ts +9 -4
  87. package/server/workspace/sources/types.ts +9 -0
  88. package/server/workspace/sources/urls.ts +1 -1
  89. package/server/workspace/tool-trace/classify.ts +4 -4
  90. package/server/workspace/workspace.ts +7 -7
  91. package/src/App.vue +286 -112
  92. package/src/components/CanvasViewToggle.vue +10 -7
  93. package/src/components/ChatInput.vue +60 -26
  94. package/src/components/FileContentHeader.vue +7 -4
  95. package/src/components/FileContentRenderer.vue +20 -6
  96. package/src/components/FileTree.vue +6 -3
  97. package/src/components/FileTreePane.vue +11 -8
  98. package/src/components/FilesView.vue +5 -3
  99. package/src/components/LockStatusPopup.vue +15 -12
  100. package/src/components/NotificationBell.vue +14 -5
  101. package/src/components/NotificationToast.vue +4 -1
  102. package/src/components/PluginLauncher.vue +19 -56
  103. package/src/components/RightSidebar.vue +13 -10
  104. package/src/components/SessionHistoryPanel.vue +33 -29
  105. package/src/components/SessionTabBar.vue +8 -10
  106. package/src/components/SettingsMcpTab.vue +43 -30
  107. package/src/components/SettingsModal.vue +21 -19
  108. package/src/components/SettingsReferenceDirsTab.vue +29 -24
  109. package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
  110. package/src/components/SidebarHeader.vue +25 -4
  111. package/src/components/StackView.vue +4 -1
  112. package/src/components/SuggestionsPanel.vue +5 -2
  113. package/src/components/TodoExplorer.vue +26 -15
  114. package/src/components/ToolResultsPanel.vue +27 -13
  115. package/src/components/todo/TodoAddDialog.vue +17 -12
  116. package/src/components/todo/TodoEditDialog.vue +7 -2
  117. package/src/components/todo/TodoEditPanel.vue +15 -10
  118. package/src/components/todo/TodoKanbanView.vue +10 -5
  119. package/src/components/todo/TodoListView.vue +5 -2
  120. package/src/components/todo/TodoTableView.vue +5 -2
  121. package/src/composables/useAppApi.ts +9 -0
  122. package/src/composables/useDynamicFavicon.ts +172 -37
  123. package/src/composables/useEventListeners.ts +7 -8
  124. package/src/composables/useFaviconState.ts +13 -2
  125. package/src/composables/useFileSelection.ts +24 -6
  126. package/src/composables/useLayoutMode.ts +32 -0
  127. package/src/composables/useSessionHistory.ts +7 -17
  128. package/src/composables/useViewLayout.ts +20 -34
  129. package/src/lang/de.ts +536 -0
  130. package/src/lang/en.ts +558 -0
  131. package/src/lang/es.ts +543 -0
  132. package/src/lang/fr.ts +536 -0
  133. package/src/lang/ja.ts +536 -0
  134. package/src/lang/ko.ts +540 -0
  135. package/src/lang/pt-BR.ts +534 -0
  136. package/src/lang/zh.ts +537 -0
  137. package/src/lib/vue-i18n.ts +97 -0
  138. package/src/main.ts +2 -0
  139. package/src/plugins/canvas/View.vue +102 -186
  140. package/src/plugins/canvas/definition.ts +0 -8
  141. package/src/plugins/chart/Preview.vue +1 -1
  142. package/src/plugins/chart/View.vue +9 -4
  143. package/src/plugins/manageRoles/Preview.vue +4 -1
  144. package/src/plugins/manageRoles/View.vue +59 -43
  145. package/src/plugins/manageSkills/Preview.vue +8 -3
  146. package/src/plugins/manageSkills/View.vue +26 -22
  147. package/src/plugins/manageSource/Preview.vue +1 -1
  148. package/src/plugins/manageSource/View.vue +73 -52
  149. package/src/plugins/markdown/Preview.vue +1 -1
  150. package/src/plugins/markdown/View.vue +24 -34
  151. package/src/plugins/presentHtml/Preview.vue +1 -1
  152. package/src/plugins/presentHtml/View.vue +7 -4
  153. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  154. package/src/plugins/presentMulmoScript/View.vue +36 -26
  155. package/src/plugins/scheduler/Preview.vue +7 -4
  156. package/src/plugins/scheduler/TasksTab.vue +53 -24
  157. package/src/plugins/scheduler/View.vue +28 -19
  158. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  159. package/src/plugins/spreadsheet/Preview.vue +8 -3
  160. package/src/plugins/spreadsheet/View.vue +21 -12
  161. package/src/plugins/textResponse/Preview.vue +15 -58
  162. package/src/plugins/textResponse/View.vue +27 -7
  163. package/src/plugins/todo/Preview.vue +11 -6
  164. package/src/plugins/todo/View.vue +27 -13
  165. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  166. package/src/plugins/ui-image/ImageView.vue +7 -4
  167. package/src/plugins/wiki/Preview.vue +5 -2
  168. package/src/plugins/wiki/View.vue +202 -81
  169. package/src/plugins/wiki/route.ts +112 -0
  170. package/src/router/guards.ts +42 -24
  171. package/src/router/index.ts +41 -26
  172. package/src/types/vue-i18n.d.ts +20 -0
  173. package/src/utils/agent/request.ts +19 -0
  174. package/src/utils/canvas/layoutMode.ts +26 -0
  175. package/src/utils/image/cacheBust.ts +16 -0
  176. package/src/utils/image/resolve.ts +16 -0
  177. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  178. package/src/vite-env.d.ts +9 -0
  179. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  180. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  181. package/client/assets/index-Bm70FDU2.css +0 -1
  182. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  183. package/src/composables/useCanvasViewMode.ts +0 -121
  184. package/src/utils/canvas/viewMode.ts +0 -46
  185. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,10 +1,11 @@
1
1
  import { Router, Request, Response } from "express";
2
- import fs from "fs";
2
+ import { ReadStream, Stats, createReadStream, readFileSync, realpathSync } from "fs";
3
3
  import path from "path";
4
4
  import { workspacePath } from "../../workspace/workspace.js";
5
5
  import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
6
6
  import { errorMessage } from "../../utils/errors.js";
7
7
  import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
8
+ import { getOptionalStringQuery } from "../../utils/request.js";
8
9
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
9
10
  import { GitignoreFilter } from "../../utils/gitignore.js";
10
11
  import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js";
@@ -187,7 +188,7 @@ export function classify(filename: string): ContentKind {
187
188
  // Cached realpath of the workspace. Computed once at module load so
188
189
  // every request avoids the syscall. resolveWithinRoot needs an
189
190
  // already-realpath'd root.
190
- const workspaceReal = fs.realpathSync(workspacePath);
191
+ const workspaceReal = realpathSync(workspacePath);
191
192
 
192
193
  // Wraps the shared resolveWithinRoot helper with the additional
193
194
  // hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
@@ -235,7 +236,7 @@ function resolveRefPath(prefixedPath: string): string | null {
235
236
 
236
237
  let rootReal: string;
237
238
  try {
238
- rootReal = fs.realpathSync(entry.hostPath);
239
+ rootReal = realpathSync(entry.hostPath);
239
240
  } catch {
240
241
  return null;
241
242
  }
@@ -276,7 +277,7 @@ export function parseRange(header: string, size: number): ByteRange | null {
276
277
  // RFC 7233 §2.1: "A Range request on a representation whose current
277
278
  // length is 0 cannot be satisfied". We also need this guard at the
278
279
  // top because the naive suffix-range math below produces `end = -1`
279
- // for zero-byte files, which then crashes `fs.createReadStream`
280
+ // for zero-byte files, which then crashes `createReadStream`
280
281
  // with `ERR_OUT_OF_RANGE`.
281
282
  if (size <= 0) return null;
282
283
  const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
@@ -325,7 +326,7 @@ function applyRawSecurityHeaders(res: Response): void {
325
326
  // If the read stream errors mid-flight (file deleted, disk error,
326
327
  // permissions changed), surface a clean failure to the client instead
327
328
  // of leaving the connection hanging.
328
- function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorResponse>): void {
329
+ function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>): void {
329
330
  stream.on("error", (err) => {
330
331
  if (res.headersSent) {
331
332
  res.destroy(err);
@@ -340,7 +341,7 @@ function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorRespons
340
341
  // the same security filters as the original sync implementation
341
342
  // (hidden dirs, sensitive files, symlinks all rejected) and the same
342
343
  // ordering (dirs before files, alphabetical within type). Uses
343
- // `fs.promises` throughout so the walk never blocks the event loop,
344
+ // `promises` throughout so the walk never blocks the event loop,
344
345
  // and fans out each directory's children in parallel via
345
346
  // `Promise.all`.
346
347
  //
@@ -493,7 +494,7 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
493
494
  // (no recursion) so the client can render the tree incrementally.
494
495
  // `path` is optional; empty / missing = workspace root.
495
496
  router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<TreeNode | ErrorResponse>) => {
496
- const relPath = typeof req.query.path === "string" ? req.query.path : "";
497
+ const relPath = getOptionalStringQuery(req, "path") ?? "";
497
498
 
498
499
  // Reference directory branch — resolve against the registered ref dir
499
500
  if (isRefPath(relPath)) {
@@ -567,8 +568,8 @@ interface PathQuery {
567
568
  function resolveAndStatFile<T>(
568
569
  req: Request<object, unknown, unknown, PathQuery>,
569
570
  res: Response<T | ErrorResponse>,
570
- ): { relPath: string; absPath: string; stat: fs.Stats } | null {
571
- const relPath = typeof req.query.path === "string" ? req.query.path : "";
571
+ ): { relPath: string; absPath: string; stat: Stats } | null {
572
+ const relPath = getOptionalStringQuery(req, "path") ?? "";
572
573
  if (!relPath) {
573
574
  badRequest(res, "path required");
574
575
  return null;
@@ -668,7 +669,7 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
668
669
  }
669
670
  let content: string;
670
671
  try {
671
- content = fs.readFileSync(absPath, "utf-8");
672
+ content = readFileSync(absPath, "utf-8");
672
673
  } catch (err) {
673
674
  res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
674
675
  return;
@@ -779,12 +780,12 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
779
780
  res.status(206);
780
781
  res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
781
782
  res.setHeader("Content-Length", String(range.end - range.start + 1));
782
- pipeWithErrorHandling(fs.createReadStream(absPath, { start: range.start, end: range.end }), res);
783
+ pipeWithErrorHandling(createReadStream(absPath, { start: range.start, end: range.end }), res);
783
784
  return;
784
785
  }
785
786
 
786
787
  res.setHeader("Content-Length", String(stat.size));
787
- pipeWithErrorHandling(fs.createReadStream(absPath), res);
788
+ pipeWithErrorHandling(createReadStream(absPath), res);
788
789
  });
789
790
 
790
791
  // ── Reference directory roots ───────────────────────────────────
@@ -7,8 +7,8 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
7
7
  const router = Router();
8
8
 
9
9
  async function callGemini(prompt: string): Promise<string> {
10
- const ai = getGeminiClient();
11
- const response = await ai.models.generateContent({
10
+ const client = getGeminiClient();
11
+ const response = await client.models.generateContent({
12
12
  model: "gemini-2.0-flash",
13
13
  contents: [{ text: prompt }],
14
14
  });
@@ -4,7 +4,7 @@ import { getSessionImageData } from "../../events/session-store/index.js";
4
4
  import { generateGeminiImageContent, generateGeminiImageFromPrompt } from "../../utils/gemini.js";
5
5
  import { errorMessage } from "../../utils/errors.js";
6
6
  import { badRequest, serverError } from "../../utils/httpError.js";
7
- import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath } from "../../utils/files/image-store.js";
7
+ import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath, imagePathFromFilename } from "../../utils/files/image-store.js";
8
8
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
9
9
 
10
10
  const router = Router();
@@ -148,14 +148,14 @@ router.post(API_ROUTES.image.upload, async (req: Request<object, unknown, Canvas
148
148
  router.put(
149
149
  API_ROUTES.image.update,
150
150
  async (req: Request<{ filename: string }, unknown, CanvasImageBody>, res: Response<CanvasImageResponse | CanvasImageError>) => {
151
- const relativePath = `images/${req.params.filename}`;
152
- const { imageData } = req.body;
153
- if (!imageData || !relativePath) {
154
- badRequest(res, "imageData and path are required");
151
+ const relativePath = imagePathFromFilename(req.params.filename);
152
+ if (!relativePath) {
153
+ badRequest(res, "invalid image filename");
155
154
  return;
156
155
  }
157
- if (!isImagePath(relativePath)) {
158
- badRequest(res, "invalid image path");
156
+ const { imageData } = req.body;
157
+ if (!imageData) {
158
+ badRequest(res, "imageData is required");
159
159
  return;
160
160
  }
161
161
  const base64 = stripDataUri(imageData);
@@ -1,8 +1,9 @@
1
1
  import { Router, Request, Response } from "express";
2
- import fs from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
5
5
  import { stripDataUri } from "../../utils/files/image-store.js";
6
+ import { writeJsonAtomic } from "../../utils/files/json.js";
6
7
  import {
7
8
  getFileObject,
8
9
  initializeContextFromFiles,
@@ -26,6 +27,7 @@ import { slugify } from "../../utils/slug.js";
26
27
  import { resolveWithinRoot } from "../../utils/files/safe.js";
27
28
  import { errorMessage } from "../../utils/errors.js";
28
29
  import { badRequest, notFound, serverError } from "../../utils/httpError.js";
30
+ import { getOptionalStringQuery } from "../../utils/request.js";
29
31
  import { log } from "../../system/logger/index.js";
30
32
  import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
31
33
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
@@ -54,8 +56,8 @@ let storiesRealCache: string | null = null;
54
56
  function ensureStoriesReal(): string | null {
55
57
  if (storiesRealCache) return storiesRealCache;
56
58
  try {
57
- fs.mkdirSync(storiesDir, { recursive: true });
58
- storiesRealCache = fs.realpathSync(storiesDir);
59
+ mkdirSync(storiesDir, { recursive: true });
60
+ storiesRealCache = realpathSync(storiesDir);
59
61
  return storiesRealCache;
60
62
  } catch {
61
63
  return null;
@@ -96,7 +98,7 @@ interface FilePathQuery {
96
98
  filePath?: string;
97
99
  }
98
100
 
99
- router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
101
+ router.post(API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
100
102
  const { script, filename } = req.body;
101
103
 
102
104
  if (!script || !Array.isArray(script.beats)) {
@@ -104,14 +106,14 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
104
106
  return;
105
107
  }
106
108
 
107
- fs.mkdirSync(storiesDir, { recursive: true });
109
+ mkdirSync(storiesDir, { recursive: true });
108
110
 
109
111
  const title = script.title || "untitled";
110
112
  const slug = filename ? filename.replace(/\.json$/, "") : slugify(title);
111
113
  const fname = `${slug}-${Date.now()}.json`;
112
114
  const filePath = path.join(storiesDir, fname);
113
115
 
114
- fs.writeFileSync(filePath, JSON.stringify(script, null, 2));
116
+ await writeJsonAtomic(filePath, script);
115
117
 
116
118
  res.json({
117
119
  data: { script, filePath: `stories/${fname}` },
@@ -120,7 +122,7 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
120
122
  });
121
123
  });
122
124
 
123
- router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unknown>, res: Response) => {
125
+ router.post(API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
124
126
  const validation = validateUpdateBeatBody(req.body);
125
127
  if (!validation.ok) {
126
128
  badRequest(res, validation.error);
@@ -131,7 +133,7 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
131
133
  const absoluteFilePath = resolveStoryPath(filePath, res);
132
134
  if (!absoluteFilePath) return;
133
135
 
134
- const script: MulmoScript = JSON.parse(fs.readFileSync(absoluteFilePath, "utf-8"));
136
+ const script: MulmoScript = JSON.parse(readFileSync(absoluteFilePath, "utf-8"));
135
137
 
136
138
  if (!Array.isArray(script.beats) || beatIndex >= script.beats.length) {
137
139
  badRequest(res, "Invalid beatIndex");
@@ -139,12 +141,12 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
139
141
  }
140
142
 
141
143
  script.beats[beatIndex] = beat as MulmoBeat;
142
- fs.writeFileSync(absoluteFilePath, JSON.stringify(script, null, 2));
144
+ await writeJsonAtomic(absoluteFilePath, script);
143
145
 
144
146
  res.json({ ok: true });
145
147
  });
146
148
 
147
- router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, unknown>, res: Response) => {
149
+ router.post(API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
148
150
  const validation = validateUpdateScriptBody(req.body);
149
151
  if (!validation.ok) {
150
152
  badRequest(res, validation.error);
@@ -155,7 +157,7 @@ router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, u
155
157
  const absoluteFilePath = resolveStoryPath(filePath, res);
156
158
  if (!absoluteFilePath) return;
157
159
 
158
- fs.writeFileSync(absoluteFilePath, JSON.stringify(updatedScript, null, 2));
160
+ await writeJsonAtomic(absoluteFilePath, updatedScript);
159
161
  res.json({ ok: true });
160
162
  });
161
163
 
@@ -170,7 +172,7 @@ router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatIma
170
172
 
171
173
  await withStoryContext(res, filePath, {}, async ({ context }) => {
172
174
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
173
- if (!fs.existsSync(imagePath)) {
175
+ if (!existsSync(imagePath)) {
174
176
  res.json({ image: null });
175
177
  return;
176
178
  }
@@ -197,13 +199,13 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
197
199
  }
198
200
 
199
201
  const outputPath = movieFilePath(context);
200
- if (!fs.existsSync(outputPath)) {
202
+ if (!existsSync(outputPath)) {
201
203
  res.json({ moviePath: null });
202
204
  return;
203
205
  }
204
206
 
205
- const movieMtime = fs.statSync(outputPath).mtimeMs;
206
- const sourceMtime = fs.statSync(absoluteFilePath).mtimeMs;
207
+ const movieMtime = statSync(outputPath).mtimeMs;
208
+ const sourceMtime = statSync(absoluteFilePath).mtimeMs;
207
209
  if (movieMtime < sourceMtime) {
208
210
  res.json({ moviePath: null });
209
211
  return;
@@ -216,7 +218,7 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
216
218
  });
217
219
 
218
220
  function fileToDataUri(filePath: string, mimeType: string): string {
219
- const data = fs.readFileSync(filePath);
221
+ const data = readFileSync(filePath);
220
222
  return `data:${mimeType};base64,${data.toString("base64")}`;
221
223
  }
222
224
 
@@ -257,7 +259,7 @@ function resolveStoryPath(filePath: string, res: Response): string | null {
257
259
  const resolved = resolveWithinRoot(storiesReal, relFromStories);
258
260
  if (!resolved) {
259
261
  const candidate = path.resolve(storiesReal, relFromStories);
260
- if (!fs.existsSync(candidate)) {
262
+ if (!existsSync(candidate)) {
261
263
  notFound(res, `File not found: ${filePath}`);
262
264
  } else {
263
265
  badRequest(res, "Invalid filePath");
@@ -373,12 +375,12 @@ router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAud
373
375
  filePath,
374
376
  {
375
377
  operation: "beat-audio",
376
- onContextMissing: (r) => r.json({ audio: null }),
378
+ onContextMissing: (response) => response.json({ audio: null }),
377
379
  },
378
380
  async ({ context }) => {
379
381
  const beat = context.studio.script.beats[beatIndex];
380
382
  const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
381
- if (!audioPath || !fs.existsSync(audioPath)) {
383
+ if (!audioPath || !existsSync(audioPath)) {
382
384
  res.json({ audio: null });
383
385
  return;
384
386
  }
@@ -422,7 +424,7 @@ router.post(
422
424
  const beat = context.studio.script.beats[beatIndex];
423
425
  const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
424
426
 
425
- if (!audioPath || !fs.existsSync(audioPath)) {
427
+ if (!audioPath || !existsSync(audioPath)) {
426
428
  // Logic-flow failure (not an exception) — emit a targeted
427
429
  // log. Don't write raw `beat.text` into persistent logs —
428
430
  // it's free-form user content and can contain sensitive
@@ -430,7 +432,7 @@ router.post(
430
432
  log.error("generate-beat-audio", "audio was not generated", {
431
433
  beatIndex,
432
434
  audioPath,
433
- exists: audioPath ? fs.existsSync(audioPath) : false,
435
+ exists: audioPath ? existsSync(audioPath) : false,
434
436
  beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
435
437
  audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile),
436
438
  });
@@ -475,7 +477,7 @@ router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, objec
475
477
  });
476
478
 
477
479
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
478
- if (!fs.existsSync(imagePath)) {
480
+ if (!existsSync(imagePath)) {
479
481
  genError = "Image was not generated";
480
482
  serverError(res, genError);
481
483
  return;
@@ -542,7 +544,7 @@ router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, ob
542
544
  await movie(audioContext);
543
545
 
544
546
  const outputPath = movieFilePath(audioContext);
545
- if (!fs.existsSync(outputPath)) {
547
+ if (!existsSync(outputPath)) {
546
548
  genError = "Movie was not generated";
547
549
  send({ type: "error", message: genError });
548
550
  res.end();
@@ -594,7 +596,7 @@ router.get(
594
596
 
595
597
  await withStoryContext(res, filePath, {}, async ({ context }) => {
596
598
  const imagePath = getReferenceImagePath(context, key, "png");
597
- if (!fs.existsSync(imagePath)) {
599
+ if (!existsSync(imagePath)) {
598
600
  res.json({ image: null });
599
601
  return;
600
602
  }
@@ -613,10 +615,10 @@ router.post(API_ROUTES.mulmoScript.uploadBeatImage, async (req: Request<object,
613
615
 
614
616
  await withStoryContext(res, filePath, {}, async ({ context }) => {
615
617
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
616
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
618
+ mkdirSync(path.dirname(imagePath), { recursive: true });
617
619
 
618
620
  const base64 = stripDataUri(imageData);
619
- fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
621
+ writeFileSync(imagePath, Buffer.from(base64, "base64"));
620
622
 
621
623
  res.json({ image: fileToDataUri(imagePath, "image/png") });
622
624
  });
@@ -647,7 +649,7 @@ router.post(
647
649
 
648
650
  const index = Object.keys(images).indexOf(key);
649
651
  const imagePath = getReferenceImagePath(context, key, "png");
650
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
652
+ mkdirSync(path.dirname(imagePath), { recursive: true });
651
653
 
652
654
  await generateReferenceImage({
653
655
  context,
@@ -656,7 +658,7 @@ router.post(
656
658
  image: imageEntry as MulmoImagePromptMedia,
657
659
  force,
658
660
  });
659
- if (!fs.existsSync(imagePath)) {
661
+ if (!existsSync(imagePath)) {
660
662
  genError = "Character image was not generated";
661
663
  serverError(res, genError);
662
664
  return;
@@ -685,10 +687,10 @@ router.post(
685
687
 
686
688
  await withStoryContext(res, filePath, {}, async ({ context }) => {
687
689
  const imagePath = getReferenceImagePath(context, key, "png");
688
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
690
+ mkdirSync(path.dirname(imagePath), { recursive: true });
689
691
 
690
692
  const base64 = stripDataUri(imageData);
691
- fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
693
+ writeFileSync(imagePath, Buffer.from(base64, "base64"));
692
694
 
693
695
  res.json({ image: fileToDataUri(imagePath, "image/png") });
694
696
  });
@@ -696,7 +698,7 @@ router.post(
696
698
  );
697
699
 
698
700
  router.get(API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
699
- const moviePath = typeof req.query.moviePath === "string" ? req.query.moviePath : undefined;
701
+ const moviePath = getOptionalStringQuery(req, "moviePath");
700
702
 
701
703
  if (!moviePath) {
702
704
  badRequest(res, "moviePath is required");
@@ -1,4 +1,4 @@
1
- import fs from "fs";
1
+ import { realpathSync } from "fs";
2
2
  import path from "path";
3
3
  import { Router, Request, Response } from "express";
4
4
  import { marked } from "marked";
@@ -56,7 +56,7 @@ const MIME_BY_EXT: Record<string, string> = {
56
56
  // Realpath of the workspace, resolved once at module load. Used to
57
57
  // validate that image paths resolved relative to markdowns/ stay
58
58
  // inside the workspace after symlink resolution.
59
- const workspaceReal = fs.realpathSync(resolveWorkspacePath(""));
59
+ const workspaceReal = realpathSync(resolveWorkspacePath(""));
60
60
 
61
61
  /**
62
62
  * Inline local images as base64 data URIs so Puppeteer can render them.
@@ -80,10 +80,10 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
80
80
  }
81
81
 
82
82
  const results = await Promise.all(
83
- matches.map(async (m) => ({
84
- full: m[0],
85
- prompt: m[1],
86
- url: geminiOk ? await generateImageFile(m[1]) : null,
83
+ matches.map(async (match) => ({
84
+ full: match[0],
85
+ prompt: match[1],
86
+ url: geminiOk ? await generateImageFile(match[1]) : null,
87
87
  })),
88
88
  );
89
89
 
@@ -91,7 +91,7 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
91
91
  // success rate even when most calls go through. The per-call
92
92
  // error already lands at warn from generateImageFile's catch.
93
93
  if (geminiOk) {
94
- const failed = results.filter((r) => !r.url).length;
94
+ const failed = results.filter((result) => !result.url).length;
95
95
  if (failed > 0) {
96
96
  log.warn("present-document", "image generation had failures", {
97
97
  failed,
@@ -261,10 +261,20 @@ router.post(
261
261
  wrapPluginExecute((req) => executeForm(null as never, req.body)),
262
262
  );
263
263
 
264
+ // 1×1 transparent PNG. Used as a placeholder so the canvas tool
265
+ // result can carry a stable file path from the moment the canvas
266
+ // is opened — client autosaves PUT-overwrite this same file, so the
267
+ // drawing survives page reload with zero client→server sync.
268
+ const BLANK_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
269
+
264
270
  // openCanvas — drawing canvas
265
271
  router.post(
266
272
  API_ROUTES.plugins.canvas,
267
- wrapPluginExecute(() => executeOpenCanvas()),
273
+ wrapPluginExecute(async () => {
274
+ const imagePath = await saveImage(BLANK_PNG_BASE64);
275
+ const base = await executeOpenCanvas();
276
+ return { ...base, data: { imageData: imagePath, prompt: "" } };
277
+ }),
268
278
  );
269
279
 
270
280
  // present3d — 3D visualization
@@ -7,7 +7,7 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
7
7
  import { EVENT_TYPES } from "../../../src/types/events.js";
8
8
  import { roleExists, deleteRole, saveRole } from "../../utils/files/roles-io.js";
9
9
 
10
- const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((r) => r.id));
10
+ const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((role) => role.id));
11
11
 
12
12
  const router = Router();
13
13
 
@@ -98,7 +98,7 @@ function saveRoleResult(input: ManageRolesInput, sessionId: string): Record<stri
98
98
  const pluginsToSave = role.availablePlugins ?? [];
99
99
  const roleToSave = {
100
100
  ...role,
101
- availablePlugins: pluginsToSave.filter((p) => p !== "switchRole"),
101
+ availablePlugins: pluginsToSave.filter((plugin) => plugin !== "switchRole"),
102
102
  };
103
103
 
104
104
  saveRole(role.id, roleToSave);
@@ -9,6 +9,8 @@ import { saveUserTasks } from "../../utils/files/user-tasks-io.js";
9
9
  import { startChat } from "./agent.js";
10
10
  import { log } from "../../system/logger/index.js";
11
11
  import { SCHEDULER_ACTIONS, TASK_ACTIONS } from "../../../src/config/schedulerActions.js";
12
+ import { badRequest, notFound, serverError } from "../../utils/httpError.js";
13
+ import { errorMessage } from "../../utils/errors.js";
12
14
 
13
15
  const router = Router();
14
16
 
@@ -77,7 +79,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
77
79
  if (action === SCHEDULER_ACTIONS.createTask) {
78
80
  const result = validateAndCreate(input);
79
81
  if (result.kind === "error") {
80
- res.status(400).json({ error: result.error });
82
+ badRequest(res, result.error);
81
83
  return;
82
84
  }
83
85
  const tasks = loadUserTasks();
@@ -97,7 +99,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
97
99
  const tasks = loadUserTasks();
98
100
  const idx = tasks.findIndex((task) => task.id === taskId);
99
101
  if (idx === -1) {
100
- res.status(404).json({ error: `task not found: ${taskId}` });
102
+ notFound(res, `task not found: ${taskId}`);
101
103
  return;
102
104
  }
103
105
  const name = tasks[idx].name;
@@ -117,7 +119,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
117
119
  const tasks = loadUserTasks();
118
120
  const task = tasks.find((candidate) => candidate.id === taskId);
119
121
  if (!task) {
120
- res.status(404).json({ error: `task not found: ${taskId}` });
122
+ notFound(res, `task not found: ${taskId}`);
121
123
  return;
122
124
  }
123
125
  const chatSessionId = crypto.randomUUID();
@@ -143,10 +145,10 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
143
145
  return;
144
146
  }
145
147
 
146
- res.status(400).json({ error: `unknown task action: ${action}` });
148
+ badRequest(res, `unknown task action: ${action}`);
147
149
  } catch (err) {
148
- log.error("scheduler", "task action failed", { error: String(err) });
149
- res.status(500).json({ error: "Internal server error" });
150
+ log.error("scheduler", "task action failed", { error: errorMessage(err) });
151
+ serverError(res, "Internal server error");
150
152
  }
151
153
  }
152
154
 
@@ -15,6 +15,7 @@ import { SESSION_ORIGINS } from "../../../src/types/session.js";
15
15
  import { loadUserTasks, validateAndCreate, applyUpdate, withUserTaskLock } from "../../workspace/skills/user-tasks.js";
16
16
  import { badRequest, notFound, serverError } from "../../utils/httpError.js";
17
17
  import { errorMessage } from "../../utils/errors.js";
18
+ import { getOptionalStringQuery } from "../../utils/request.js";
18
19
  import { log } from "../../system/logger/index.js";
19
20
  import { startChat } from "./agent.js";
20
21
 
@@ -150,11 +151,12 @@ interface LogQuery {
150
151
 
151
152
  router.get(API_ROUTES.scheduler.logs, async (req: Request<object, unknown, object, LogQuery>, res: Response<{ logs: TaskLogEntry[] }>) => {
152
153
  const MAX_LIMIT = 500;
153
- const rawLimit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : undefined;
154
+ const rawLimitStr = getOptionalStringQuery(req, "limit");
155
+ const rawLimit = rawLimitStr ? parseInt(rawLimitStr, 10) : undefined;
154
156
  const limit = Number.isFinite(rawLimit) && rawLimit! > 0 ? Math.min(rawLimit!, MAX_LIMIT) : undefined;
155
157
  const logs = await getSchedulerLogs({
156
- since: typeof req.query.since === "string" ? req.query.since : undefined,
157
- taskId: typeof req.query.taskId === "string" ? req.query.taskId : undefined,
158
+ since: getOptionalStringQuery(req, "since"),
159
+ taskId: getOptionalStringQuery(req, "taskId"),
158
160
  limit,
159
161
  });
160
162
  res.json({ logs });
@@ -1,5 +1,5 @@
1
1
  import { Router, Request, Response } from "express";
2
- import fs from "fs";
2
+ import { realpathSync } from "fs";
3
3
  import { readdir, stat } from "fs/promises";
4
4
  import { readTextSafe } from "../../utils/files/safe.js";
5
5
  import path from "path";
@@ -242,7 +242,7 @@ router.get(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res
242
242
  const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
243
243
  let storiesReal: string;
244
244
  try {
245
- storiesReal = fs.realpathSync(storiesDir);
245
+ storiesReal = realpathSync(storiesDir);
246
246
  } catch {
247
247
  return entry;
248
248
  }
@@ -21,8 +21,8 @@ const CURSOR_PREFIX = "v1:";
21
21
  * fall back to when an incoming cursor is malformed.
22
22
  */
23
23
  export function encodeCursor(changeMs: number): string {
24
- const ms = Number.isFinite(changeMs) && changeMs > 0 ? Math.floor(changeMs) : 0;
25
- return `${CURSOR_PREFIX}${ms}`;
24
+ const floored = Number.isFinite(changeMs) && changeMs > 0 ? Math.floor(changeMs) : 0;
25
+ return `${CURSOR_PREFIX}${floored}`;
26
26
  }
27
27
 
28
28
  /**
@@ -36,8 +36,8 @@ export function encodeCursor(changeMs: number): string {
36
36
  export function parseCursor(raw: unknown): number {
37
37
  if (typeof raw !== "string") return 0;
38
38
  if (!raw.startsWith(CURSOR_PREFIX)) return 0;
39
- const n = Number(raw.slice(CURSOR_PREFIX.length));
40
- return Number.isFinite(n) && n > 0 ? n : 0;
39
+ const parsed = Number(raw.slice(CURSOR_PREFIX.length));
40
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
41
41
  }
42
42
 
43
43
  /**
@@ -55,17 +55,17 @@ interface DeleteSkillResponse {
55
55
  router.get(API_ROUTES.skills.list, async (_req: Request, res: Response<SkillsListResponse>) => {
56
56
  const skills = await discoverSkills({ workspaceRoot: workspacePath });
57
57
  res.json({
58
- skills: skills.map((s) => ({
59
- name: s.name,
60
- description: s.description,
61
- source: s.source,
58
+ skills: skills.map((skill) => ({
59
+ name: skill.name,
60
+ description: skill.description,
61
+ source: skill.source,
62
62
  })),
63
63
  });
64
64
  });
65
65
 
66
66
  router.get(API_ROUTES.skills.detail, async (req: Request<{ name: string }>, res: Response<SkillDetailResponse | ErrorResponse>) => {
67
67
  const skills = await discoverSkills({ workspaceRoot: workspacePath });
68
- const skill = skills.find((s) => s.name === req.params.name);
68
+ const skill = skills.find((candidate) => candidate.name === req.params.name);
69
69
  if (!skill) {
70
70
  notFound(res, `skill not found: ${req.params.name}`);
71
71
  return;
@@ -312,7 +312,7 @@ async function handleRegister(body: ManageSourceBody, res: Response<ManageSource
312
312
  async function handleRemove(body: ManageSourceBody, res: Response<ManageSourceSuccess | ErrorResponse>): Promise<void> {
313
313
  const slug = typeof body.slug === "string" ? body.slug.trim() : "";
314
314
  if (!isValidSlug(slug)) {
315
- res.status(400).json({ error: "slug is required and must be a valid slug" });
315
+ badRequest(res, "slug is required and must be a valid slug");
316
316
  return;
317
317
  }
318
318
  const removed = await deleteSource(workspacePath, slug);
@@ -525,8 +525,8 @@ async function resolveCategories(parsed: ParsedRegisterBody): Promise<{ categori
525
525
 
526
526
  function isHttpUrl(raw: string): boolean {
527
527
  try {
528
- const u = new URL(raw);
529
- return u.protocol === "http:" || u.protocol === "https:";
528
+ const url = new URL(raw);
529
+ return url.protocol === "http:" || url.protocol === "https:";
530
530
  } catch {
531
531
  return false;
532
532
  }
@@ -242,7 +242,7 @@ export function handleRemoveLabel(items: TodoItem[], input: TodosActionInput): T
242
242
 
243
243
  export function handleListLabels(items: TodoItem[]): TodosActionResult {
244
244
  const inventory = listLabelsWithCount(items);
245
- const summary = inventory.map((l) => `${l.label} (${l.count})`).join(", ");
245
+ const summary = inventory.map((entry) => `${entry.label} (${entry.count})`).join(", ");
246
246
  const message = inventory.length === 0 ? "No labels in use" : `${inventory.length} label(s) in use: ${summary}`;
247
247
  return {
248
248
  kind: "success",