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.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// CSRF defense: reject cross-origin state-changing requests.
|
|
2
|
+
//
|
|
3
|
+
// Complements the CORS / localhost-bind hardening in #148. With
|
|
4
|
+
// those in place, the browser refuses to expose response bodies
|
|
5
|
+
// to cross-origin callers, but the **request itself** still
|
|
6
|
+
// reaches the server. That's enough for a fire-and-forget side
|
|
7
|
+
// effect (e.g. `POST /api/chat-index/rebuild` spawning claude CLI
|
|
8
|
+
// in the background) to be triggered from an attacker page.
|
|
9
|
+
//
|
|
10
|
+
// This middleware checks the Origin header on every non-safe
|
|
11
|
+
// method and rejects anything that didn't come from localhost.
|
|
12
|
+
// Requests with NO Origin header are allowed — that's how
|
|
13
|
+
// non-browser callers (MCP tools, curl, CLI scripts) look, and
|
|
14
|
+
// they're trustable only because the server binds to 127.0.0.1
|
|
15
|
+
// (#148) so remote traffic can't reach us at all.
|
|
16
|
+
//
|
|
17
|
+
// Full design + threat model: plans/done/fix-server-csrf-origin-check.md
|
|
18
|
+
|
|
19
|
+
import type { Request, Response, NextFunction } from "express";
|
|
20
|
+
import { log } from "../system/logger/index.js";
|
|
21
|
+
import { forbidden } from "../utils/httpError.js";
|
|
22
|
+
|
|
23
|
+
const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
24
|
+
|
|
25
|
+
const LOCALHOST_HOSTNAMES: ReadonlySet<string> = new Set([
|
|
26
|
+
"localhost",
|
|
27
|
+
"127.0.0.1",
|
|
28
|
+
// IPv6 loopback. Note `new URL("http://[::1]:5173").hostname`
|
|
29
|
+
// returns the literal string `[::1]` **with brackets** (the
|
|
30
|
+
// Node URL parser preserves them). So that's what we match.
|
|
31
|
+
// The un-bracketed `::1` is kept alongside as belt-and-
|
|
32
|
+
// suspenders in case a different parser implementation (older
|
|
33
|
+
// Node, a shim) ever strips them.
|
|
34
|
+
"[::1]",
|
|
35
|
+
"::1",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Decide whether an Origin header value points at the same
|
|
39
|
+
// machine. Accepts scheme + hostname + optional port; rejects
|
|
40
|
+
// `null`, empty, malformed, subdomain-lookalikes, non-loopback
|
|
41
|
+
// IPs, and non-HTTP schemes. Exported for test.
|
|
42
|
+
export function isLocalhostOrigin(origin: string): boolean {
|
|
43
|
+
if (!origin) return false;
|
|
44
|
+
let url: URL;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(origin);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return LOCALHOST_HOSTNAMES.has(url.hostname);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Express middleware. Safe-method requests (GET / HEAD / OPTIONS)
|
|
54
|
+
// pass through unchecked — they have no side effects per RFC 9110,
|
|
55
|
+
// and OPTIONS is required for CORS preflights anyway (even though
|
|
56
|
+
// we no longer advertise CORS, browsers still issue the preflight
|
|
57
|
+
// before some requests). Non-safe requests need an Origin header
|
|
58
|
+
// that resolves to localhost OR no Origin header at all.
|
|
59
|
+
export function requireSameOrigin(req: Request, res: Response, next: NextFunction): void {
|
|
60
|
+
if (SAFE_METHODS.has(req.method)) {
|
|
61
|
+
next();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const origin = req.headers.origin;
|
|
65
|
+
if (typeof origin !== "string") {
|
|
66
|
+
// Missing Origin: non-browser caller (curl, MCP, Node HTTP
|
|
67
|
+
// libraries). Trusted because the server binds to 127.0.0.1.
|
|
68
|
+
next();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (isLocalhostOrigin(origin)) {
|
|
72
|
+
next();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Security-relevant event: an upstream caller just hit us from
|
|
76
|
+
// off-localhost with a state-changing method. Log it at warn so
|
|
77
|
+
// operators see it in both the console and the rotating file
|
|
78
|
+
// log even if the attack is otherwise silent on the wire.
|
|
79
|
+
log.warn("csrf", "rejected cross-origin request", {
|
|
80
|
+
origin,
|
|
81
|
+
method: req.method,
|
|
82
|
+
path: req.path,
|
|
83
|
+
});
|
|
84
|
+
forbidden(res, "Forbidden: cross-origin request rejected");
|
|
85
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import { getSessionQuery } from "../../utils/request.js";
|
|
3
|
+
import {
|
|
4
|
+
createSessionMeta,
|
|
5
|
+
backfillFirstUserMessage as backfillMeta,
|
|
6
|
+
backfillOrigin,
|
|
7
|
+
readSessionMetaFull,
|
|
8
|
+
readSessionMeta,
|
|
9
|
+
setClaudeSessionId as setClaudeId,
|
|
10
|
+
clearClaudeSessionId as clearClaudeId,
|
|
11
|
+
appendSessionLine,
|
|
12
|
+
readSessionJsonl,
|
|
13
|
+
sessionJsonlAbsPath,
|
|
14
|
+
ensureChatDir,
|
|
15
|
+
} from "../../utils/files/session-io.js";
|
|
16
|
+
import { getRole } from "../../workspace/roles.js";
|
|
17
|
+
import { runAgent } from "../../agent/index.js";
|
|
18
|
+
import { prependJournalPointer } from "../../agent/prompt.js";
|
|
19
|
+
import { buildTranscriptPreamble, isStaleSessionError } from "../../agent/resumeFailover.js";
|
|
20
|
+
import { getOrCreateSession, beginRun, endRun, cancelRun, pushSessionEvent, pushToolResult, getActiveSessionIds } from "../../events/session-store/index.js";
|
|
21
|
+
import { workspacePath } from "../../workspace/workspace.js";
|
|
22
|
+
import { maybeRunJournal } from "../../workspace/journal/index.js";
|
|
23
|
+
import { maybeIndexSession } from "../../workspace/chat-index/index.js";
|
|
24
|
+
import { maybeAppendWikiBacklinks } from "../../workspace/wiki-backlinks/index.js";
|
|
25
|
+
import { log } from "../../system/logger/index.js";
|
|
26
|
+
import { logBackgroundError } from "../../utils/logBackgroundError.js";
|
|
27
|
+
import { createArgsCache, recordToolEvent } from "../../workspace/tool-trace/index.js";
|
|
28
|
+
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
29
|
+
import { EVENT_TYPES } from "../../../src/types/events.js";
|
|
30
|
+
import { isSessionOrigin } from "../../../src/types/session.js";
|
|
31
|
+
import { env } from "../../system/env.js";
|
|
32
|
+
import type { Attachment } from "@mulmobridge/protocol";
|
|
33
|
+
import { parseDataUrl } from "@mulmobridge/client";
|
|
34
|
+
|
|
35
|
+
const router = Router();
|
|
36
|
+
const PORT = env.port;
|
|
37
|
+
|
|
38
|
+
// Short, safe preview of tool args for logs. Full payload may contain
|
|
39
|
+
// base64 images or large blobs, so we cap it. The goal is to make a
|
|
40
|
+
// line like `mcp__deepwiki__read_wiki_contents` grep-able in logs
|
|
41
|
+
// alongside its args shape, not to record the full input.
|
|
42
|
+
const TOOL_ARGS_LOG_PREVIEW_MAX = 200;
|
|
43
|
+
function previewJson(value: unknown): string {
|
|
44
|
+
let serialised: string;
|
|
45
|
+
try {
|
|
46
|
+
serialised = JSON.stringify(value);
|
|
47
|
+
} catch {
|
|
48
|
+
return "[unserialisable]";
|
|
49
|
+
}
|
|
50
|
+
if (serialised === undefined) return "";
|
|
51
|
+
return serialised.length > TOOL_ARGS_LOG_PREVIEW_MAX ? `${serialised.slice(0, TOOL_ARGS_LOG_PREVIEW_MAX)}…` : serialised;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Called by the MCP server to push a ToolResult into the active session.
|
|
55
|
+
interface OkResponse {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
router.post(API_ROUTES.agent.internal.toolResult, async (req: Request<object, unknown, Record<string, unknown>>, res: Response<OkResponse>) => {
|
|
60
|
+
const chatSessionId = getSessionQuery(req);
|
|
61
|
+
const outcome = await pushToolResult(chatSessionId, req.body);
|
|
62
|
+
res.json({ ok: outcome.kind === "processed" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Called by the MCP server to trigger a role switch on the frontend
|
|
66
|
+
interface SwitchRoleBody {
|
|
67
|
+
roleId: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
router.post(API_ROUTES.agent.internal.switchRole, async (req: Request<object, unknown, SwitchRoleBody>, res: Response<OkResponse>) => {
|
|
71
|
+
const chatSessionId = getSessionQuery(req);
|
|
72
|
+
pushSessionEvent(chatSessionId, {
|
|
73
|
+
type: EVENT_TYPES.switchRole,
|
|
74
|
+
roleId: req.body.roleId,
|
|
75
|
+
});
|
|
76
|
+
res.json({ ok: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Cancel a running agent session by killing the Claude CLI process.
|
|
80
|
+
interface CancelBody {
|
|
81
|
+
chatSessionId: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
router.post(API_ROUTES.agent.cancel, (req: Request<object, unknown, CancelBody>, res: Response<OkResponse>) => {
|
|
85
|
+
const { chatSessionId } = req.body;
|
|
86
|
+
if (!chatSessionId) {
|
|
87
|
+
res.json({ ok: false });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ok = cancelRun(chatSessionId);
|
|
91
|
+
res.json({ ok });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── Internal API: startChat ─────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// Shared entry point for starting an agent chat. Called by both the
|
|
97
|
+
// POST /api/agent route and server-side callers (e.g. debug tasks).
|
|
98
|
+
|
|
99
|
+
export interface StartChatParams {
|
|
100
|
+
message: string;
|
|
101
|
+
roleId: string;
|
|
102
|
+
chatSessionId: string;
|
|
103
|
+
selectedImageData?: string;
|
|
104
|
+
attachments?: Attachment[];
|
|
105
|
+
/** Where this session originates (#486). Accepts string for
|
|
106
|
+
* cross-package compatibility (chat-service passes string). */
|
|
107
|
+
origin?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type StartChatResult = { kind: "started"; chatSessionId: string } | { kind: "error"; error: string; status?: number };
|
|
111
|
+
|
|
112
|
+
export async function startChat(params: StartChatParams): Promise<StartChatResult> {
|
|
113
|
+
const { message, roleId, chatSessionId, selectedImageData, attachments } = params;
|
|
114
|
+
|
|
115
|
+
if (!message || !roleId || !chatSessionId) {
|
|
116
|
+
return {
|
|
117
|
+
kind: "error",
|
|
118
|
+
error: "message, roleId, and chatSessionId are required",
|
|
119
|
+
status: 400,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ensureChatDir();
|
|
124
|
+
const resultsFilePath = sessionJsonlAbsPath(chatSessionId);
|
|
125
|
+
|
|
126
|
+
// Discriminate missing (first turn) from corrupt (warn, don't clobber).
|
|
127
|
+
const metaResult = await readSessionMetaFull(chatSessionId);
|
|
128
|
+
const isFirstTurn = metaResult.kind === "missing";
|
|
129
|
+
if (metaResult.kind === "corrupt") {
|
|
130
|
+
log.warn("agent", "session meta is corrupt — treating as existing", {
|
|
131
|
+
chatSessionId,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const persistedHasUnread = metaResult.kind === "ok" && metaResult.meta.hasUnread === true ? true : undefined;
|
|
135
|
+
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
getOrCreateSession(chatSessionId, {
|
|
138
|
+
roleId,
|
|
139
|
+
resultsFilePath,
|
|
140
|
+
selectedImageData,
|
|
141
|
+
startedAt: now,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
hasUnread: persistedHasUnread,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Register abort callback and mark running FIRST. If the session
|
|
147
|
+
// is already running, reject with 409 before we persist anything.
|
|
148
|
+
// Writing the user message to jsonl or broadcasting it before this
|
|
149
|
+
// check leaves an orphan message on disk + in every viewing tab
|
|
150
|
+
// when the run is rejected — see #281.
|
|
151
|
+
const abortController = new AbortController();
|
|
152
|
+
const started = beginRun(chatSessionId, () => abortController.abort());
|
|
153
|
+
if (!started) {
|
|
154
|
+
return { kind: "error", error: "Session is already running", status: 409 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Run is committed. Now persist the user message so callers (and
|
|
158
|
+
// other tabs) see the turn. Metadata first — it powers the sidebar
|
|
159
|
+
// title cache; the append follows so the jsonl is always a
|
|
160
|
+
// superset of what metadata advertised.
|
|
161
|
+
const validOrigin = isSessionOrigin(params.origin) ? params.origin : undefined;
|
|
162
|
+
if (isFirstTurn) {
|
|
163
|
+
await createSessionMeta(chatSessionId, roleId, message, undefined, validOrigin);
|
|
164
|
+
} else {
|
|
165
|
+
await backfillMeta(chatSessionId, message);
|
|
166
|
+
if (validOrigin) {
|
|
167
|
+
await backfillOrigin(chatSessionId, validOrigin);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Append user message for this turn
|
|
172
|
+
await appendSessionLine(chatSessionId, JSON.stringify({ source: "user", type: EVENT_TYPES.text, message }));
|
|
173
|
+
|
|
174
|
+
// Broadcast the user message so other tabs viewing this session
|
|
175
|
+
// see the input in real time. Runs AFTER beginRun so a 409 never
|
|
176
|
+
// produces a phantom user message in other clients.
|
|
177
|
+
pushSessionEvent(chatSessionId, {
|
|
178
|
+
type: EVENT_TYPES.text,
|
|
179
|
+
source: "user",
|
|
180
|
+
message,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const role = getRole(roleId);
|
|
184
|
+
const claudeSessionId = await readClaudeSessionIdFromSession(chatSessionId);
|
|
185
|
+
|
|
186
|
+
const requestStartedAt = Date.now();
|
|
187
|
+
log.info("agent", "request received", {
|
|
188
|
+
chatSessionId,
|
|
189
|
+
roleId,
|
|
190
|
+
messageLen: message.length,
|
|
191
|
+
resumed: Boolean(claudeSessionId),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const decoratedMessage = claudeSessionId ? message : prependJournalPointer(message, workspacePath);
|
|
195
|
+
|
|
196
|
+
runAgentInBackground({
|
|
197
|
+
decoratedMessage,
|
|
198
|
+
role,
|
|
199
|
+
chatSessionId,
|
|
200
|
+
claudeSessionId,
|
|
201
|
+
abortSignal: abortController.signal,
|
|
202
|
+
resultsFilePath,
|
|
203
|
+
requestStartedAt,
|
|
204
|
+
toolArgsCache: createArgsCache(),
|
|
205
|
+
attachments: mergeAttachments(selectedImageData, attachments),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { kind: "started", chatSessionId };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** Convert legacy `selectedImageData` (data URL from the Vue UI) into
|
|
214
|
+
* the generic Attachment format, then merge with any explicitly-
|
|
215
|
+
* provided attachments from the bridge protocol. Returns undefined
|
|
216
|
+
* when there's nothing to attach. */
|
|
217
|
+
function mergeAttachments(selectedImageData: string | undefined, explicit: Attachment[] | undefined): Attachment[] | undefined {
|
|
218
|
+
const result: Attachment[] = [];
|
|
219
|
+
if (selectedImageData) {
|
|
220
|
+
const parsed = parseDataUrl(selectedImageData);
|
|
221
|
+
if (parsed) {
|
|
222
|
+
result.push({ mimeType: parsed.mimeType, data: parsed.data });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (explicit) {
|
|
226
|
+
result.push(...explicit);
|
|
227
|
+
}
|
|
228
|
+
return result.length > 0 ? result : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── HTTP route ──────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
// HTTP route body — used by the Vue UI only. `selectedImageData` is
|
|
234
|
+
// the legacy data-URL path; new bridge clients send `attachments`
|
|
235
|
+
// via the socket relay instead. mergeAttachments() unifies both
|
|
236
|
+
// paths inside startChat(). See #382 for the rationale.
|
|
237
|
+
interface AgentBody {
|
|
238
|
+
message: string;
|
|
239
|
+
roleId: string;
|
|
240
|
+
chatSessionId: string;
|
|
241
|
+
selectedImageData?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface ErrorResponse {
|
|
245
|
+
error: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface AcceptedResponse {
|
|
249
|
+
chatSessionId: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
router.post(API_ROUTES.agent.run, async (req: Request<object, unknown, AgentBody>, res: Response<ErrorResponse | AcceptedResponse>) => {
|
|
253
|
+
const result = await startChat(req.body);
|
|
254
|
+
if (result.kind === "error") {
|
|
255
|
+
res.status(result.status ?? 500).json({ error: result.error });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
res.status(202).json({ chatSessionId: result.chatSessionId });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Runs the agent loop as a detached async task. Events are published
|
|
262
|
+
// to the session's pub/sub channel. When the loop ends, `endRun` is
|
|
263
|
+
// called to mark the session as finished and publish `session_finished`.
|
|
264
|
+
interface BackgroundRunParams {
|
|
265
|
+
decoratedMessage: string;
|
|
266
|
+
role: ReturnType<typeof getRole>;
|
|
267
|
+
chatSessionId: string;
|
|
268
|
+
claudeSessionId: string | undefined;
|
|
269
|
+
abortSignal: AbortSignal;
|
|
270
|
+
resultsFilePath: string;
|
|
271
|
+
requestStartedAt: number;
|
|
272
|
+
toolArgsCache: ReturnType<typeof createArgsCache>;
|
|
273
|
+
attachments: Attachment[] | undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Per-event side-effect context passed to `handleAgentEvent`.
|
|
277
|
+
// `textAccumulator` collects streaming text chunks so we write
|
|
278
|
+
// one consolidated line to the jsonl instead of per-chunk lines
|
|
279
|
+
// (which would appear as separate cards on session reload).
|
|
280
|
+
interface EventContext {
|
|
281
|
+
chatSessionId: string;
|
|
282
|
+
resultsFilePath: string;
|
|
283
|
+
toolArgsCache: ReturnType<typeof createArgsCache>;
|
|
284
|
+
textAccumulator: string[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Returns true if the event was handled "out of band" (no pub-sub
|
|
288
|
+
// broadcast, no jsonl append). Right now only `claudeSessionId`
|
|
289
|
+
// events fall into that bucket — they update meta and are otherwise
|
|
290
|
+
// invisible to clients. Everything else is treated as "normal flow":
|
|
291
|
+
// broadcast + optional jsonl append + optional tool-trace side effect.
|
|
292
|
+
async function handleAgentEvent(event: Awaited<ReturnType<typeof runAgent>> extends AsyncGenerator<infer E> ? E : never, ctx: EventContext): Promise<void> {
|
|
293
|
+
if (event.type === EVENT_TYPES.claudeSessionId) {
|
|
294
|
+
await flushTextAccumulator(ctx);
|
|
295
|
+
await setClaudeId(ctx.chatSessionId, event.id);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
pushSessionEvent(ctx.chatSessionId, event as Record<string, unknown>);
|
|
299
|
+
|
|
300
|
+
if (event.type === EVENT_TYPES.text) {
|
|
301
|
+
// Accumulate text chunks instead of writing each one to jsonl.
|
|
302
|
+
// Flushed when a non-text event arrives (preserving jsonl order
|
|
303
|
+
// relative to tool events) or when the run ends.
|
|
304
|
+
ctx.textAccumulator.push(event.message);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Any non-text event marks the end of a text burst — flush so
|
|
308
|
+
// jsonl order matches the live stream and crashes mid-run don't
|
|
309
|
+
// lose already-streamed text.
|
|
310
|
+
await flushTextAccumulator(ctx);
|
|
311
|
+
if (event.type === EVENT_TYPES.toolCall) {
|
|
312
|
+
log.info("agent-tool", "call", {
|
|
313
|
+
chatSessionId: ctx.chatSessionId,
|
|
314
|
+
toolName: event.toolName,
|
|
315
|
+
toolUseId: event.toolUseId,
|
|
316
|
+
argsPreview: previewJson(event.args),
|
|
317
|
+
});
|
|
318
|
+
} else if (event.type === EVENT_TYPES.toolCallResult) {
|
|
319
|
+
// Look up the toolName from the cache *before* recordToolEvent
|
|
320
|
+
// runs (it deletes the cache entry on result).
|
|
321
|
+
const cached = ctx.toolArgsCache.get(event.toolUseId);
|
|
322
|
+
log.info("agent-tool", "result", {
|
|
323
|
+
chatSessionId: ctx.chatSessionId,
|
|
324
|
+
toolName: cached?.toolName,
|
|
325
|
+
toolUseId: event.toolUseId,
|
|
326
|
+
contentBytes: event.content.length,
|
|
327
|
+
});
|
|
328
|
+
} else {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Fire-and-forget: tool-trace persistence failures must not block
|
|
332
|
+
// the agent loop. Errors are log.warn'd by recordToolEvent itself.
|
|
333
|
+
recordToolEvent(event, {
|
|
334
|
+
workspaceRoot: workspacePath,
|
|
335
|
+
chatSessionId: ctx.chatSessionId,
|
|
336
|
+
resultsFilePath: ctx.resultsFilePath,
|
|
337
|
+
argsCache: ctx.toolArgsCache,
|
|
338
|
+
}).catch(logBackgroundError("tool-trace"));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Write the accumulated streaming text chunks as one consolidated
|
|
342
|
+
// jsonl line. Called at the end of each agent run (success or error)
|
|
343
|
+
// so the session transcript has exactly one assistant text entry
|
|
344
|
+
// per response, not N per-chunk entries.
|
|
345
|
+
async function flushTextAccumulator(ctx: EventContext): Promise<void> {
|
|
346
|
+
if (ctx.textAccumulator.length === 0) return;
|
|
347
|
+
const fullText = ctx.textAccumulator.join("");
|
|
348
|
+
ctx.textAccumulator.length = 0;
|
|
349
|
+
if (!fullText) return;
|
|
350
|
+
await appendSessionLine(
|
|
351
|
+
ctx.chatSessionId,
|
|
352
|
+
JSON.stringify({
|
|
353
|
+
source: "assistant",
|
|
354
|
+
type: EVENT_TYPES.text,
|
|
355
|
+
message: fullText,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function runAgentInBackground(params: BackgroundRunParams): Promise<void> {
|
|
361
|
+
const { decoratedMessage, role, chatSessionId, claudeSessionId, abortSignal, resultsFilePath, requestStartedAt, toolArgsCache, attachments } = params;
|
|
362
|
+
|
|
363
|
+
const eventCtx: EventContext = {
|
|
364
|
+
chatSessionId,
|
|
365
|
+
resultsFilePath,
|
|
366
|
+
toolArgsCache,
|
|
367
|
+
textAccumulator: [],
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Retry budget for the stale `--resume` id fail-over (#211). Only
|
|
371
|
+
// meaningful when we entered with a `claudeSessionId`; a fresh
|
|
372
|
+
// session can't hit that error. One retry max so a looping CLI
|
|
373
|
+
// bug can't stack infinite replays of the transcript.
|
|
374
|
+
let failoverAttemptsRemaining = claudeSessionId ? 1 : 0;
|
|
375
|
+
let currentMessage = decoratedMessage;
|
|
376
|
+
let currentClaudeSessionId = claudeSessionId;
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
while (true) {
|
|
380
|
+
let staleSessionDetected = false;
|
|
381
|
+
for await (const event of runAgent(currentMessage, role, workspacePath, chatSessionId, PORT, currentClaudeSessionId, abortSignal, attachments)) {
|
|
382
|
+
if (failoverAttemptsRemaining > 0 && event.type === EVENT_TYPES.error && typeof event.message === "string" && isStaleSessionError(event.message)) {
|
|
383
|
+
// Swallow the error — we're about to recover. `break`
|
|
384
|
+
// abandons the current generator; since the event is only
|
|
385
|
+
// yielded after the CLI has already exited non-zero, the
|
|
386
|
+
// subprocess is dead by this point and there's nothing to
|
|
387
|
+
// clean up beyond what `for await`'s return() already does.
|
|
388
|
+
staleSessionDetected = true;
|
|
389
|
+
failoverAttemptsRemaining--;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
await handleAgentEvent(event, eventCtx);
|
|
393
|
+
}
|
|
394
|
+
if (!staleSessionDetected) break;
|
|
395
|
+
|
|
396
|
+
// Stale `--resume` recovery: clear the bad id from meta so the
|
|
397
|
+
// next *external* read of this session doesn't see it, build a
|
|
398
|
+
// natural-language preamble from the jsonl we already have,
|
|
399
|
+
// and loop back to `runAgent` without `--resume`. Surface a
|
|
400
|
+
// status event so the UI pause doesn't look like a hang.
|
|
401
|
+
log.warn("agent", "stale claude session id — retrying without --resume", {
|
|
402
|
+
chatSessionId,
|
|
403
|
+
});
|
|
404
|
+
await clearClaudeId(chatSessionId);
|
|
405
|
+
const preamble = await readTranscriptPreamble(chatSessionId);
|
|
406
|
+
currentMessage = preamble ? `${preamble}${decoratedMessage}` : decoratedMessage;
|
|
407
|
+
currentClaudeSessionId = undefined;
|
|
408
|
+
pushSessionEvent(chatSessionId, {
|
|
409
|
+
type: EVENT_TYPES.status,
|
|
410
|
+
message: "Previous session unavailable — continuing with local transcript.",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
// Flush any accumulated streaming text as a single consolidated
|
|
414
|
+
// line in the jsonl. This prevents per-chunk lines that would
|
|
415
|
+
// appear as separate cards on session reload.
|
|
416
|
+
await flushTextAccumulator(eventCtx);
|
|
417
|
+
|
|
418
|
+
log.info("agent", "request completed", {
|
|
419
|
+
chatSessionId,
|
|
420
|
+
durationMs: Date.now() - requestStartedAt,
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
await flushTextAccumulator(eventCtx);
|
|
424
|
+
log.error("agent", "request failed", {
|
|
425
|
+
chatSessionId,
|
|
426
|
+
error: String(err),
|
|
427
|
+
});
|
|
428
|
+
pushSessionEvent(chatSessionId, {
|
|
429
|
+
type: EVENT_TYPES.error,
|
|
430
|
+
message: String(err),
|
|
431
|
+
});
|
|
432
|
+
} finally {
|
|
433
|
+
endRun(chatSessionId);
|
|
434
|
+
// Fire-and-forget: journal + chat-index post-processing
|
|
435
|
+
maybeRunJournal({ activeSessionIds: getActiveSessionIds() }).catch(logBackgroundError("journal"));
|
|
436
|
+
maybeIndexSession({
|
|
437
|
+
sessionId: chatSessionId,
|
|
438
|
+
activeSessionIds: getActiveSessionIds(),
|
|
439
|
+
}).catch(logBackgroundError("chat-index"));
|
|
440
|
+
// Walks wiki/pages/ for files modified during this turn and
|
|
441
|
+
// appends a backlink to the originating chat session so the
|
|
442
|
+
// user can jump back from a wiki page to the conversation
|
|
443
|
+
// that created it. See #109.
|
|
444
|
+
maybeAppendWikiBacklinks({
|
|
445
|
+
chatSessionId,
|
|
446
|
+
turnStartedAt: requestStartedAt,
|
|
447
|
+
}).catch(logBackgroundError("wiki-backlinks"));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Read claudeSessionId from meta (primary) or jsonl (legacy fallback).
|
|
452
|
+
async function readClaudeSessionIdFromSession(chatSessionId: string): Promise<string | undefined> {
|
|
453
|
+
const meta = await readSessionMeta(chatSessionId);
|
|
454
|
+
if (meta?.claudeSessionId) return meta.claudeSessionId as string;
|
|
455
|
+
// Legacy scan: search jsonl lines backwards for a claudeSessionId event
|
|
456
|
+
const jsonl = await readSessionJsonl(chatSessionId);
|
|
457
|
+
if (!jsonl) return undefined;
|
|
458
|
+
const lines = jsonl.split("\n").filter(Boolean);
|
|
459
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
460
|
+
try {
|
|
461
|
+
const entry = JSON.parse(lines[i]);
|
|
462
|
+
if (entry.type === EVENT_TYPES.claudeSessionId && entry.id) return entry.id;
|
|
463
|
+
} catch {
|
|
464
|
+
// skip malformed lines
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Read the session jsonl and render the transcript preamble used on
|
|
471
|
+
// `--resume` fail-over.
|
|
472
|
+
async function readTranscriptPreamble(chatSessionId: string): Promise<string> {
|
|
473
|
+
const jsonl = await readSessionJsonl(chatSessionId);
|
|
474
|
+
if (!jsonl) return "";
|
|
475
|
+
return buildTranscriptPreamble(jsonl);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export default router;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import { WORKSPACE_DIRS } from "../../workspace/paths.js";
|
|
3
|
+
import { writeWorkspaceText } from "../../utils/files/workspace-io.js";
|
|
4
|
+
import { buildArtifactPath } from "../../utils/files/naming.js";
|
|
5
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
6
|
+
import { badRequest, serverError } from "../../utils/httpError.js";
|
|
7
|
+
import { isRecord } from "../../utils/types.js";
|
|
8
|
+
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
|
|
12
|
+
// See plans/done/feat-chart-plugin.md for the full design. The LLM sends an
|
|
13
|
+
// ECharts option object per chart; we persist the whole document to
|
|
14
|
+
// <workspace>/charts/<slug>-<timestamp>.chart.json so it can be
|
|
15
|
+
// browsed in the files explorer and (eventually) wikified.
|
|
16
|
+
|
|
17
|
+
interface ChartEntry {
|
|
18
|
+
title?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
option: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ChartDocument {
|
|
24
|
+
title?: string;
|
|
25
|
+
charts: ChartEntry[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PresentChartBody {
|
|
29
|
+
document?: ChartDocument;
|
|
30
|
+
title?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PresentChartSuccessResponse {
|
|
34
|
+
message: string;
|
|
35
|
+
instructions: string;
|
|
36
|
+
data: { document: ChartDocument; title?: string; filePath: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PresentChartErrorResponse {
|
|
40
|
+
error: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type PresentChartResponse = PresentChartSuccessResponse | PresentChartErrorResponse;
|
|
44
|
+
|
|
45
|
+
function isOptionalString(value: unknown): boolean {
|
|
46
|
+
return value === undefined || typeof value === "string";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isValidChartDocument(value: unknown): value is ChartDocument {
|
|
50
|
+
if (!isRecord(value)) return false;
|
|
51
|
+
const candidate = value;
|
|
52
|
+
if (!isOptionalString(candidate.title)) return false;
|
|
53
|
+
if (!Array.isArray(candidate.charts)) return false;
|
|
54
|
+
if (candidate.charts.length === 0) return false;
|
|
55
|
+
return candidate.charts.every((entry) => isValidChartEntry(entry));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isValidChartEntry(value: unknown): value is ChartEntry {
|
|
59
|
+
if (!isRecord(value)) return false;
|
|
60
|
+
const candidate = value;
|
|
61
|
+
if (!isOptionalString(candidate.title)) return false;
|
|
62
|
+
if (!isOptionalString(candidate.type)) return false;
|
|
63
|
+
if (!isRecord(candidate.option)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
router.post(API_ROUTES.chart.present, async (req: Request<object, unknown, PresentChartBody>, res: Response<PresentChartResponse>) => {
|
|
70
|
+
const { document, title } = req.body;
|
|
71
|
+
|
|
72
|
+
if (!isValidChartDocument(document)) {
|
|
73
|
+
badRequest(res, "document must be { charts: [{ option: {...}, title?, type? }, ...] } with at least one entry");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (title !== undefined && typeof title !== "string") {
|
|
78
|
+
badRequest(res, "title must be a string when provided");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const baseLabel = title ?? document.title ?? "chart";
|
|
84
|
+
const filePath = buildArtifactPath(WORKSPACE_DIRS.charts, baseLabel, ".chart.json", "chart");
|
|
85
|
+
await writeWorkspaceText(filePath, `${JSON.stringify(document, null, 2)}\n`);
|
|
86
|
+
res.json({
|
|
87
|
+
message: `Saved chart document to ${filePath}`,
|
|
88
|
+
instructions:
|
|
89
|
+
"Acknowledge that the chart(s) have been presented to the user. The document contains " +
|
|
90
|
+
`${document.charts.length} chart${document.charts.length === 1 ? "" : "s"}.`,
|
|
91
|
+
data: { document, title, filePath },
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
serverError(res, errorMessage(err));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export default router;
|