mulmoclaude 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,18 +1,26 @@
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";
12
+ import { log } from "../../system/logger/index.js";
13
+ import { previewSnippet } from "../../utils/logPreview.js";
11
14
 
12
15
  const router = Router();
13
16
 
14
17
  const MAX_PREVIEW_BYTES = 1024 * 1024; // 1 MB — text content embedded in JSON
15
- const MAX_RAW_BYTES = 50 * 1024 * 1024; // 50 MB — cap for binary streaming
18
+ const MAX_RAW_BYTES = 50 * 1024 * 1024; // 50 MB — cap for non-media streaming (images/pdf/binary load whole into the browser)
19
+ // Audio/video are streamed via HTTP Range requests (see GET /raw),
20
+ // so the browser never buffers the whole file. Podcasts commonly
21
+ // run 100–300 MB and recorded video can run multi-GB; cap at 4 GB
22
+ // just to keep an obviously-pathological file from being served.
23
+ const MAX_MEDIA_BYTES = 4 * 1024 * 1024 * 1024;
16
24
  const HIDDEN_DIRS = new Set([".git"]);
17
25
 
18
26
  // Files whose basename exactly matches one of these is refused by
@@ -187,7 +195,7 @@ export function classify(filename: string): ContentKind {
187
195
  // Cached realpath of the workspace. Computed once at module load so
188
196
  // every request avoids the syscall. resolveWithinRoot needs an
189
197
  // already-realpath'd root.
190
- const workspaceReal = fs.realpathSync(workspacePath);
198
+ const workspaceReal = realpathSync(workspacePath);
191
199
 
192
200
  // Wraps the shared resolveWithinRoot helper with the additional
193
201
  // hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
@@ -235,7 +243,7 @@ function resolveRefPath(prefixedPath: string): string | null {
235
243
 
236
244
  let rootReal: string;
237
245
  try {
238
- rootReal = fs.realpathSync(entry.hostPath);
246
+ rootReal = realpathSync(entry.hostPath);
239
247
  } catch {
240
248
  return null;
241
249
  }
@@ -276,7 +284,7 @@ export function parseRange(header: string, size: number): ByteRange | null {
276
284
  // RFC 7233 §2.1: "A Range request on a representation whose current
277
285
  // length is 0 cannot be satisfied". We also need this guard at the
278
286
  // top because the naive suffix-range math below produces `end = -1`
279
- // for zero-byte files, which then crashes `fs.createReadStream`
287
+ // for zero-byte files, which then crashes `createReadStream`
280
288
  // with `ERR_OUT_OF_RANGE`.
281
289
  if (size <= 0) return null;
282
290
  const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
@@ -325,7 +333,7 @@ function applyRawSecurityHeaders(res: Response): void {
325
333
  // If the read stream errors mid-flight (file deleted, disk error,
326
334
  // permissions changed), surface a clean failure to the client instead
327
335
  // of leaving the connection hanging.
328
- function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorResponse>): void {
336
+ function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>): void {
329
337
  stream.on("error", (err) => {
330
338
  if (res.headersSent) {
331
339
  res.destroy(err);
@@ -340,7 +348,7 @@ function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorRespons
340
348
  // the same security filters as the original sync implementation
341
349
  // (hidden dirs, sensitive files, symlinks all rejected) and the same
342
350
  // ordering (dirs before files, alphabetical within type). Uses
343
- // `fs.promises` throughout so the walk never blocks the event loop,
351
+ // `promises` throughout so the walk never blocks the event loop,
344
352
  // and fans out each directory's children in parallel via
345
353
  // `Promise.all`.
346
354
  //
@@ -475,6 +483,7 @@ export async function listDirShallow(absPath: string, relPath: string, gitFilter
475
483
  }
476
484
 
477
485
  router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown, object>, res: Response<TreeNode | ErrorResponse>) => {
486
+ log.info("files", "GET tree: start");
478
487
  try {
479
488
  // Start with an empty filter — the workspace root's .gitignore
480
489
  // is for git (excluding github/ from commits), NOT for the
@@ -485,7 +494,8 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
485
494
  const tree = await buildTreeAsync(workspaceReal, "");
486
495
  res.json(tree);
487
496
  } catch (err) {
488
- res.status(500).json({ error: `Failed to read workspace: ${errorMessage(err)}` });
497
+ log.error("files", "GET tree: threw", { error: errorMessage(err) });
498
+ serverError(res, `Failed to read workspace: ${errorMessage(err)}`);
489
499
  }
490
500
  });
491
501
 
@@ -493,17 +503,20 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
493
503
  // (no recursion) so the client can render the tree incrementally.
494
504
  // `path` is optional; empty / missing = workspace root.
495
505
  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 : "";
506
+ const relPath = getOptionalStringQuery(req, "path") ?? "";
507
+ log.info("files", "GET dir: start", { pathPreview: previewSnippet(relPath) });
497
508
 
498
509
  // Reference directory branch — resolve against the registered ref dir
499
510
  if (isRefPath(relPath)) {
500
511
  const absPath = resolveRefPath(relPath);
501
512
  if (!absPath) {
513
+ log.warn("files", "GET dir: ref dir not found", { pathPreview: previewSnippet(relPath) });
502
514
  notFound(res, "Not found");
503
515
  return;
504
516
  }
505
517
  const stat = await statSafeAsync(absPath);
506
518
  if (!stat || !stat.isDirectory()) {
519
+ log.warn("files", "GET dir: ref path missing or not a dir", { pathPreview: previewSnippet(relPath) });
507
520
  notFound(res, "Not found");
508
521
  return;
509
522
  }
@@ -515,15 +528,18 @@ router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, P
515
528
  // Workspace path — existing logic
516
529
  const absPath = resolveSafe(relPath);
517
530
  if (!absPath) {
531
+ log.warn("files", "GET dir: path outside workspace", { pathPreview: previewSnippet(relPath) });
518
532
  notFound(res, "Not found");
519
533
  return;
520
534
  }
521
535
  const stat = await statSafeAsync(absPath);
522
536
  if (!stat) {
537
+ log.warn("files", "GET dir: not found", { pathPreview: previewSnippet(relPath) });
523
538
  notFound(res, "Not found");
524
539
  return;
525
540
  }
526
541
  if (!stat.isDirectory()) {
542
+ log.warn("files", "GET dir: not a directory", { pathPreview: previewSnippet(relPath) });
527
543
  badRequest(res, "path is not a directory");
528
544
  return;
529
545
  }
@@ -541,6 +557,7 @@ router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, P
541
557
  const listing = await listDirShallow(absPath, path.relative(workspaceReal, absPath), filter);
542
558
  res.json(listing);
543
559
  } catch (err) {
560
+ log.error("files", "GET dir: threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
544
561
  serverError(res, `Failed to read directory: ${errorMessage(err)}`);
545
562
  }
546
563
  });
@@ -567,8 +584,8 @@ interface PathQuery {
567
584
  function resolveAndStatFile<T>(
568
585
  req: Request<object, unknown, unknown, PathQuery>,
569
586
  res: Response<T | ErrorResponse>,
570
- ): { relPath: string; absPath: string; stat: fs.Stats } | null {
571
- const relPath = typeof req.query.path === "string" ? req.query.path : "";
587
+ ): { relPath: string; absPath: string; stat: Stats } | null {
588
+ const relPath = getOptionalStringQuery(req, "path") ?? "";
572
589
  if (!relPath) {
573
590
  badRequest(res, "path required");
574
591
  return null;
@@ -623,8 +640,16 @@ function resolveAndStatFile<T>(
623
640
  }
624
641
 
625
642
  router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, PathQuery>, res: Response<FileContentResponse | ErrorResponse>) => {
643
+ const requestedPath = getOptionalStringQuery(req, "path") ?? "";
644
+ log.info("files", "GET content: start", { pathPreview: previewSnippet(requestedPath) });
626
645
  const ctx = resolveAndStatFile(req, res);
627
- if (!ctx) return;
646
+ if (!ctx) {
647
+ // resolveAndStatFile already wrote the 4xx; surface the gate
648
+ // miss so the operator can correlate the user-visible error
649
+ // with a concrete reason in the log without re-running.
650
+ log.warn("files", "GET content: gated by resolve/stat", { pathPreview: previewSnippet(requestedPath) });
651
+ return;
652
+ }
628
653
  const { relPath, absPath, stat } = ctx;
629
654
 
630
655
  const meta = {
@@ -633,10 +658,13 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
633
658
  modifiedMs: stat.mtimeMs,
634
659
  };
635
660
 
636
- // Anything past the binary stream cap is "too-large" regardless of
637
- // type even images/PDFs, since the client would have to fetch
638
- // them via /files/raw which enforces the same limit.
639
- if (stat.size > MAX_RAW_BYTES) {
661
+ const kind = classify(absPath);
662
+ // Audio/video stream via Range requests, so they get the looser
663
+ // MAX_MEDIA_BYTES cap. Everything else (images/PDFs/binary) is
664
+ // loaded whole by the browser and stays at MAX_RAW_BYTES.
665
+ const isStreamingMedia = kind === "audio" || kind === "video";
666
+ const sizeCap = isStreamingMedia ? MAX_MEDIA_BYTES : MAX_RAW_BYTES;
667
+ if (stat.size > sizeCap) {
640
668
  res.json({
641
669
  kind: "too-large",
642
670
  ...meta,
@@ -645,7 +673,6 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
645
673
  return;
646
674
  }
647
675
 
648
- const kind = classify(absPath);
649
676
  if (kind === "image" || kind === "pdf" || kind === "audio" || kind === "video") {
650
677
  res.json({ kind, ...meta });
651
678
  return;
@@ -668,11 +695,13 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
668
695
  }
669
696
  let content: string;
670
697
  try {
671
- content = fs.readFileSync(absPath, "utf-8");
698
+ content = readFileSync(absPath, "utf-8");
672
699
  } catch (err) {
673
- res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
700
+ log.error("files", "GET content: read threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
701
+ serverError(res, `Failed to read file: ${errorMessage(err)}`);
674
702
  return;
675
703
  }
704
+ log.info("files", "GET content: ok", { pathPreview: previewSnippet(relPath), bytes: stat.size });
676
705
  res.json({ kind: "text", ...meta, content });
677
706
  });
678
707
 
@@ -682,15 +711,22 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
682
711
  // The file must already exist; creating new files is out of scope.
683
712
  router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteContentRequest>, res: Response<WriteContentResponse | ErrorResponse>) => {
684
713
  const { path: relPathRaw, content: contentRaw } = req.body ?? {};
714
+ log.info("files", "PUT content: start", {
715
+ pathPreview: typeof relPathRaw === "string" ? previewSnippet(relPathRaw) : undefined,
716
+ bytes: typeof contentRaw === "string" ? Buffer.byteLength(contentRaw, "utf-8") : undefined,
717
+ });
685
718
  if (typeof relPathRaw !== "string" || relPathRaw.length === 0) {
719
+ log.warn("files", "PUT content: missing path");
686
720
  badRequest(res, "path required");
687
721
  return;
688
722
  }
689
723
  if (typeof contentRaw !== "string") {
724
+ log.warn("files", "PUT content: missing content", { pathPreview: previewSnippet(relPathRaw) });
690
725
  badRequest(res, "content required");
691
726
  return;
692
727
  }
693
728
  if (Buffer.byteLength(contentRaw, "utf-8") > MAX_PREVIEW_BYTES) {
729
+ log.warn("files", "PUT content: too large", { pathPreview: previewSnippet(relPathRaw), bytes: Buffer.byteLength(contentRaw, "utf-8") });
694
730
  badRequest(res, `content exceeds ${MAX_PREVIEW_BYTES} byte limit`);
695
731
  return;
696
732
  }
@@ -729,10 +765,15 @@ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteC
729
765
  // other's staging file and race through the rename.
730
766
  await writeFileAtomic(absPath, contentRaw, { uniqueTmp: true });
731
767
  } catch (err) {
768
+ log.error("files", "PUT content: write threw", { pathPreview: previewSnippet(relPathRaw), error: errorMessage(err) });
732
769
  serverError(res, `Failed to write file: ${errorMessage(err)}`);
733
770
  return;
734
771
  }
735
772
  const fresh = await statSafeAsync(absPath);
773
+ log.info("files", "PUT content: ok", {
774
+ pathPreview: previewSnippet(relPathRaw),
775
+ bytes: fresh?.size ?? Buffer.byteLength(contentRaw, "utf-8"),
776
+ });
736
777
  res.json({
737
778
  path: relPathRaw,
738
779
  size: fresh?.size ?? Buffer.byteLength(contentRaw, "utf-8"),
@@ -741,12 +782,20 @@ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteC
741
782
  });
742
783
 
743
784
  router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQuery>, res: Response<ErrorResponse>) => {
785
+ const requestedPath = getOptionalStringQuery(req, "path") ?? "";
786
+ log.info("files", "GET raw: start", { pathPreview: previewSnippet(requestedPath) });
744
787
  const ctx = resolveAndStatFile(req, res);
745
- if (!ctx) return;
788
+ if (!ctx) {
789
+ log.warn("files", "GET raw: gated by resolve/stat", { pathPreview: previewSnippet(requestedPath) });
790
+ return;
791
+ }
746
792
  const { absPath, stat } = ctx;
747
793
 
748
- if (stat.size > MAX_RAW_BYTES) {
749
- sendError(res, 413, `File too large to stream (${stat.size} bytes, limit ${MAX_RAW_BYTES})`);
794
+ const rawKind = classify(absPath);
795
+ const rawSizeCap = rawKind === "audio" || rawKind === "video" ? MAX_MEDIA_BYTES : MAX_RAW_BYTES;
796
+ if (stat.size > rawSizeCap) {
797
+ log.warn("files", "GET raw: too large", { pathPreview: previewSnippet(requestedPath), bytes: stat.size, cap: rawSizeCap });
798
+ sendError(res, 413, `File too large to stream (${stat.size} bytes, limit ${rawSizeCap})`);
750
799
  return;
751
800
  }
752
801
  const ext = path.extname(absPath).toLowerCase();
@@ -779,12 +828,12 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
779
828
  res.status(206);
780
829
  res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
781
830
  res.setHeader("Content-Length", String(range.end - range.start + 1));
782
- pipeWithErrorHandling(fs.createReadStream(absPath, { start: range.start, end: range.end }), res);
831
+ pipeWithErrorHandling(createReadStream(absPath, { start: range.start, end: range.end }), res);
783
832
  return;
784
833
  }
785
834
 
786
835
  res.setHeader("Content-Length", String(stat.size));
787
- pipeWithErrorHandling(fs.createReadStream(absPath), res);
836
+ pipeWithErrorHandling(createReadStream(absPath), res);
788
837
  });
789
838
 
790
839
  // ── Reference directory roots ───────────────────────────────────
@@ -794,6 +843,7 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
794
843
  // prefix so subsequent /dir and /content requests route correctly.
795
844
 
796
845
  router.get(API_ROUTES.files.refRoots, async (_req: Request, res: Response<TreeNode[]>) => {
846
+ log.info("files", "GET ref-roots: start");
797
847
  const entries = getCachedReferenceDirs();
798
848
  const nodes: TreeNode[] = [];
799
849
  for (const entry of entries) {
@@ -806,6 +856,7 @@ router.get(API_ROUTES.files.refRoots, async (_req: Request, res: Response<TreeNo
806
856
  modifiedMs: stat.mtimeMs,
807
857
  });
808
858
  }
859
+ log.info("files", "GET ref-roots: ok", { configured: entries.length, mounted: nodes.length });
809
860
  res.json(nodes);
810
861
  });
811
862
 
@@ -3,12 +3,14 @@ import { readCurrentHtml, writeCurrentHtml } from "../../utils/files/html-io.js"
3
3
  import { getGeminiClient, isGeminiAvailable } from "../../utils/gemini.js";
4
4
  import { errorMessage } from "../../utils/errors.js";
5
5
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
6
+ import { log } from "../../system/logger/index.js";
7
+ import { promptMeta } from "../../utils/promptMeta.js";
6
8
 
7
9
  const router = Router();
8
10
 
9
11
  async function callGemini(prompt: string): Promise<string> {
10
- const ai = getGeminiClient();
11
- const response = await ai.models.generateContent({
12
+ const client = getGeminiClient();
13
+ const response = await client.models.generateContent({
12
14
  model: "gemini-2.0-flash",
13
15
  contents: [{ text: prompt }],
14
16
  });
@@ -41,11 +43,14 @@ type HtmlResponse = HtmlSuccessResponse | HtmlErrorResponse;
41
43
 
42
44
  router.post(API_ROUTES.html.generate, async (req: Request<object, unknown, HtmlPromptBody>, res: Response<HtmlResponse>) => {
43
45
  const { prompt } = req.body;
46
+ log.info("html", "generate: start", { prompt: typeof prompt === "string" ? promptMeta(prompt) : undefined });
44
47
  if (!prompt) {
48
+ log.warn("html", "generate: missing prompt");
45
49
  res.status(400).json({ message: "prompt is required" });
46
50
  return;
47
51
  }
48
52
  if (!isGeminiAvailable()) {
53
+ log.warn("html", "generate: GEMINI_API_KEY not set");
49
54
  res.status(500).json({ message: "GEMINI_API_KEY is not set" });
50
55
  return;
51
56
  }
@@ -54,6 +59,7 @@ router.post(API_ROUTES.html.generate, async (req: Request<object, unknown, HtmlP
54
59
  const html = await callGemini(fullPrompt);
55
60
 
56
61
  await writeCurrentHtml(html);
62
+ log.info("html", "generate: ok", { bytes: html.length });
57
63
  res.json({
58
64
  message: "HTML generation succeeded",
59
65
  instructions: "Acknowledge that the HTML was generated and has been presented to the user.",
@@ -61,23 +67,28 @@ router.post(API_ROUTES.html.generate, async (req: Request<object, unknown, HtmlP
61
67
  data: { html, type: "tailwind" },
62
68
  });
63
69
  } catch (err) {
70
+ log.error("html", "generate: threw", { error: errorMessage(err), prompt: promptMeta(prompt) });
64
71
  res.status(500).json({ message: errorMessage(err) });
65
72
  }
66
73
  });
67
74
 
68
75
  router.post(API_ROUTES.html.edit, async (req: Request<object, unknown, HtmlPromptBody>, res: Response<HtmlResponse>) => {
69
76
  const { prompt } = req.body;
77
+ log.info("html", "edit: start", { prompt: typeof prompt === "string" ? promptMeta(prompt) : undefined });
70
78
  if (!prompt) {
79
+ log.warn("html", "edit: missing prompt");
71
80
  res.status(400).json({ message: "prompt is required" });
72
81
  return;
73
82
  }
74
83
  if (!isGeminiAvailable()) {
84
+ log.warn("html", "edit: GEMINI_API_KEY not set");
75
85
  res.status(500).json({ message: "GEMINI_API_KEY is not set" });
76
86
  return;
77
87
  }
78
88
  try {
79
89
  const existingHtml = await readCurrentHtml();
80
90
  if (!existingHtml?.trim()) {
91
+ log.warn("html", "edit: no existing HTML to modify");
81
92
  res.status(400).json({
82
93
  message: "No HTML page has been generated yet. Use generateHtml first.",
83
94
  });
@@ -86,6 +97,7 @@ router.post(API_ROUTES.html.edit, async (req: Request<object, unknown, HtmlPromp
86
97
  const fullPrompt = `Modify the following HTML page based on this instruction: ${prompt}\n\nExisting HTML:\n${existingHtml}\n\nRequirements:\n- Return only the complete modified HTML, no explanation`;
87
98
  const html = await callGemini(fullPrompt);
88
99
  await writeCurrentHtml(html);
100
+ log.info("html", "edit: ok", { bytes: html.length });
89
101
  res.json({
90
102
  message: "HTML editing succeeded",
91
103
  instructions: "Acknowledge that the HTML was modified and has been presented to the user.",
@@ -94,6 +106,7 @@ router.post(API_ROUTES.html.edit, async (req: Request<object, unknown, HtmlPromp
94
106
  updating: true,
95
107
  });
96
108
  } catch (err) {
109
+ log.error("html", "edit: threw", { error: errorMessage(err), prompt: promptMeta(prompt) });
97
110
  res.status(500).json({ message: errorMessage(err) });
98
111
  }
99
112
  });
@@ -5,7 +5,15 @@ import { generateGeminiImageContent, generateGeminiImageFromPrompt } from "../..
5
5
  import { errorMessage } from "../../utils/errors.js";
6
6
  import { badRequest, serverError } from "../../utils/httpError.js";
7
7
  import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath } from "../../utils/files/image-store.js";
8
+ import { promptMeta } from "../../utils/promptMeta.js";
8
9
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
10
+ import { log } from "../../system/logger/index.js";
11
+
12
+ // Image-generation routes were silent on success and on failure. When
13
+ // the canvas showed "missing image" with no server-side trace, there
14
+ // was nothing to grep. Log lines now carry a prompt fingerprint
15
+ // (`{ length, sha256 }`) via `promptMeta()` instead of the raw text —
16
+ // see `server/utils/promptMeta.ts` for why.
9
17
 
10
18
  const router = Router();
11
19
 
@@ -37,6 +45,17 @@ async function respondWithImage(
37
45
  kind: "generation" | "edit",
38
46
  ): Promise<void> {
39
47
  if (!imageData) {
48
+ // Gemini returned text-only / no image — typically a refusal,
49
+ // safety filter, or a quota miss. Codex flagged this branch
50
+ // (review of #780) for treating refusals as success; switching
51
+ // it to a 502 is the obvious fix, but `apiPost.extractError`
52
+ // only extracts `body.error` and image responses use
53
+ // `{ success: false, message }`, so the agent would lose the
54
+ // Gemini-side message and see only "Bad Gateway". Leaving
55
+ // behavior unchanged here until the shared error-shape
56
+ // (`extractError` accepting `message`, or all image responses
57
+ // adopting `error`) lands in a separate PR — see #783 review
58
+ // history.
40
59
  res.json({ message: fallbackMessage ?? "no image data in response" });
41
60
  return;
42
61
  }
@@ -87,13 +106,27 @@ interface GenerateImageBody {
87
106
  router.post(API_ROUTES.image.generate, async (req: Request<object, unknown, GenerateImageBody>, res: Response<ImageResponse>) => {
88
107
  const { prompt, model } = req.body;
89
108
  if (!prompt) {
109
+ log.warn("image", "generate: missing prompt");
90
110
  res.status(400).json({ success: false, message: "prompt is required" });
91
111
  return;
92
112
  }
113
+ log.info("image", "generate: start", { prompt: promptMeta(prompt), model: model ?? "(default)" });
93
114
  try {
94
115
  const { imageData, message } = await generateGeminiImageFromPrompt(prompt, model);
116
+ if (!imageData) {
117
+ log.warn("image", "generate: gemini returned no image data", {
118
+ prompt: promptMeta(prompt),
119
+ fallbackMessage: message,
120
+ });
121
+ } else {
122
+ log.info("image", "generate: ok", { prompt: promptMeta(prompt), bytes: imageData.length });
123
+ }
95
124
  await respondWithImage(res, imageData, message, prompt, "generation");
96
125
  } catch (err) {
126
+ log.error("image", "generate: gemini call threw", {
127
+ prompt: promptMeta(prompt),
128
+ error: errorMessage(err),
129
+ });
97
130
  res.status(500).json({ success: false, message: errorMessage(err) });
98
131
  }
99
132
  });
@@ -106,17 +139,20 @@ router.post(API_ROUTES.image.edit, async (req: Request<object, unknown, EditImag
106
139
  const { prompt } = req.body;
107
140
  const session = getOptionalStringQuery(req, "session");
108
141
  if (!prompt) {
142
+ log.warn("image", "edit: missing prompt", { session });
109
143
  res.status(400).json({ success: false, message: "prompt is required" });
110
144
  return;
111
145
  }
112
146
  const currentImageData = session ? getSessionImageData(session) : undefined;
113
147
  if (!currentImageData) {
148
+ log.warn("image", "edit: no source image selected", { session });
114
149
  res.status(400).json({
115
150
  success: false,
116
151
  message: "No image is selected. Please click an image in the sidebar first, then ask me to edit it.",
117
152
  });
118
153
  return;
119
154
  }
155
+ log.info("image", "edit: start", { prompt: promptMeta(prompt), session, sourceKind: isImagePath(currentImageData) ? "path" : "dataUri" });
120
156
  try {
121
157
  // Resolve input image to raw base64 — supports both file paths and legacy data URIs
122
158
  const base64Data = isImagePath(currentImageData) ? await loadImageBase64(currentImageData) : stripDataUri(currentImageData);
@@ -127,8 +163,22 @@ router.post(API_ROUTES.image.edit, async (req: Request<object, unknown, EditImag
127
163
  parts: [{ inlineData: { mimeType: "image/png", data: base64Data } }, { text: prompt }],
128
164
  },
129
165
  ]);
166
+ if (!imageData) {
167
+ log.warn("image", "edit: gemini returned no image data", {
168
+ prompt: promptMeta(prompt),
169
+ session,
170
+ fallbackMessage: message,
171
+ });
172
+ } else {
173
+ log.info("image", "edit: ok", { prompt: promptMeta(prompt), session, bytes: imageData.length });
174
+ }
130
175
  await respondWithImage(res, imageData, message, prompt, "edit");
131
176
  } catch (err) {
177
+ log.error("image", "edit: gemini call threw", {
178
+ prompt: promptMeta(prompt),
179
+ session,
180
+ error: errorMessage(err),
181
+ });
132
182
  res.status(500).json({ success: false, message: errorMessage(err) });
133
183
  }
134
184
  });
@@ -145,25 +195,30 @@ router.post(API_ROUTES.image.upload, async (req: Request<object, unknown, Canvas
145
195
  await saveCanvasImage(res, base64, async (b64) => saveImage(b64));
146
196
  });
147
197
 
148
- router.put(
149
- API_ROUTES.image.update,
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");
155
- return;
156
- }
157
- if (!isImagePath(relativePath)) {
158
- badRequest(res, "invalid image path");
159
- return;
160
- }
161
- const base64 = stripDataUri(imageData);
162
- await saveCanvasImage(res, base64, async (b64) => {
163
- await overwriteImage(relativePath, b64);
164
- return relativePath;
165
- });
166
- },
167
- );
198
+ interface UpdateImageBody extends CanvasImageBody {
199
+ relativePath: string;
200
+ }
201
+
202
+ // Canvas saves come in with the workspace-relative path the file
203
+ // already lives at (returned at canvas creation), so the client never
204
+ // has to know how `saveImage` shards by YYYY/MM. The server validates
205
+ // the prefix + extension via `isImagePath`; `safeResolve` inside
206
+ // `overwriteImage` blocks any traversal.
207
+ router.put(API_ROUTES.image.update, async (req: Request<object, unknown, UpdateImageBody>, res: Response<CanvasImageResponse | CanvasImageError>) => {
208
+ const { relativePath, imageData } = req.body;
209
+ if (!relativePath || !isImagePath(relativePath)) {
210
+ badRequest(res, "invalid image relativePath");
211
+ return;
212
+ }
213
+ if (!imageData) {
214
+ badRequest(res, "imageData is required");
215
+ return;
216
+ }
217
+ const base64 = stripDataUri(imageData);
218
+ await saveCanvasImage(res, base64, async (b64) => {
219
+ await overwriteImage(relativePath, b64);
220
+ return relativePath;
221
+ });
222
+ });
168
223
 
169
224
  export default router;