mulmoclaude 0.1.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 (408) hide show
  1. package/README.md +44 -0
  2. package/bin/mulmoclaude.js +202 -0
  3. package/bin/prepare-dist.js +93 -0
  4. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
  5. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
  6. package/client/assets/index-D8rhwXLq.js +4906 -0
  7. package/client/assets/index-KNLBjwuh.css +1 -0
  8. package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
  9. package/client/assets/material-icons-Dr0goTwe.woff +0 -0
  10. package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
  11. package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
  12. package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
  13. package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
  14. package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
  15. package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
  16. package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
  17. package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
  18. package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
  19. package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
  20. package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
  21. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
  22. package/client/index.html +28 -0
  23. package/package.json +66 -0
  24. package/server/agent/attachmentConverter.ts +270 -0
  25. package/server/agent/config.ts +414 -0
  26. package/server/agent/index.ts +260 -0
  27. package/server/agent/mcp-server.ts +412 -0
  28. package/server/agent/mcp-tools/index.ts +63 -0
  29. package/server/agent/mcp-tools/x.ts +188 -0
  30. package/server/agent/plugin-names.ts +75 -0
  31. package/server/agent/prompt.ts +349 -0
  32. package/server/agent/resumeFailover.ts +129 -0
  33. package/server/agent/sandboxMounts.ts +329 -0
  34. package/server/agent/stream.ts +194 -0
  35. package/server/api/auth/bearerAuth.ts +61 -0
  36. package/server/api/auth/token.ts +98 -0
  37. package/server/api/csrfGuard.ts +85 -0
  38. package/server/api/routes/agent.ts +478 -0
  39. package/server/api/routes/chart.ts +98 -0
  40. package/server/api/routes/chat-index.ts +46 -0
  41. package/server/api/routes/config.ts +258 -0
  42. package/server/api/routes/dispatchResponse.ts +79 -0
  43. package/server/api/routes/files.ts +812 -0
  44. package/server/api/routes/html.ts +101 -0
  45. package/server/api/routes/image.ts +169 -0
  46. package/server/api/routes/mulmo-script.ts +712 -0
  47. package/server/api/routes/mulmoScriptValidate.ts +101 -0
  48. package/server/api/routes/notifications.ts +69 -0
  49. package/server/api/routes/pdf.ts +163 -0
  50. package/server/api/routes/plugins.ts +276 -0
  51. package/server/api/routes/presentHtml.ts +48 -0
  52. package/server/api/routes/roles.ts +125 -0
  53. package/server/api/routes/scheduler.ts +153 -0
  54. package/server/api/routes/schedulerHandlers.ts +151 -0
  55. package/server/api/routes/schedulerTasks.ts +163 -0
  56. package/server/api/routes/sessions.ts +294 -0
  57. package/server/api/routes/sessionsCursor.ts +59 -0
  58. package/server/api/routes/skills.ts +195 -0
  59. package/server/api/routes/sources.ts +540 -0
  60. package/server/api/routes/todos.ts +263 -0
  61. package/server/api/routes/todosColumnsHandlers.ts +347 -0
  62. package/server/api/routes/todosHandlers.ts +274 -0
  63. package/server/api/routes/todosItemsHandlers.ts +386 -0
  64. package/server/api/routes/wiki/pageIndex.ts +53 -0
  65. package/server/api/routes/wiki.ts +363 -0
  66. package/server/api/sandboxStatus.ts +64 -0
  67. package/server/events/notifications.ts +160 -0
  68. package/server/events/pub-sub/index.ts +45 -0
  69. package/server/events/relay-client.ts +288 -0
  70. package/server/events/scheduler-adapter.ts +302 -0
  71. package/server/events/session-store/index.ts +492 -0
  72. package/server/events/task-manager/index.ts +181 -0
  73. package/server/index.ts +572 -0
  74. package/server/system/config.ts +243 -0
  75. package/server/system/credentials.ts +220 -0
  76. package/server/system/docker.ts +97 -0
  77. package/server/system/env.ts +109 -0
  78. package/server/system/logger/config.ts +112 -0
  79. package/server/system/logger/formatters.ts +40 -0
  80. package/server/system/logger/index.ts +53 -0
  81. package/server/system/logger/rotation.ts +37 -0
  82. package/server/system/logger/sinks.ts +101 -0
  83. package/server/system/logger/types.ts +29 -0
  84. package/server/utils/date.ts +57 -0
  85. package/server/utils/errors.ts +7 -0
  86. package/server/utils/fetch.ts +27 -0
  87. package/server/utils/files/atomic.ts +125 -0
  88. package/server/utils/files/html-io.ts +20 -0
  89. package/server/utils/files/image-store.ts +66 -0
  90. package/server/utils/files/index.ts +45 -0
  91. package/server/utils/files/journal-io.ts +213 -0
  92. package/server/utils/files/json.ts +69 -0
  93. package/server/utils/files/markdown-store.ts +33 -0
  94. package/server/utils/files/naming.ts +50 -0
  95. package/server/utils/files/reference-dirs-io.ts +45 -0
  96. package/server/utils/files/roles-io.ts +45 -0
  97. package/server/utils/files/safe.ts +106 -0
  98. package/server/utils/files/scheduler-io.ts +20 -0
  99. package/server/utils/files/scheduler-overrides-io.ts +64 -0
  100. package/server/utils/files/session-io.ts +136 -0
  101. package/server/utils/files/spreadsheet-store.ts +63 -0
  102. package/server/utils/files/todos-io.ts +29 -0
  103. package/server/utils/files/user-tasks-io.ts +25 -0
  104. package/server/utils/files/workspace-io.ts +221 -0
  105. package/server/utils/gemini.ts +59 -0
  106. package/server/utils/gitignore.ts +69 -0
  107. package/server/utils/http.ts +15 -0
  108. package/server/utils/httpError.ts +61 -0
  109. package/server/utils/id.ts +16 -0
  110. package/server/utils/json.ts +83 -0
  111. package/server/utils/logBackgroundError.ts +22 -0
  112. package/server/utils/markdown.ts +82 -0
  113. package/server/utils/request.ts +29 -0
  114. package/server/utils/slug.ts +50 -0
  115. package/server/utils/spawn.ts +62 -0
  116. package/server/utils/time.ts +34 -0
  117. package/server/utils/types.ts +47 -0
  118. package/server/workspace/chat-index/index.ts +153 -0
  119. package/server/workspace/chat-index/indexer.ts +209 -0
  120. package/server/workspace/chat-index/paths.ts +34 -0
  121. package/server/workspace/chat-index/summarizer.ts +247 -0
  122. package/server/workspace/chat-index/types.ts +38 -0
  123. package/server/workspace/custom-dirs.ts +220 -0
  124. package/server/workspace/helps/business.md +104 -0
  125. package/server/workspace/helps/github.md +23 -0
  126. package/server/workspace/helps/index.md +60 -0
  127. package/server/workspace/helps/mulmoscript.md +249 -0
  128. package/server/workspace/helps/sandbox.md +90 -0
  129. package/server/workspace/helps/spreadsheet.md +43 -0
  130. package/server/workspace/helps/telegram.md +135 -0
  131. package/server/workspace/helps/wiki.md +131 -0
  132. package/server/workspace/journal/archivist.ts +386 -0
  133. package/server/workspace/journal/dailyPass.ts +743 -0
  134. package/server/workspace/journal/diff.ts +71 -0
  135. package/server/workspace/journal/index.ts +185 -0
  136. package/server/workspace/journal/indexFile.ts +136 -0
  137. package/server/workspace/journal/linkRewrite.ts +4 -0
  138. package/server/workspace/journal/memoryExtractor.ts +130 -0
  139. package/server/workspace/journal/optimizationPass.ts +160 -0
  140. package/server/workspace/journal/paths.ts +76 -0
  141. package/server/workspace/journal/state.ts +125 -0
  142. package/server/workspace/paths.ts +158 -0
  143. package/server/workspace/reference-dirs.ts +252 -0
  144. package/server/workspace/roles.ts +37 -0
  145. package/server/workspace/skills/discovery.ts +125 -0
  146. package/server/workspace/skills/index.ts +10 -0
  147. package/server/workspace/skills/parser.ts +144 -0
  148. package/server/workspace/skills/paths.ts +41 -0
  149. package/server/workspace/skills/scheduler.ts +149 -0
  150. package/server/workspace/skills/types.ts +30 -0
  151. package/server/workspace/skills/user-tasks.ts +257 -0
  152. package/server/workspace/skills/writer.ts +189 -0
  153. package/server/workspace/sources/arxivDiscovery.ts +182 -0
  154. package/server/workspace/sources/classifier.ts +268 -0
  155. package/server/workspace/sources/fetchers/arxiv.ts +170 -0
  156. package/server/workspace/sources/fetchers/github.ts +106 -0
  157. package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
  158. package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
  159. package/server/workspace/sources/fetchers/index.ts +71 -0
  160. package/server/workspace/sources/fetchers/registerAll.ts +15 -0
  161. package/server/workspace/sources/fetchers/rss.ts +141 -0
  162. package/server/workspace/sources/fetchers/rssParser.ts +295 -0
  163. package/server/workspace/sources/httpFetcher.ts +230 -0
  164. package/server/workspace/sources/interests.ts +120 -0
  165. package/server/workspace/sources/paths.ts +110 -0
  166. package/server/workspace/sources/pipeline/dedup.ts +60 -0
  167. package/server/workspace/sources/pipeline/fetch.ts +136 -0
  168. package/server/workspace/sources/pipeline/index.ts +249 -0
  169. package/server/workspace/sources/pipeline/notify.ts +72 -0
  170. package/server/workspace/sources/pipeline/plan.ts +66 -0
  171. package/server/workspace/sources/pipeline/summarize.ts +189 -0
  172. package/server/workspace/sources/pipeline/write.ts +185 -0
  173. package/server/workspace/sources/rateLimiter.ts +148 -0
  174. package/server/workspace/sources/registry.ts +326 -0
  175. package/server/workspace/sources/robots.ts +271 -0
  176. package/server/workspace/sources/sourceState.ts +135 -0
  177. package/server/workspace/sources/taxonomy.ts +74 -0
  178. package/server/workspace/sources/types.ts +144 -0
  179. package/server/workspace/sources/urls.ts +112 -0
  180. package/server/workspace/tool-trace/classify.ts +114 -0
  181. package/server/workspace/tool-trace/index.ts +250 -0
  182. package/server/workspace/tool-trace/writeSearch.ts +98 -0
  183. package/server/workspace/wiki-backlinks/index.ts +107 -0
  184. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
  185. package/server/workspace/workspace.ts +66 -0
  186. package/src/App.vue +720 -0
  187. package/src/assets/mulmo_bw.png +0 -0
  188. package/src/components/CanvasViewToggle.vue +27 -0
  189. package/src/components/ChatAttachmentPreview.vue +45 -0
  190. package/src/components/ChatImagePreview.vue +17 -0
  191. package/src/components/ChatInput.vue +208 -0
  192. package/src/components/FileContentHeader.vue +49 -0
  193. package/src/components/FileContentRenderer.vue +162 -0
  194. package/src/components/FileTree.vue +115 -0
  195. package/src/components/FileTreePane.vue +85 -0
  196. package/src/components/FilesView.vue +206 -0
  197. package/src/components/LockStatusPopup.vue +111 -0
  198. package/src/components/NotificationBell.vue +131 -0
  199. package/src/components/NotificationToast.vue +72 -0
  200. package/src/components/PluginLauncher.vue +138 -0
  201. package/src/components/RightSidebar.vue +113 -0
  202. package/src/components/RoleSelector.vue +64 -0
  203. package/src/components/SessionHistoryPanel.vue +176 -0
  204. package/src/components/SessionTabBar.vue +81 -0
  205. package/src/components/SettingsMcpTab.vue +350 -0
  206. package/src/components/SettingsModal.vue +275 -0
  207. package/src/components/SettingsReferenceDirsTab.vue +173 -0
  208. package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
  209. package/src/components/SidebarHeader.vue +69 -0
  210. package/src/components/StackView.vue +360 -0
  211. package/src/components/SuggestionsPanel.vue +65 -0
  212. package/src/components/TodoExplorer.vue +358 -0
  213. package/src/components/ToolResultsPanel.vue +77 -0
  214. package/src/components/todo/TodoAddDialog.vue +131 -0
  215. package/src/components/todo/TodoEditDialog.vue +47 -0
  216. package/src/components/todo/TodoEditPanel.vue +113 -0
  217. package/src/components/todo/TodoKanbanView.vue +249 -0
  218. package/src/components/todo/TodoListView.vue +79 -0
  219. package/src/components/todo/TodoTableView.vue +177 -0
  220. package/src/composables/useActiveSession.ts +40 -0
  221. package/src/composables/useAppApi.ts +45 -0
  222. package/src/composables/useCanvasViewMode.ts +121 -0
  223. package/src/composables/useChatScroll.ts +47 -0
  224. package/src/composables/useClickOutside.ts +26 -0
  225. package/src/composables/useClipboardCopy.ts +44 -0
  226. package/src/composables/useContentDisplay.ts +52 -0
  227. package/src/composables/useDebugBeat.ts +23 -0
  228. package/src/composables/useDynamicFavicon.ts +115 -0
  229. package/src/composables/useEventListeners.ts +42 -0
  230. package/src/composables/useExpandedDirs.ts +64 -0
  231. package/src/composables/useFaviconState.ts +30 -0
  232. package/src/composables/useFileSelection.ts +115 -0
  233. package/src/composables/useFileSortMode.ts +24 -0
  234. package/src/composables/useFileTree.ts +85 -0
  235. package/src/composables/useFreshPluginData.ts +89 -0
  236. package/src/composables/useHealth.ts +38 -0
  237. package/src/composables/useImeAwareEnter.ts +57 -0
  238. package/src/composables/useKeyNavigation.ts +60 -0
  239. package/src/composables/useMarkdownLinkHandler.ts +46 -0
  240. package/src/composables/useMarkdownMode.ts +17 -0
  241. package/src/composables/useMcpTools.ts +71 -0
  242. package/src/composables/useMergedSessions.ts +27 -0
  243. package/src/composables/useNotifications.ts +90 -0
  244. package/src/composables/usePdfDownload.ts +60 -0
  245. package/src/composables/usePendingCalls.ts +77 -0
  246. package/src/composables/usePubSub.ts +85 -0
  247. package/src/composables/useRightSidebar.ts +23 -0
  248. package/src/composables/useRoles.ts +34 -0
  249. package/src/composables/useSandboxStatus.ts +67 -0
  250. package/src/composables/useSelectedResult.ts +49 -0
  251. package/src/composables/useSessionDerived.ts +51 -0
  252. package/src/composables/useSessionHistory.ts +81 -0
  253. package/src/composables/useSessionSync.ts +57 -0
  254. package/src/composables/useViewLayout.ts +55 -0
  255. package/src/config/apiRoutes.ts +173 -0
  256. package/src/config/pubsubChannels.ts +45 -0
  257. package/src/config/roles.ts +335 -0
  258. package/src/config/schedulerActions.ts +25 -0
  259. package/src/config/toolNames.ts +71 -0
  260. package/src/config/workspacePaths.ts +24 -0
  261. package/src/index.css +107 -0
  262. package/src/main.ts +25 -0
  263. package/src/plugins/canvas/Preview.vue +13 -0
  264. package/src/plugins/canvas/View.vue +333 -0
  265. package/src/plugins/canvas/definition.ts +38 -0
  266. package/src/plugins/canvas/index.ts +36 -0
  267. package/src/plugins/chart/Preview.vue +49 -0
  268. package/src/plugins/chart/View.vue +143 -0
  269. package/src/plugins/chart/definition.ts +58 -0
  270. package/src/plugins/chart/index.ts +52 -0
  271. package/src/plugins/editImage/Preview.vue +13 -0
  272. package/src/plugins/editImage/View.vue +13 -0
  273. package/src/plugins/editImage/definition.ts +27 -0
  274. package/src/plugins/editImage/index.ts +36 -0
  275. package/src/plugins/generateImage/Preview.vue +13 -0
  276. package/src/plugins/generateImage/View.vue +33 -0
  277. package/src/plugins/generateImage/definition.ts +32 -0
  278. package/src/plugins/generateImage/index.ts +56 -0
  279. package/src/plugins/manageRoles/Preview.vue +49 -0
  280. package/src/plugins/manageRoles/View.vue +525 -0
  281. package/src/plugins/manageRoles/definition.ts +43 -0
  282. package/src/plugins/manageRoles/index.ts +47 -0
  283. package/src/plugins/manageSkills/Preview.vue +21 -0
  284. package/src/plugins/manageSkills/View.vue +321 -0
  285. package/src/plugins/manageSkills/definition.ts +49 -0
  286. package/src/plugins/manageSkills/index.ts +49 -0
  287. package/src/plugins/manageSource/Preview.vue +33 -0
  288. package/src/plugins/manageSource/View.vue +697 -0
  289. package/src/plugins/manageSource/definition.ts +63 -0
  290. package/src/plugins/manageSource/index.ts +66 -0
  291. package/src/plugins/markdown/Preview.vue +77 -0
  292. package/src/plugins/markdown/View.vue +476 -0
  293. package/src/plugins/markdown/definition.ts +50 -0
  294. package/src/plugins/markdown/index.ts +36 -0
  295. package/src/plugins/presentHtml/Preview.vue +25 -0
  296. package/src/plugins/presentHtml/View.vue +52 -0
  297. package/src/plugins/presentHtml/definition.ts +27 -0
  298. package/src/plugins/presentHtml/helpers.ts +72 -0
  299. package/src/plugins/presentHtml/index.ts +41 -0
  300. package/src/plugins/presentMulmoScript/Preview.vue +23 -0
  301. package/src/plugins/presentMulmoScript/View.vue +1166 -0
  302. package/src/plugins/presentMulmoScript/definition.ts +95 -0
  303. package/src/plugins/presentMulmoScript/helpers.ts +162 -0
  304. package/src/plugins/presentMulmoScript/index.ts +40 -0
  305. package/src/plugins/scheduler/Preview.vue +67 -0
  306. package/src/plugins/scheduler/TasksTab.vue +205 -0
  307. package/src/plugins/scheduler/View.vue +565 -0
  308. package/src/plugins/scheduler/definition.ts +57 -0
  309. package/src/plugins/scheduler/index.ts +45 -0
  310. package/src/plugins/scheduler/viewModes.ts +26 -0
  311. package/src/plugins/spreadsheet/Preview.vue +29 -0
  312. package/src/plugins/spreadsheet/View.vue +997 -0
  313. package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
  314. package/src/plugins/spreadsheet/definition.ts +121 -0
  315. package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
  316. package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
  317. package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
  318. package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
  319. package/src/plugins/spreadsheet/engine/engine.ts +176 -0
  320. package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
  321. package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
  322. package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
  323. package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
  324. package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
  325. package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
  326. package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
  327. package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
  328. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
  329. package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
  330. package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
  331. package/src/plugins/spreadsheet/engine/index.ts +27 -0
  332. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
  333. package/src/plugins/spreadsheet/engine/parser.ts +143 -0
  334. package/src/plugins/spreadsheet/engine/registry.ts +150 -0
  335. package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
  336. package/src/plugins/spreadsheet/engine/types.ts +64 -0
  337. package/src/plugins/spreadsheet/index.ts +36 -0
  338. package/src/plugins/textResponse/Preview.vue +94 -0
  339. package/src/plugins/textResponse/View.vue +503 -0
  340. package/src/plugins/textResponse/definition.ts +34 -0
  341. package/src/plugins/textResponse/index.ts +27 -0
  342. package/src/plugins/textResponse/plugin.ts +29 -0
  343. package/src/plugins/textResponse/samples.ts +97 -0
  344. package/src/plugins/textResponse/types.ts +11 -0
  345. package/src/plugins/todo/Preview.vue +63 -0
  346. package/src/plugins/todo/View.vue +364 -0
  347. package/src/plugins/todo/composables/useTodos.ts +177 -0
  348. package/src/plugins/todo/definition.ts +45 -0
  349. package/src/plugins/todo/index.ts +61 -0
  350. package/src/plugins/todo/labels.ts +163 -0
  351. package/src/plugins/todo/priority.ts +98 -0
  352. package/src/plugins/todo/viewModes.ts +19 -0
  353. package/src/plugins/ui-image/ImagePreview.vue +23 -0
  354. package/src/plugins/ui-image/ImageView.vue +34 -0
  355. package/src/plugins/ui-image/index.ts +3 -0
  356. package/src/plugins/ui-image/types.ts +4 -0
  357. package/src/plugins/wiki/Preview.vue +65 -0
  358. package/src/plugins/wiki/View.vue +342 -0
  359. package/src/plugins/wiki/definition.ts +25 -0
  360. package/src/plugins/wiki/helpers.ts +59 -0
  361. package/src/plugins/wiki/index.ts +52 -0
  362. package/src/router/guards.ts +61 -0
  363. package/src/router/index.ts +50 -0
  364. package/src/tools/index.ts +52 -0
  365. package/src/tools/types.ts +27 -0
  366. package/src/types/events.ts +16 -0
  367. package/src/types/fileTree.ts +13 -0
  368. package/src/types/notification.ts +67 -0
  369. package/src/types/session.ts +116 -0
  370. package/src/types/sse.ts +90 -0
  371. package/src/types/toolCallHistory.ts +13 -0
  372. package/src/utils/agent/eventDispatch.ts +74 -0
  373. package/src/utils/agent/request.ts +55 -0
  374. package/src/utils/agent/toolCalls.ts +62 -0
  375. package/src/utils/api.ts +218 -0
  376. package/src/utils/canvas/viewMode.ts +46 -0
  377. package/src/utils/dom/authTokenMeta.ts +20 -0
  378. package/src/utils/dom/clickOutside.ts +11 -0
  379. package/src/utils/dom/externalLink.ts +57 -0
  380. package/src/utils/dom/scrollable.ts +24 -0
  381. package/src/utils/errors.ts +11 -0
  382. package/src/utils/files/expandedDirs.ts +25 -0
  383. package/src/utils/files/filename.ts +12 -0
  384. package/src/utils/files/sortChildren.ts +20 -0
  385. package/src/utils/filesPreview/schedulerPreview.ts +38 -0
  386. package/src/utils/filesPreview/todoPreview.ts +40 -0
  387. package/src/utils/format/date.ts +85 -0
  388. package/src/utils/format/frontmatter.ts +80 -0
  389. package/src/utils/format/jsonSyntax.ts +109 -0
  390. package/src/utils/html/previewCsp.ts +65 -0
  391. package/src/utils/image/resolve.ts +8 -0
  392. package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
  393. package/src/utils/markdown/extractFirstH1.ts +39 -0
  394. package/src/utils/notification/dispatch.ts +22 -0
  395. package/src/utils/path/relativeLink.ts +130 -0
  396. package/src/utils/role/icon.ts +20 -0
  397. package/src/utils/role/merge.ts +10 -0
  398. package/src/utils/role/plugins.ts +12 -0
  399. package/src/utils/session/mergeSessions.ts +103 -0
  400. package/src/utils/session/seedRoleDefault.ts +35 -0
  401. package/src/utils/session/sessionEntries.ts +121 -0
  402. package/src/utils/session/sessionFactory.ts +22 -0
  403. package/src/utils/session/sessionHelpers.ts +99 -0
  404. package/src/utils/tools/dedup.ts +17 -0
  405. package/src/utils/tools/mcp.ts +33 -0
  406. package/src/utils/tools/pendingCalls.ts +16 -0
  407. package/src/utils/tools/result.ts +40 -0
  408. package/src/utils/types.ts +44 -0
@@ -0,0 +1,812 @@
1
+ import { Router, Request, Response } from "express";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { workspacePath } from "../../workspace/workspace.js";
5
+ import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
6
+ import { errorMessage } from "../../utils/errors.js";
7
+ import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
8
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
9
+ import { GitignoreFilter } from "../../utils/gitignore.js";
10
+ import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js";
11
+
12
+ const router = Router();
13
+
14
+ 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
16
+ const HIDDEN_DIRS = new Set([".git"]);
17
+
18
+ // Files whose basename exactly matches one of these is refused by
19
+ // every file-API endpoint. Used to keep workspace secrets
20
+ // (credentials, API keys, SSH / TLS private keys) off the HTTP
21
+ // surface. Compared against `path.basename(...).toLowerCase()`.
22
+ const SENSITIVE_BASENAMES = new Set([
23
+ "credentials.json",
24
+ // Claude Code credentials file written by server/credentials.ts.
25
+ ".session-token",
26
+ // Bearer auth token file — readable without auth via /api/files/*
27
+ // exemption, so it must be blocked here (defense in depth).
28
+ ".npmrc",
29
+ ".htpasswd",
30
+ "id_rsa",
31
+ "id_ecdsa",
32
+ "id_ed25519",
33
+ "id_dsa",
34
+ ]);
35
+
36
+ // File extensions whose contents are almost always secret. Compared
37
+ // against `path.extname(...).toLowerCase()`. Note: `.env` is matched
38
+ // separately below because `path.extname(".env")` returns "" —
39
+ // dotfiles with no second extension don't carry an extname.
40
+ const SENSITIVE_EXTENSIONS = new Set([".pem", ".key", ".crt"]);
41
+
42
+ // Decide whether `relPath` names a file whose contents should NEVER
43
+ // be served by the file API. Applied in three places:
44
+ //
45
+ // 1. `resolveSafe` returns null for sensitive paths so every
46
+ // endpoint (content, raw, anything future) rejects them with a
47
+ // generic 400.
48
+ // 2. `buildTreeAsync` / `listDirShallow` filter them out of
49
+ // `/files/tree` and `/files/dir`, so the file explorer never
50
+ // lists them in the first place.
51
+ // 3. The `.env` blocklist below is what keeps `/files/content`
52
+ // from leaking credentials on a matching-name lookup.
53
+ //
54
+ // Exported so `test/routes/test_filesRoute.ts` can pin the matching
55
+ // rules down table-driven — regressions here silently reopen a
56
+ // credential-exfil surface.
57
+ export function isSensitivePath(relPath: string): boolean {
58
+ const base = path.basename(relPath).toLowerCase();
59
+ if (SENSITIVE_BASENAMES.has(base)) return true;
60
+ // `.env` and every `.env.<something>` variant
61
+ // (`.env.local`, `.env.production`, ...). The startsWith check
62
+ // is scoped to `.env` to avoid false-positives on names like
63
+ // `.environment-notes` — we only match `.env` exact or
64
+ // `.env.<suffix>`.
65
+ if (base === ".env") return true;
66
+ if (base.startsWith(".env.")) return true;
67
+ const ext = path.extname(base);
68
+ if (SENSITIVE_EXTENSIONS.has(ext)) return true;
69
+ return false;
70
+ }
71
+
72
+ const TEXT_EXTENSIONS = new Set([
73
+ ".md",
74
+ ".markdown",
75
+ ".txt",
76
+ ".json",
77
+ ".jsonl",
78
+ ".ndjson",
79
+ ".yaml",
80
+ ".yml",
81
+ ".js",
82
+ ".ts",
83
+ ".jsx",
84
+ ".tsx",
85
+ ".vue",
86
+ ".html",
87
+ ".htm",
88
+ ".css",
89
+ ".csv",
90
+ ".log",
91
+ // `.env` intentionally removed — see `isSensitivePath` below.
92
+ // It used to be here, making `/files/content?path=.env` return
93
+ // the workspace credentials as JSON text over an open CORS
94
+ // endpoint. The file API now refuses sensitive paths outright;
95
+ // this set is kept for genuine plain-text previews only.
96
+ ".gitignore",
97
+ ".sh",
98
+ ".py",
99
+ ]);
100
+
101
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
102
+
103
+ const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".ogg", ".oga", ".flac", ".aac"]);
104
+
105
+ const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".m4v", ".ogv"]);
106
+
107
+ const MIME_BY_EXT: Record<string, string> = {
108
+ ".png": "image/png",
109
+ ".jpg": "image/jpeg",
110
+ ".jpeg": "image/jpeg",
111
+ ".gif": "image/gif",
112
+ ".webp": "image/webp",
113
+ ".svg": "image/svg+xml",
114
+ ".pdf": "application/pdf",
115
+ ".mp3": "audio/mpeg",
116
+ ".wav": "audio/wav",
117
+ ".m4a": "audio/mp4",
118
+ ".ogg": "audio/ogg",
119
+ ".oga": "audio/ogg",
120
+ ".flac": "audio/flac",
121
+ ".aac": "audio/aac",
122
+ ".mp4": "video/mp4",
123
+ ".webm": "video/webm",
124
+ ".mov": "video/quicktime",
125
+ ".m4v": "video/x-m4v",
126
+ ".ogv": "video/ogg",
127
+ };
128
+
129
+ export interface TreeNode {
130
+ name: string;
131
+ path: string;
132
+ type: "file" | "dir";
133
+ size?: number;
134
+ modifiedMs?: number;
135
+ children?: TreeNode[];
136
+ }
137
+
138
+ interface ErrorResponse {
139
+ error: string;
140
+ }
141
+
142
+ interface FileContentText {
143
+ kind: "text";
144
+ path: string;
145
+ content: string;
146
+ size: number;
147
+ modifiedMs: number;
148
+ }
149
+
150
+ interface WriteContentRequest {
151
+ path?: unknown;
152
+ content?: unknown;
153
+ }
154
+
155
+ interface WriteContentResponse {
156
+ path: string;
157
+ size: number;
158
+ modifiedMs: number;
159
+ }
160
+
161
+ interface FileContentMeta {
162
+ kind: "image" | "pdf" | "audio" | "video" | "binary" | "too-large";
163
+ path: string;
164
+ size: number;
165
+ modifiedMs: number;
166
+ message?: string;
167
+ }
168
+
169
+ type FileContentResponse = FileContentText | FileContentMeta;
170
+
171
+ export type ContentKind = "text" | "image" | "pdf" | "audio" | "video" | "binary";
172
+
173
+ // Exported for unit tests. Classification is purely extension-based
174
+ // and case-insensitive (via `path.extname(...).toLowerCase()`).
175
+ export function classify(filename: string): ContentKind {
176
+ const ext = path.extname(filename).toLowerCase();
177
+ if (TEXT_EXTENSIONS.has(ext)) return "text";
178
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
179
+ if (AUDIO_EXTENSIONS.has(ext)) return "audio";
180
+ if (VIDEO_EXTENSIONS.has(ext)) return "video";
181
+ if (ext === ".pdf") return "pdf";
182
+ // Files with no extension (e.g. README, LICENSE) — treat as text
183
+ if (!ext) return "text";
184
+ return "binary";
185
+ }
186
+
187
+ // Cached realpath of the workspace. Computed once at module load so
188
+ // every request avoids the syscall. resolveWithinRoot needs an
189
+ // already-realpath'd root.
190
+ const workspaceReal = fs.realpathSync(workspacePath);
191
+
192
+ // Wraps the shared resolveWithinRoot helper with the additional
193
+ // hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
194
+ // / `listDirShallow` hide these from the listing, but the URL
195
+ // endpoints are reachable directly so they need their own check.
196
+ function resolveSafe(relPath: string): string | null {
197
+ const resolved = resolveWithinRoot(workspaceReal, relPath);
198
+ if (!resolved) return null;
199
+ const relativeFromWorkspace = path.relative(workspaceReal, resolved);
200
+ if (relativeFromWorkspace) {
201
+ for (const seg of relativeFromWorkspace.split(path.sep)) {
202
+ if (HIDDEN_DIRS.has(seg)) return null;
203
+ }
204
+ }
205
+ // Reject workspace-sensitive filenames outright. `isSensitivePath`
206
+ // matches on the basename so it catches `.env`, `id_rsa`, and
207
+ // friends regardless of which directory they sit in.
208
+ if (isSensitivePath(resolved)) return null;
209
+ return resolved;
210
+ }
211
+
212
+ // ── Reference directory path resolution ──────────────────────────
213
+
214
+ const REF_PREFIX = "@ref/";
215
+
216
+ function isRefPath(relPath: string): boolean {
217
+ return relPath.startsWith(REF_PREFIX);
218
+ }
219
+
220
+ /**
221
+ * Resolve a `@ref/<label>/remainder` path against a registered
222
+ * reference directory. Returns the absolute host path or null if
223
+ * the label is unknown, the path escapes the ref root, or the
224
+ * resolved file is sensitive / hidden.
225
+ */
226
+ function resolveRefPath(prefixedPath: string): string | null {
227
+ const afterPrefix = prefixedPath.slice(REF_PREFIX.length);
228
+ const slashIdx = afterPrefix.indexOf("/");
229
+ const label = slashIdx >= 0 ? afterPrefix.slice(0, slashIdx) : afterPrefix;
230
+ const remainder = slashIdx >= 0 ? afterPrefix.slice(slashIdx + 1) : "";
231
+
232
+ const entries = getCachedReferenceDirs();
233
+ const entry = entries.find((e) => e.label === label);
234
+ if (!entry) return null;
235
+
236
+ let rootReal: string;
237
+ try {
238
+ rootReal = fs.realpathSync(entry.hostPath);
239
+ } catch {
240
+ return null;
241
+ }
242
+
243
+ // For root of the reference dir (no remainder), return the dir itself
244
+ if (!remainder) return rootReal;
245
+
246
+ const resolved = resolveWithinRoot(rootReal, remainder);
247
+ if (!resolved) return null;
248
+
249
+ // Apply the same hidden-dir and sensitive-path filters
250
+ const relFromRoot = path.relative(rootReal, resolved);
251
+ if (relFromRoot) {
252
+ for (const seg of relFromRoot.split(path.sep)) {
253
+ if (HIDDEN_DIRS.has(seg)) return null;
254
+ }
255
+ }
256
+ if (isSensitivePath(resolved)) return null;
257
+
258
+ return resolved;
259
+ }
260
+
261
+ export interface ByteRange {
262
+ start: number;
263
+ end: number;
264
+ }
265
+
266
+ // Parse an HTTP Range header of the form `bytes=START-END` or
267
+ // `bytes=-SUFFIX`. Returns null for malformed or unsatisfiable ranges
268
+ // so the caller can respond 416. We deliberately reject multi-range
269
+ // requests (`bytes=0-99,200-299`) since browsers don't issue them for
270
+ // media playback and supporting them would complicate the response.
271
+ //
272
+ // Exported for unit tests — this is the most security-sensitive piece
273
+ // of the file-serving surface, so it's covered exhaustively in
274
+ // `test/routes/test_filesRoute.ts`.
275
+ export function parseRange(header: string, size: number): ByteRange | null {
276
+ // RFC 7233 §2.1: "A Range request on a representation whose current
277
+ // length is 0 cannot be satisfied". We also need this guard at the
278
+ // top because the naive suffix-range math below produces `end = -1`
279
+ // for zero-byte files, which then crashes `fs.createReadStream`
280
+ // with `ERR_OUT_OF_RANGE`.
281
+ if (size <= 0) return null;
282
+ const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
283
+ if (!match) return null;
284
+ const [, startStr, endStr] = match;
285
+ if (startStr === "" && endStr === "") return null;
286
+ if (startStr === "") {
287
+ const suffix = Number(endStr);
288
+ if (!Number.isFinite(suffix) || suffix <= 0) return null;
289
+ return { start: Math.max(0, size - suffix), end: size - 1 };
290
+ }
291
+ const start = Number(startStr);
292
+ const end = endStr === "" ? size - 1 : Number(endStr);
293
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
294
+ if (start < 0 || end < start || end >= size) return null;
295
+ return { start, end };
296
+ }
297
+
298
+ // Security headers applied to every `/files/raw` response. Exported
299
+ // so a regression test can pin the exact strings down — a silent
300
+ // regression here reopens a real XSS surface (see plans/
301
+ // fix-files-raw-csp-sandbox.md for the full threat model).
302
+ //
303
+ // `sandbox` (no allow-flags) creates an opaque origin for the
304
+ // response. Even if an SVG / HTML / PDF with embedded JavaScript
305
+ // gets loaded as a top-level document or inside an iframe, its
306
+ // scripts can't access the localhost:3001 origin's cookies,
307
+ // session storage, or hit the `/api/*` endpoints. Frames rendering
308
+ // the response become sandboxed too — PDFs still work because
309
+ // they don't rely on same-origin access to the parent.
310
+ //
311
+ // `nosniff` stops Chrome / Firefox from re-guessing Content-Type
312
+ // on files the server declared but the browser might want to
313
+ // re-interpret as HTML.
314
+ export const RAW_SECURITY_HEADERS: Readonly<Record<string, string>> = {
315
+ "Content-Security-Policy": "sandbox",
316
+ "X-Content-Type-Options": "nosniff",
317
+ };
318
+
319
+ function applyRawSecurityHeaders(res: Response): void {
320
+ for (const [name, value] of Object.entries(RAW_SECURITY_HEADERS)) {
321
+ res.setHeader(name, value);
322
+ }
323
+ }
324
+
325
+ // If the read stream errors mid-flight (file deleted, disk error,
326
+ // permissions changed), surface a clean failure to the client instead
327
+ // of leaving the connection hanging.
328
+ function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorResponse>): void {
329
+ stream.on("error", (err) => {
330
+ if (res.headersSent) {
331
+ res.destroy(err);
332
+ return;
333
+ }
334
+ serverError(res, `Failed to read file: ${err.message}`);
335
+ });
336
+ stream.pipe(res);
337
+ }
338
+
339
+ // Async workspace tree walker — recurses through the workspace with
340
+ // the same security filters as the original sync implementation
341
+ // (hidden dirs, sensitive files, symlinks all rejected) and the same
342
+ // ordering (dirs before files, alphabetical within type). Uses
343
+ // `fs.promises` throughout so the walk never blocks the event loop,
344
+ // and fans out each directory's children in parallel via
345
+ // `Promise.all`.
346
+ //
347
+ // Exported so unit tests can point it at a tmp dir fixture.
348
+ export async function buildTreeAsync(absPath: string, relPath: string, gitFilter?: GitignoreFilter): Promise<TreeNode> {
349
+ const stat = await statSafeAsync(absPath);
350
+ if (!stat) {
351
+ // Caller is expected to have resolved `absPath` beforehand; if it
352
+ // vanished between resolve and walk, surface an empty dir node.
353
+ return {
354
+ name: path.basename(absPath),
355
+ path: relPath,
356
+ type: "dir",
357
+ children: [],
358
+ };
359
+ }
360
+ if (!stat.isDirectory()) {
361
+ return {
362
+ name: path.basename(absPath),
363
+ path: relPath,
364
+ type: "file",
365
+ size: stat.size,
366
+ modifiedMs: stat.mtimeMs,
367
+ };
368
+ }
369
+ const entries = await readDirSafeAsync(absPath);
370
+ // Pick up any .gitignore in this directory so its rules apply to
371
+ // children. The filter chains: parent rules + local .gitignore.
372
+ // When gitFilter is undefined (workspace root), DON'T read the
373
+ // root .gitignore (it's for git, not the UI). Pass a fresh empty
374
+ // filter so children pick up THEIR .gitignore files.
375
+ const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
376
+ // Build every surviving child concurrently. Filter:
377
+ // skip hidden dirs, sensitive files, symlinks, .gitignore matches,
378
+ // and entries that fail to stat.
379
+ const childPromises: Promise<TreeNode | null>[] = entries.map(async (entry): Promise<TreeNode | null> => {
380
+ if (HIDDEN_DIRS.has(entry.name)) return null;
381
+ if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
382
+ if (entry.isSymbolicLink()) return null;
383
+ const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
384
+ // .gitignore check: for directories, append trailing / so
385
+ // directory-only patterns (e.g. "node_modules/") match.
386
+ if (localFilter) {
387
+ const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
388
+ if (localFilter.ignores(testPath)) return null;
389
+ }
390
+ const childAbs = path.join(absPath, entry.name);
391
+ const childStat = await statSafeAsync(childAbs);
392
+ if (!childStat) return null;
393
+ return buildTreeAsync(childAbs, childRel, localFilter);
394
+ });
395
+ const resolved = await Promise.all(childPromises);
396
+ const children = resolved.filter((c): c is TreeNode => c !== null);
397
+ children.sort((a, b) => {
398
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
399
+ return a.name.localeCompare(b.name);
400
+ });
401
+ return {
402
+ name: relPath ? path.basename(relPath) : "",
403
+ path: relPath,
404
+ type: "dir",
405
+ modifiedMs: stat.mtimeMs,
406
+ children,
407
+ };
408
+ }
409
+
410
+ // Shallow variant: return the given directory's immediate children
411
+ // only (no recursion). Used by the lazy-expand endpoint below — the
412
+ // client fetches one level at a time as the user expands nodes,
413
+ // so the initial Files view load cost is O(root entries) rather than
414
+ // O(all workspace files).
415
+ //
416
+ // Exported for unit tests.
417
+ export async function listDirShallow(absPath: string, relPath: string, gitFilter?: GitignoreFilter): Promise<TreeNode> {
418
+ const stat = await statSafeAsync(absPath);
419
+ if (!stat || !stat.isDirectory()) {
420
+ return {
421
+ name: relPath ? path.basename(relPath) : "",
422
+ path: relPath,
423
+ type: "dir",
424
+ children: [],
425
+ };
426
+ }
427
+ const entries = await readDirSafeAsync(absPath);
428
+ // When gitFilter is undefined (workspace root), DON'T read the
429
+ // root .gitignore (it's for git, not the UI). Pass a fresh empty
430
+ // filter so children pick up THEIR .gitignore files.
431
+ const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
432
+ const childPromises: Promise<TreeNode | null>[] = entries.map(async (entry): Promise<TreeNode | null> => {
433
+ if (HIDDEN_DIRS.has(entry.name)) return null;
434
+ if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
435
+ if (entry.isSymbolicLink()) return null;
436
+ const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
437
+ if (localFilter) {
438
+ const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
439
+ if (localFilter.ignores(testPath)) return null;
440
+ }
441
+ const childAbs = path.join(absPath, entry.name);
442
+ const childStat = await statSafeAsync(childAbs);
443
+ if (!childStat) return null;
444
+ if (childStat.isDirectory()) {
445
+ return {
446
+ name: entry.name,
447
+ path: childRel,
448
+ type: "dir",
449
+ modifiedMs: childStat.mtimeMs,
450
+ // No `children` field — caller fetches via another
451
+ // /api/files/dir call on expand.
452
+ };
453
+ }
454
+ return {
455
+ name: entry.name,
456
+ path: childRel,
457
+ type: "file",
458
+ size: childStat.size,
459
+ modifiedMs: childStat.mtimeMs,
460
+ };
461
+ });
462
+ const resolved = await Promise.all(childPromises);
463
+ const children = resolved.filter((c): c is TreeNode => c !== null);
464
+ children.sort((a, b) => {
465
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
466
+ return a.name.localeCompare(b.name);
467
+ });
468
+ return {
469
+ name: relPath ? path.basename(relPath) : "",
470
+ path: relPath,
471
+ type: "dir",
472
+ modifiedMs: stat.mtimeMs,
473
+ children,
474
+ };
475
+ }
476
+
477
+ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown, object>, res: Response<TreeNode | ErrorResponse>) => {
478
+ try {
479
+ // Start with an empty filter — the workspace root's .gitignore
480
+ // is for git (excluding github/ from commits), NOT for the
481
+ // Files UI. Only .gitignore files inside subdirectories (e.g.
482
+ // github/mulmoclaude/.gitignore) are applied.
483
+ // Pass undefined = skip workspace root .gitignore (it's for
484
+ // git, not the UI). Sub-dir .gitignore files still apply.
485
+ const tree = await buildTreeAsync(workspaceReal, "");
486
+ res.json(tree);
487
+ } catch (err) {
488
+ res.status(500).json({ error: `Failed to read workspace: ${errorMessage(err)}` });
489
+ }
490
+ });
491
+
492
+ // Lazy-expand endpoint. Returns one directory's immediate children
493
+ // (no recursion) so the client can render the tree incrementally.
494
+ // `path` is optional; empty / missing = workspace root.
495
+ 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
+
498
+ // Reference directory branch — resolve against the registered ref dir
499
+ if (isRefPath(relPath)) {
500
+ const absPath = resolveRefPath(relPath);
501
+ if (!absPath) {
502
+ notFound(res, "Not found");
503
+ return;
504
+ }
505
+ const stat = await statSafeAsync(absPath);
506
+ if (!stat || !stat.isDirectory()) {
507
+ notFound(res, "Not found");
508
+ return;
509
+ }
510
+ const node = await listDirShallow(absPath, relPath, undefined);
511
+ res.json(node);
512
+ return;
513
+ }
514
+
515
+ // Workspace path — existing logic
516
+ const absPath = resolveSafe(relPath);
517
+ if (!absPath) {
518
+ notFound(res, "Not found");
519
+ return;
520
+ }
521
+ const stat = await statSafeAsync(absPath);
522
+ if (!stat) {
523
+ notFound(res, "Not found");
524
+ return;
525
+ }
526
+ if (!stat.isDirectory()) {
527
+ badRequest(res, "path is not a directory");
528
+ return;
529
+ }
530
+ try {
531
+ // Build the gitignore filter chain. Start undefined at root
532
+ // (workspace root .gitignore is for git, not the UI). Once we
533
+ // descend into a sub-dir, childForDir picks up local .gitignore.
534
+ let filter: GitignoreFilter | undefined;
535
+ const segments = path.relative(workspaceReal, absPath).split(path.sep).filter(Boolean);
536
+ let walkAbs = workspaceReal;
537
+ for (const seg of segments) {
538
+ walkAbs = path.join(walkAbs, seg);
539
+ filter = filter ? filter.childForDir(walkAbs) : new GitignoreFilter().childForDir(walkAbs);
540
+ }
541
+ const listing = await listDirShallow(absPath, path.relative(workspaceReal, absPath), filter);
542
+ res.json(listing);
543
+ } catch (err) {
544
+ serverError(res, `Failed to read directory: ${errorMessage(err)}`);
545
+ }
546
+ });
547
+
548
+ interface PathQuery {
549
+ path?: string;
550
+ }
551
+
552
+ // Shared validation preamble for /files/content and /files/raw. Both
553
+ // endpoints need to: read `path` from the query, validate it's
554
+ // inside the workspace (with symlink hardening), stat it, and
555
+ // confirm it's a regular file. On any failure this writes the
556
+ // appropriate 4xx response and returns null; the caller bails out.
557
+ //
558
+ // `T` lets each caller's Response type stay precise — both endpoints
559
+ // have different success-shape unions and we just need ErrorResponse
560
+ // to be one of the alternatives.
561
+ //
562
+ // Order matters: stat the syntactic candidate first so a missing
563
+ // file gets a 404, then run the realpath-hardened resolveSafe check
564
+ // for symlink escapes (which would return 400). Doing them in this
565
+ // order keeps 404 reachable for the common "file not found" case
566
+ // instead of conflating it with traversal attempts.
567
+ function resolveAndStatFile<T>(
568
+ req: Request<object, unknown, unknown, PathQuery>,
569
+ res: Response<T | ErrorResponse>,
570
+ ): { relPath: string; absPath: string; stat: fs.Stats } | null {
571
+ const relPath = typeof req.query.path === "string" ? req.query.path : "";
572
+ if (!relPath) {
573
+ badRequest(res, "path required");
574
+ return null;
575
+ }
576
+
577
+ // Reference directory branch
578
+ if (isRefPath(relPath)) {
579
+ const absPath = resolveRefPath(relPath);
580
+ if (!absPath) {
581
+ notFound(res, "Not found");
582
+ return null;
583
+ }
584
+ const stat = statSafe(absPath);
585
+ if (!stat || !stat.isFile()) {
586
+ notFound(res, "File not found");
587
+ return null;
588
+ }
589
+ return { relPath, absPath, stat };
590
+ }
591
+
592
+ // Workspace path — existing logic
593
+ // Syntactic candidate (no symlink resolution yet).
594
+ const candidate = path.resolve(workspaceReal, path.normalize(relPath));
595
+ const stat = statSafe(candidate);
596
+ if (!stat) {
597
+ // Distinguish "missing file under workspace" (404) from "path
598
+ // syntactically outside workspace" (400). We check the
599
+ // syntactic relative form, NOT realpath, because the file
600
+ // doesn't exist so realpath would throw anyway.
601
+ const relativeFromWorkspace = path.relative(workspaceReal, candidate);
602
+ const escapesSyntactically = relativeFromWorkspace === ".." || relativeFromWorkspace.startsWith(`..${path.sep}`);
603
+ if (escapesSyntactically) {
604
+ badRequest(res, "Path outside workspace");
605
+ } else {
606
+ notFound(res, "File not found");
607
+ }
608
+ return null;
609
+ }
610
+ if (!stat.isFile()) {
611
+ badRequest(res, "Not a file");
612
+ return null;
613
+ }
614
+ // File exists — run the realpath-hardened check to defeat
615
+ // symlink-escape attempts (e.g. workspace/secret → /etc/passwd).
616
+ // resolveSafe also rejects paths that traverse a hidden dir.
617
+ const absPath = resolveSafe(relPath);
618
+ if (!absPath) {
619
+ badRequest(res, "Path outside workspace");
620
+ return null;
621
+ }
622
+ return { relPath, absPath, stat };
623
+ }
624
+
625
+ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, PathQuery>, res: Response<FileContentResponse | ErrorResponse>) => {
626
+ const ctx = resolveAndStatFile(req, res);
627
+ if (!ctx) return;
628
+ const { relPath, absPath, stat } = ctx;
629
+
630
+ const meta = {
631
+ path: relPath,
632
+ size: stat.size,
633
+ modifiedMs: stat.mtimeMs,
634
+ };
635
+
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) {
640
+ res.json({
641
+ kind: "too-large",
642
+ ...meta,
643
+ message: `File too large to preview (${stat.size} bytes)`,
644
+ });
645
+ return;
646
+ }
647
+
648
+ const kind = classify(absPath);
649
+ if (kind === "image" || kind === "pdf" || kind === "audio" || kind === "video") {
650
+ res.json({ kind, ...meta });
651
+ return;
652
+ }
653
+ if (kind === "binary") {
654
+ res.json({
655
+ kind: "binary",
656
+ ...meta,
657
+ message: "Binary file — preview not supported",
658
+ });
659
+ return;
660
+ }
661
+ if (stat.size > MAX_PREVIEW_BYTES) {
662
+ res.json({
663
+ kind: "too-large",
664
+ ...meta,
665
+ message: `Text file too large to preview (${stat.size} bytes)`,
666
+ });
667
+ return;
668
+ }
669
+ let content: string;
670
+ try {
671
+ content = fs.readFileSync(absPath, "utf-8");
672
+ } catch (err) {
673
+ res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
674
+ return;
675
+ }
676
+ res.json({ kind: "text", ...meta, content });
677
+ });
678
+
679
+ // Write the body of an existing text file. Only text-classified files
680
+ // (per `classify`) are editable — binary, image, audio, etc. are
681
+ // refused so the endpoint can't be used to ship arbitrary uploads.
682
+ // The file must already exist; creating new files is out of scope.
683
+ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteContentRequest>, res: Response<WriteContentResponse | ErrorResponse>) => {
684
+ const { path: relPathRaw, content: contentRaw } = req.body ?? {};
685
+ if (typeof relPathRaw !== "string" || relPathRaw.length === 0) {
686
+ badRequest(res, "path required");
687
+ return;
688
+ }
689
+ if (typeof contentRaw !== "string") {
690
+ badRequest(res, "content required");
691
+ return;
692
+ }
693
+ if (Buffer.byteLength(contentRaw, "utf-8") > MAX_PREVIEW_BYTES) {
694
+ badRequest(res, `content exceeds ${MAX_PREVIEW_BYTES} byte limit`);
695
+ return;
696
+ }
697
+ // Two-step resolution to distinguish "path outside workspace" (400)
698
+ // from "file does not exist" (404): realpath throws on ENOENT, so
699
+ // resolveSafe conflates the two. Stat the syntactic candidate
700
+ // first; if it exists, THEN run the symlink-hardened resolveSafe.
701
+ const candidate = path.resolve(workspaceReal, path.normalize(relPathRaw));
702
+ const existing = await statSafeAsync(candidate);
703
+ if (!existing) {
704
+ const relativeFromWorkspace = path.relative(workspaceReal, candidate);
705
+ const escapesSyntactically = relativeFromWorkspace === ".." || relativeFromWorkspace.startsWith(`..${path.sep}`);
706
+ if (escapesSyntactically) {
707
+ badRequest(res, "Path outside workspace");
708
+ } else {
709
+ notFound(res, "File not found");
710
+ }
711
+ return;
712
+ }
713
+ if (!existing.isFile()) {
714
+ badRequest(res, "Not a file");
715
+ return;
716
+ }
717
+ const absPath = resolveSafe(relPathRaw);
718
+ if (!absPath) {
719
+ badRequest(res, "Path outside workspace");
720
+ return;
721
+ }
722
+ if (classify(absPath) !== "text") {
723
+ badRequest(res, "File type not editable");
724
+ return;
725
+ }
726
+ try {
727
+ // `uniqueTmp: true` appends a randomUUID to the tmp filename so
728
+ // two simultaneous PUTs to the same path can't clobber each
729
+ // other's staging file and race through the rename.
730
+ await writeFileAtomic(absPath, contentRaw, { uniqueTmp: true });
731
+ } catch (err) {
732
+ serverError(res, `Failed to write file: ${errorMessage(err)}`);
733
+ return;
734
+ }
735
+ const fresh = await statSafeAsync(absPath);
736
+ res.json({
737
+ path: relPathRaw,
738
+ size: fresh?.size ?? Buffer.byteLength(contentRaw, "utf-8"),
739
+ modifiedMs: fresh?.mtimeMs ?? Date.now(),
740
+ });
741
+ });
742
+
743
+ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQuery>, res: Response<ErrorResponse>) => {
744
+ const ctx = resolveAndStatFile(req, res);
745
+ if (!ctx) return;
746
+ const { absPath, stat } = ctx;
747
+
748
+ if (stat.size > MAX_RAW_BYTES) {
749
+ sendError(res, 413, `File too large to stream (${stat.size} bytes, limit ${MAX_RAW_BYTES})`);
750
+ return;
751
+ }
752
+ const ext = path.extname(absPath).toLowerCase();
753
+ const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
754
+ res.setHeader("Accept-Ranges", "bytes");
755
+ res.setHeader("Content-Type", mime);
756
+ // Sandbox the response so an `.svg` / `.html` / `.pdf` with
757
+ // embedded JavaScript can't escape into the localhost:3001
758
+ // origin via direct navigation or <iframe>. See
759
+ // plans/done/fix-files-raw-csp-sandbox.md for the threat model.
760
+ applyRawSecurityHeaders(res);
761
+
762
+ // Range support is required for `<video>` playback (Safari refuses
763
+ // to play media without 206 responses) and for seek-past-buffered
764
+ // in `<audio>`. When no Range header is sent we fall through to
765
+ // the existing full-file pipe.
766
+ const rangeHeader = req.headers.range;
767
+ if (rangeHeader) {
768
+ const range = parseRange(rangeHeader, stat.size);
769
+ if (!range) {
770
+ // The media MIME was set above so the 206 success path
771
+ // doesn't have to repeat it, but on a 416 we want JSON so
772
+ // `res.json` doesn't lie about the body's content-type. Set
773
+ // the Content-Range per RFC 7233 §4.4 before sending.
774
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
775
+ res.setHeader("Content-Range", `bytes */${stat.size}`);
776
+ sendError(res, 416, "Range not satisfiable");
777
+ return;
778
+ }
779
+ res.status(206);
780
+ res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
781
+ res.setHeader("Content-Length", String(range.end - range.start + 1));
782
+ pipeWithErrorHandling(fs.createReadStream(absPath, { start: range.start, end: range.end }), res);
783
+ return;
784
+ }
785
+
786
+ res.setHeader("Content-Length", String(stat.size));
787
+ pipeWithErrorHandling(fs.createReadStream(absPath), res);
788
+ });
789
+
790
+ // ── Reference directory roots ───────────────────────────────────
791
+ //
792
+ // Returns configured reference directories as top-level TreeNode[]
793
+ // for the file explorer. Each node's path uses the @ref/<label>
794
+ // prefix so subsequent /dir and /content requests route correctly.
795
+
796
+ router.get(API_ROUTES.files.refRoots, async (_req: Request, res: Response<TreeNode[]>) => {
797
+ const entries = getCachedReferenceDirs();
798
+ const nodes: TreeNode[] = [];
799
+ for (const entry of entries) {
800
+ const stat = await statSafeAsync(entry.hostPath);
801
+ if (!stat || !stat.isDirectory()) continue;
802
+ nodes.push({
803
+ name: entry.label,
804
+ path: `${REF_PREFIX}${entry.label}`,
805
+ type: "dir",
806
+ modifiedMs: stat.mtimeMs,
807
+ });
808
+ }
809
+ res.json(nodes);
810
+ });
811
+
812
+ export default router;