@myrialabs/clopen 0.0.1

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 (456) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/release.yml +60 -0
  3. package/.github/workflows/test.yml +40 -0
  4. package/CONTRIBUTING.md +499 -0
  5. package/LICENSE +21 -0
  6. package/README.md +209 -0
  7. package/backend/index.ts +156 -0
  8. package/backend/lib/chat/helpers.ts +42 -0
  9. package/backend/lib/chat/index.ts +2 -0
  10. package/backend/lib/chat/stream-manager.ts +1126 -0
  11. package/backend/lib/database/README.md +77 -0
  12. package/backend/lib/database/index.ts +119 -0
  13. package/backend/lib/database/migrations/001_create_projects_table.ts +31 -0
  14. package/backend/lib/database/migrations/002_create_chat_sessions_table.ts +33 -0
  15. package/backend/lib/database/migrations/003_create_messages_table.ts +32 -0
  16. package/backend/lib/database/migrations/004_create_prompt_templates_table.ts +34 -0
  17. package/backend/lib/database/migrations/005_create_settings_table.ts +24 -0
  18. package/backend/lib/database/migrations/006_add_user_to_messages.ts +58 -0
  19. package/backend/lib/database/migrations/007_create_stream_states_table.ts +41 -0
  20. package/backend/lib/database/migrations/008_create_message_snapshots_table.ts +62 -0
  21. package/backend/lib/database/migrations/009_add_delta_snapshot_fields.ts +41 -0
  22. package/backend/lib/database/migrations/010_add_soft_delete_and_branch_support.ts +70 -0
  23. package/backend/lib/database/migrations/011_git_like_commit_graph.ts +156 -0
  24. package/backend/lib/database/migrations/012_add_file_change_statistics.ts +41 -0
  25. package/backend/lib/database/migrations/013_checkpoint_tree_state.ts +118 -0
  26. package/backend/lib/database/migrations/014_add_engine_to_sessions.ts +18 -0
  27. package/backend/lib/database/migrations/015_add_model_to_sessions.ts +18 -0
  28. package/backend/lib/database/migrations/016_create_user_projects_table.ts +34 -0
  29. package/backend/lib/database/migrations/017_add_current_session_to_user_projects.ts +32 -0
  30. package/backend/lib/database/migrations/018_create_claude_accounts_table.ts +24 -0
  31. package/backend/lib/database/migrations/019_add_claude_account_to_sessions.ts +18 -0
  32. package/backend/lib/database/migrations/020_add_snapshot_tree_hash.ts +32 -0
  33. package/backend/lib/database/migrations/021_drop_prompt_templates_table.ts +33 -0
  34. package/backend/lib/database/migrations/index.ts +154 -0
  35. package/backend/lib/database/queries/checkpoint-queries.ts +87 -0
  36. package/backend/lib/database/queries/engine-queries.ts +75 -0
  37. package/backend/lib/database/queries/index.ts +9 -0
  38. package/backend/lib/database/queries/message-queries.ts +472 -0
  39. package/backend/lib/database/queries/project-queries.ts +117 -0
  40. package/backend/lib/database/queries/session-queries.ts +271 -0
  41. package/backend/lib/database/queries/settings-queries.ts +34 -0
  42. package/backend/lib/database/queries/snapshot-queries.ts +326 -0
  43. package/backend/lib/database/queries/utils-queries.ts +59 -0
  44. package/backend/lib/database/seeders/index.ts +13 -0
  45. package/backend/lib/database/seeders/settings_seeder.ts +84 -0
  46. package/backend/lib/database/utils/connection.ts +174 -0
  47. package/backend/lib/database/utils/index.ts +4 -0
  48. package/backend/lib/database/utils/migration-runner.ts +118 -0
  49. package/backend/lib/database/utils/seeder-runner.ts +121 -0
  50. package/backend/lib/engine/adapters/claude/environment.ts +164 -0
  51. package/backend/lib/engine/adapters/claude/error-handler.ts +60 -0
  52. package/backend/lib/engine/adapters/claude/index.ts +1 -0
  53. package/backend/lib/engine/adapters/claude/path-utils.ts +38 -0
  54. package/backend/lib/engine/adapters/claude/stream.ts +177 -0
  55. package/backend/lib/engine/adapters/opencode/index.ts +2 -0
  56. package/backend/lib/engine/adapters/opencode/message-converter.ts +862 -0
  57. package/backend/lib/engine/adapters/opencode/server.ts +104 -0
  58. package/backend/lib/engine/adapters/opencode/stream.ts +755 -0
  59. package/backend/lib/engine/index.ts +196 -0
  60. package/backend/lib/engine/types.ts +58 -0
  61. package/backend/lib/files/file-operations.ts +478 -0
  62. package/backend/lib/files/file-reading.ts +308 -0
  63. package/backend/lib/files/file-watcher.ts +383 -0
  64. package/backend/lib/files/path-browsing.ts +382 -0
  65. package/backend/lib/git/git-executor.ts +88 -0
  66. package/backend/lib/git/git-parser.ts +411 -0
  67. package/backend/lib/git/git-service.ts +505 -0
  68. package/backend/lib/mcp/README.md +1144 -0
  69. package/backend/lib/mcp/config.ts +316 -0
  70. package/backend/lib/mcp/index.ts +35 -0
  71. package/backend/lib/mcp/project-context.ts +236 -0
  72. package/backend/lib/mcp/servers/browser-automation/actions.ts +156 -0
  73. package/backend/lib/mcp/servers/browser-automation/browser.ts +419 -0
  74. package/backend/lib/mcp/servers/browser-automation/index.ts +791 -0
  75. package/backend/lib/mcp/servers/browser-automation/inspection.ts +501 -0
  76. package/backend/lib/mcp/servers/helper.ts +143 -0
  77. package/backend/lib/mcp/servers/index.ts +45 -0
  78. package/backend/lib/mcp/servers/weather/get-temperature.ts +56 -0
  79. package/backend/lib/mcp/servers/weather/index.ts +31 -0
  80. package/backend/lib/mcp/stdio-server.ts +103 -0
  81. package/backend/lib/mcp/types.ts +65 -0
  82. package/backend/lib/preview/browser/browser-audio-capture.ts +86 -0
  83. package/backend/lib/preview/browser/browser-console-manager.ts +263 -0
  84. package/backend/lib/preview/browser/browser-dialog-handler.ts +222 -0
  85. package/backend/lib/preview/browser/browser-interaction-handler.ts +421 -0
  86. package/backend/lib/preview/browser/browser-mcp-control.ts +415 -0
  87. package/backend/lib/preview/browser/browser-native-ui-handler.ts +512 -0
  88. package/backend/lib/preview/browser/browser-navigation-tracker.ts +104 -0
  89. package/backend/lib/preview/browser/browser-pool.ts +357 -0
  90. package/backend/lib/preview/browser/browser-preview-service.ts +882 -0
  91. package/backend/lib/preview/browser/browser-tab-manager.ts +935 -0
  92. package/backend/lib/preview/browser/browser-video-capture.ts +695 -0
  93. package/backend/lib/preview/browser/scripts/audio-stream.ts +292 -0
  94. package/backend/lib/preview/browser/scripts/cursor-tracking.ts +85 -0
  95. package/backend/lib/preview/browser/scripts/video-stream.ts +438 -0
  96. package/backend/lib/preview/browser/types.ts +359 -0
  97. package/backend/lib/preview/index.ts +24 -0
  98. package/backend/lib/project/index.ts +2 -0
  99. package/backend/lib/project/status-manager.ts +182 -0
  100. package/backend/lib/shared/index.ts +2 -0
  101. package/backend/lib/shared/port-utils.ts +25 -0
  102. package/backend/lib/shared/process-manager.ts +281 -0
  103. package/backend/lib/snapshot/blob-store.ts +227 -0
  104. package/backend/lib/snapshot/gitignore.ts +307 -0
  105. package/backend/lib/snapshot/helpers.ts +397 -0
  106. package/backend/lib/snapshot/snapshot-service.ts +483 -0
  107. package/backend/lib/terminal/helpers.ts +14 -0
  108. package/backend/lib/terminal/index.ts +8 -0
  109. package/backend/lib/terminal/pty-manager.ts +4 -0
  110. package/backend/lib/terminal/pty-session-manager.ts +387 -0
  111. package/backend/lib/terminal/shell-utils.ts +313 -0
  112. package/backend/lib/terminal/stream-manager.ts +293 -0
  113. package/backend/lib/tunnel/global-tunnel-manager.ts +243 -0
  114. package/backend/lib/tunnel/project-tunnel-manager.ts +311 -0
  115. package/backend/lib/user/helpers.ts +87 -0
  116. package/backend/lib/utils/ws.ts +944 -0
  117. package/backend/lib/vite-dev.ts +353 -0
  118. package/backend/middleware/cors.ts +15 -0
  119. package/backend/middleware/error-handler.ts +49 -0
  120. package/backend/middleware/logger.ts +9 -0
  121. package/backend/types/api.ts +24 -0
  122. package/backend/ws/README.md +1505 -0
  123. package/backend/ws/chat/background.ts +198 -0
  124. package/backend/ws/chat/index.ts +21 -0
  125. package/backend/ws/chat/stream.ts +707 -0
  126. package/backend/ws/engine/claude/accounts.ts +401 -0
  127. package/backend/ws/engine/claude/index.ts +13 -0
  128. package/backend/ws/engine/claude/status.ts +43 -0
  129. package/backend/ws/engine/index.ts +14 -0
  130. package/backend/ws/engine/opencode/index.ts +11 -0
  131. package/backend/ws/engine/opencode/status.ts +30 -0
  132. package/backend/ws/engine/utils.ts +36 -0
  133. package/backend/ws/files/index.ts +30 -0
  134. package/backend/ws/files/read.ts +189 -0
  135. package/backend/ws/files/search.ts +453 -0
  136. package/backend/ws/files/watch.ts +124 -0
  137. package/backend/ws/files/write.ts +143 -0
  138. package/backend/ws/git/branch.ts +106 -0
  139. package/backend/ws/git/commit.ts +39 -0
  140. package/backend/ws/git/conflict.ts +68 -0
  141. package/backend/ws/git/diff.ts +69 -0
  142. package/backend/ws/git/index.ts +24 -0
  143. package/backend/ws/git/log.ts +41 -0
  144. package/backend/ws/git/remote.ts +214 -0
  145. package/backend/ws/git/staging.ts +84 -0
  146. package/backend/ws/git/status.ts +90 -0
  147. package/backend/ws/index.ts +69 -0
  148. package/backend/ws/mcp/index.ts +61 -0
  149. package/backend/ws/messages/crud.ts +74 -0
  150. package/backend/ws/messages/index.ts +14 -0
  151. package/backend/ws/preview/browser/cleanup.ts +129 -0
  152. package/backend/ws/preview/browser/console.ts +114 -0
  153. package/backend/ws/preview/browser/interact.ts +513 -0
  154. package/backend/ws/preview/browser/mcp.ts +129 -0
  155. package/backend/ws/preview/browser/native-ui.ts +235 -0
  156. package/backend/ws/preview/browser/stats.ts +55 -0
  157. package/backend/ws/preview/browser/tab-info.ts +126 -0
  158. package/backend/ws/preview/browser/tab.ts +166 -0
  159. package/backend/ws/preview/browser/webcodecs.ts +293 -0
  160. package/backend/ws/preview/index.ts +146 -0
  161. package/backend/ws/projects/crud.ts +113 -0
  162. package/backend/ws/projects/index.ts +25 -0
  163. package/backend/ws/projects/presence.ts +46 -0
  164. package/backend/ws/projects/status.ts +116 -0
  165. package/backend/ws/sessions/crud.ts +327 -0
  166. package/backend/ws/sessions/index.ts +33 -0
  167. package/backend/ws/settings/crud.ts +112 -0
  168. package/backend/ws/settings/index.ts +14 -0
  169. package/backend/ws/snapshot/index.ts +17 -0
  170. package/backend/ws/snapshot/restore.ts +173 -0
  171. package/backend/ws/snapshot/timeline.ts +141 -0
  172. package/backend/ws/system/index.ts +14 -0
  173. package/backend/ws/system/operations.ts +49 -0
  174. package/backend/ws/terminal/index.ts +40 -0
  175. package/backend/ws/terminal/persistence.ts +153 -0
  176. package/backend/ws/terminal/session.ts +382 -0
  177. package/backend/ws/terminal/stream.ts +79 -0
  178. package/backend/ws/tunnel/index.ts +14 -0
  179. package/backend/ws/tunnel/operations.ts +91 -0
  180. package/backend/ws/types.ts +20 -0
  181. package/backend/ws/user/crud.ts +156 -0
  182. package/backend/ws/user/index.ts +14 -0
  183. package/bin/clopen.ts +307 -0
  184. package/bun.lock +1352 -0
  185. package/frontend/App.svelte +34 -0
  186. package/frontend/app.css +313 -0
  187. package/frontend/lib/app-environment.ts +10 -0
  188. package/frontend/lib/components/chat/ChatInterface.svelte +407 -0
  189. package/frontend/lib/components/chat/formatters/ErrorMessage.svelte +57 -0
  190. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +224 -0
  191. package/frontend/lib/components/chat/formatters/TextMessage.svelte +395 -0
  192. package/frontend/lib/components/chat/formatters/Tools.svelte +70 -0
  193. package/frontend/lib/components/chat/formatters/index.ts +3 -0
  194. package/frontend/lib/components/chat/input/ChatInput.svelte +421 -0
  195. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +78 -0
  196. package/frontend/lib/components/chat/input/components/DragDropOverlay.svelte +30 -0
  197. package/frontend/lib/components/chat/input/components/EditModeIndicator.svelte +33 -0
  198. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +619 -0
  199. package/frontend/lib/components/chat/input/components/FileAttachmentPreview.svelte +48 -0
  200. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +31 -0
  201. package/frontend/lib/components/chat/input/composables/use-animations.svelte.ts +201 -0
  202. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +148 -0
  203. package/frontend/lib/components/chat/input/composables/use-file-handling.svelte.ts +216 -0
  204. package/frontend/lib/components/chat/input/composables/use-input-state.svelte.ts +357 -0
  205. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +57 -0
  206. package/frontend/lib/components/chat/message/ChatMessage.svelte +478 -0
  207. package/frontend/lib/components/chat/message/ChatMessages.svelte +541 -0
  208. package/frontend/lib/components/chat/message/DateSeparator.svelte +86 -0
  209. package/frontend/lib/components/chat/message/MessageBubble.svelte +86 -0
  210. package/frontend/lib/components/chat/message/MessageHeader.svelte +157 -0
  211. package/frontend/lib/components/chat/modal/DebugModal.svelte +59 -0
  212. package/frontend/lib/components/chat/modal/TokenUsageModal.svelte +124 -0
  213. package/frontend/lib/components/chat/shared/index.ts +2 -0
  214. package/frontend/lib/components/chat/shared/utils.ts +116 -0
  215. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +35 -0
  216. package/frontend/lib/components/chat/tools/BashTool.svelte +46 -0
  217. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +139 -0
  218. package/frontend/lib/components/chat/tools/EditTool.svelte +48 -0
  219. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +32 -0
  220. package/frontend/lib/components/chat/tools/GlobTool.svelte +51 -0
  221. package/frontend/lib/components/chat/tools/GrepTool.svelte +90 -0
  222. package/frontend/lib/components/chat/tools/KillShellTool.svelte +26 -0
  223. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +31 -0
  224. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +38 -0
  225. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +34 -0
  226. package/frontend/lib/components/chat/tools/ReadTool.svelte +41 -0
  227. package/frontend/lib/components/chat/tools/TaskTool.svelte +64 -0
  228. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +75 -0
  229. package/frontend/lib/components/chat/tools/WebFetchTool.svelte +35 -0
  230. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +84 -0
  231. package/frontend/lib/components/chat/tools/WriteTool.svelte +33 -0
  232. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +79 -0
  233. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +408 -0
  234. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +45 -0
  235. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +19 -0
  236. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +27 -0
  237. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +54 -0
  238. package/frontend/lib/components/chat/tools/components/index.ts +7 -0
  239. package/frontend/lib/components/chat/tools/index.ts +26 -0
  240. package/frontend/lib/components/chat/widgets/FloatingTodoList.svelte +249 -0
  241. package/frontend/lib/components/chat/widgets/TokenUsage.svelte +78 -0
  242. package/frontend/lib/components/checkpoint/TimelineModal.svelte +391 -0
  243. package/frontend/lib/components/checkpoint/timeline/TimelineEdge.svelte +26 -0
  244. package/frontend/lib/components/checkpoint/timeline/TimelineGraph.svelte +87 -0
  245. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +108 -0
  246. package/frontend/lib/components/checkpoint/timeline/TimelineVersionGroup.svelte +59 -0
  247. package/frontend/lib/components/checkpoint/timeline/animation.ts +168 -0
  248. package/frontend/lib/components/checkpoint/timeline/config.ts +44 -0
  249. package/frontend/lib/components/checkpoint/timeline/graph-builder.ts +304 -0
  250. package/frontend/lib/components/checkpoint/timeline/types.ts +65 -0
  251. package/frontend/lib/components/checkpoint/timeline/utils.ts +53 -0
  252. package/frontend/lib/components/common/Alert.svelte +139 -0
  253. package/frontend/lib/components/common/AvatarBubble.svelte +56 -0
  254. package/frontend/lib/components/common/Button.svelte +71 -0
  255. package/frontend/lib/components/common/Card.svelte +102 -0
  256. package/frontend/lib/components/common/Checkbox.svelte +48 -0
  257. package/frontend/lib/components/common/Dialog.svelte +249 -0
  258. package/frontend/lib/components/common/FolderBrowser.svelte +843 -0
  259. package/frontend/lib/components/common/Icon.svelte +58 -0
  260. package/frontend/lib/components/common/Input.svelte +72 -0
  261. package/frontend/lib/components/common/Lightbox.svelte +233 -0
  262. package/frontend/lib/components/common/LoadingScreen.svelte +52 -0
  263. package/frontend/lib/components/common/LoadingSpinner.svelte +48 -0
  264. package/frontend/lib/components/common/Modal.svelte +177 -0
  265. package/frontend/lib/components/common/ModalProvider.svelte +28 -0
  266. package/frontend/lib/components/common/ModelSelector.svelte +110 -0
  267. package/frontend/lib/components/common/MonacoEditor.svelte +569 -0
  268. package/frontend/lib/components/common/NotificationToast.svelte +113 -0
  269. package/frontend/lib/components/common/PageTemplate.svelte +76 -0
  270. package/frontend/lib/components/common/ProjectUserAvatars.svelte +79 -0
  271. package/frontend/lib/components/common/Select.svelte +98 -0
  272. package/frontend/lib/components/common/Textarea.svelte +80 -0
  273. package/frontend/lib/components/common/ThemeToggle.svelte +44 -0
  274. package/frontend/lib/components/common/lucide-icons.ts +1642 -0
  275. package/frontend/lib/components/common/material-icons.ts +1082 -0
  276. package/frontend/lib/components/common/xterm/XTerm.svelte +796 -0
  277. package/frontend/lib/components/common/xterm/index.ts +16 -0
  278. package/frontend/lib/components/common/xterm/terminal-config.ts +68 -0
  279. package/frontend/lib/components/common/xterm/types.ts +31 -0
  280. package/frontend/lib/components/common/xterm/xterm-service.ts +353 -0
  281. package/frontend/lib/components/files/FileNode.svelte +384 -0
  282. package/frontend/lib/components/files/FileTree.svelte +681 -0
  283. package/frontend/lib/components/files/FileViewer.svelte +728 -0
  284. package/frontend/lib/components/files/SearchResults.svelte +303 -0
  285. package/frontend/lib/components/git/BranchManager.svelte +458 -0
  286. package/frontend/lib/components/git/ChangesSection.svelte +107 -0
  287. package/frontend/lib/components/git/CommitForm.svelte +76 -0
  288. package/frontend/lib/components/git/ConflictResolver.svelte +158 -0
  289. package/frontend/lib/components/git/DiffViewer.svelte +364 -0
  290. package/frontend/lib/components/git/FileChangeItem.svelte +97 -0
  291. package/frontend/lib/components/git/GitButton.svelte +33 -0
  292. package/frontend/lib/components/git/GitLog.svelte +361 -0
  293. package/frontend/lib/components/git/GitModal.svelte +80 -0
  294. package/frontend/lib/components/history/HistoryModal.svelte +563 -0
  295. package/frontend/lib/components/history/HistoryView.svelte +615 -0
  296. package/frontend/lib/components/index.ts +34 -0
  297. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +549 -0
  298. package/frontend/lib/components/preview/browser/components/Canvas.svelte +1058 -0
  299. package/frontend/lib/components/preview/browser/components/ConsolePanel.svelte +757 -0
  300. package/frontend/lib/components/preview/browser/components/Container.svelte +450 -0
  301. package/frontend/lib/components/preview/browser/components/ContextMenu.svelte +236 -0
  302. package/frontend/lib/components/preview/browser/components/SelectDropdown.svelte +224 -0
  303. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +339 -0
  304. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +36 -0
  305. package/frontend/lib/components/preview/browser/core/cleanup.svelte.ts +155 -0
  306. package/frontend/lib/components/preview/browser/core/coordinator.svelte.ts +837 -0
  307. package/frontend/lib/components/preview/browser/core/interactions.svelte.ts +113 -0
  308. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +296 -0
  309. package/frontend/lib/components/preview/browser/core/native-ui-handlers.svelte.ts +391 -0
  310. package/frontend/lib/components/preview/browser/core/stream-handler.svelte.ts +231 -0
  311. package/frontend/lib/components/preview/browser/core/tab-manager.svelte.ts +210 -0
  312. package/frontend/lib/components/preview/browser/core/tab-operations.svelte.ts +239 -0
  313. package/frontend/lib/components/preview/index.ts +2 -0
  314. package/frontend/lib/components/settings/SettingsModal.svelte +235 -0
  315. package/frontend/lib/components/settings/SettingsView.svelte +36 -0
  316. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +51 -0
  317. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +160 -0
  318. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +76 -0
  319. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +917 -0
  320. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +187 -0
  321. package/frontend/lib/components/settings/general/DataManagementSettings.svelte +203 -0
  322. package/frontend/lib/components/settings/general/GeneralSettings.svelte +10 -0
  323. package/frontend/lib/components/settings/model/ModelSettings.svelte +357 -0
  324. package/frontend/lib/components/settings/notifications/NotificationSettings.svelte +205 -0
  325. package/frontend/lib/components/settings/user/UserSettings.svelte +197 -0
  326. package/frontend/lib/components/terminal/Terminal.svelte +368 -0
  327. package/frontend/lib/components/terminal/TerminalTabs.svelte +87 -0
  328. package/frontend/lib/components/terminal/TerminalView.svelte +55 -0
  329. package/frontend/lib/components/tunnel/TunnelActive.svelte +142 -0
  330. package/frontend/lib/components/tunnel/TunnelButton.svelte +54 -0
  331. package/frontend/lib/components/tunnel/TunnelInactive.svelte +284 -0
  332. package/frontend/lib/components/tunnel/TunnelModal.svelte +47 -0
  333. package/frontend/lib/components/tunnel/TunnelQRCode.svelte +49 -0
  334. package/frontend/lib/components/workspace/DesktopNavigator.svelte +382 -0
  335. package/frontend/lib/components/workspace/MobileNavigator.svelte +403 -0
  336. package/frontend/lib/components/workspace/PanelContainer.svelte +100 -0
  337. package/frontend/lib/components/workspace/PanelHeader.svelte +505 -0
  338. package/frontend/lib/components/workspace/ViewMenu.svelte +162 -0
  339. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +169 -0
  340. package/frontend/lib/components/workspace/layout/DesktopLayout.svelte +15 -0
  341. package/frontend/lib/components/workspace/layout/MobileLayout.svelte +17 -0
  342. package/frontend/lib/components/workspace/layout/split-pane/Container.svelte +42 -0
  343. package/frontend/lib/components/workspace/layout/split-pane/Handle.svelte +85 -0
  344. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +37 -0
  345. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +274 -0
  346. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +1261 -0
  347. package/frontend/lib/components/workspace/panels/GitPanel.svelte +1560 -0
  348. package/frontend/lib/components/workspace/panels/PreviewPanel.svelte +150 -0
  349. package/frontend/lib/components/workspace/panels/TerminalPanel.svelte +73 -0
  350. package/frontend/lib/constants/preview.ts +45 -0
  351. package/frontend/lib/services/chat/chat.service.ts +704 -0
  352. package/frontend/lib/services/chat/index.ts +7 -0
  353. package/frontend/lib/services/notification/global-stream-monitor.ts +86 -0
  354. package/frontend/lib/services/notification/index.ts +8 -0
  355. package/frontend/lib/services/notification/push.service.ts +144 -0
  356. package/frontend/lib/services/notification/sound.service.ts +127 -0
  357. package/frontend/lib/services/preview/browser/browser-console.service.ts +61 -0
  358. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +1499 -0
  359. package/frontend/lib/services/preview/browser/mcp-integration.svelte.ts +67 -0
  360. package/frontend/lib/services/preview/index.ts +23 -0
  361. package/frontend/lib/services/project/index.ts +8 -0
  362. package/frontend/lib/services/project/status.service.ts +159 -0
  363. package/frontend/lib/services/snapshot/snapshot.service.ts +47 -0
  364. package/frontend/lib/services/terminal/background/index.ts +130 -0
  365. package/frontend/lib/services/terminal/background/session-restore.ts +274 -0
  366. package/frontend/lib/services/terminal/background/stream-manager.ts +286 -0
  367. package/frontend/lib/services/terminal/index.ts +14 -0
  368. package/frontend/lib/services/terminal/persistence.service.ts +260 -0
  369. package/frontend/lib/services/terminal/project.service.ts +953 -0
  370. package/frontend/lib/services/terminal/session.service.ts +364 -0
  371. package/frontend/lib/services/terminal/terminal.service.ts +369 -0
  372. package/frontend/lib/stores/core/app.svelte.ts +117 -0
  373. package/frontend/lib/stores/core/files.svelte.ts +73 -0
  374. package/frontend/lib/stores/core/presence.svelte.ts +48 -0
  375. package/frontend/lib/stores/core/projects.svelte.ts +317 -0
  376. package/frontend/lib/stores/core/sessions.svelte.ts +383 -0
  377. package/frontend/lib/stores/features/claude-accounts.svelte.ts +58 -0
  378. package/frontend/lib/stores/features/models.svelte.ts +89 -0
  379. package/frontend/lib/stores/features/settings.svelte.ts +87 -0
  380. package/frontend/lib/stores/features/terminal.svelte.ts +701 -0
  381. package/frontend/lib/stores/features/tunnel.svelte.ts +161 -0
  382. package/frontend/lib/stores/features/user.svelte.ts +96 -0
  383. package/frontend/lib/stores/ui/chat-input.svelte.ts +57 -0
  384. package/frontend/lib/stores/ui/chat-model.svelte.ts +61 -0
  385. package/frontend/lib/stores/ui/dialog.svelte.ts +59 -0
  386. package/frontend/lib/stores/ui/edit-mode.svelte.ts +214 -0
  387. package/frontend/lib/stores/ui/notification.svelte.ts +166 -0
  388. package/frontend/lib/stores/ui/settings-modal.svelte.ts +88 -0
  389. package/frontend/lib/stores/ui/theme.svelte.ts +179 -0
  390. package/frontend/lib/stores/ui/workspace.svelte.ts +754 -0
  391. package/frontend/lib/types/native-ui.ts +73 -0
  392. package/frontend/lib/utils/chat/date-separator.ts +39 -0
  393. package/frontend/lib/utils/chat/message-grouper.ts +219 -0
  394. package/frontend/lib/utils/chat/message-processor.ts +135 -0
  395. package/frontend/lib/utils/chat/tool-handler.ts +161 -0
  396. package/frontend/lib/utils/chat/virtual-scroll.svelte.ts +142 -0
  397. package/frontend/lib/utils/click-outside.ts +20 -0
  398. package/frontend/lib/utils/context-manager.ts +257 -0
  399. package/frontend/lib/utils/file-icon-mappings.ts +769 -0
  400. package/frontend/lib/utils/folder-icon-mappings.ts +1030 -0
  401. package/frontend/lib/utils/git-status.ts +68 -0
  402. package/frontend/lib/utils/platform.ts +113 -0
  403. package/frontend/lib/utils/port-check.ts +65 -0
  404. package/frontend/lib/utils/terminalFormatter.ts +207 -0
  405. package/frontend/lib/utils/theme.ts +6 -0
  406. package/frontend/lib/utils/tree-visualizer.ts +320 -0
  407. package/frontend/lib/utils/ws.ts +44 -0
  408. package/frontend/main.ts +13 -0
  409. package/index.html +70 -0
  410. package/package.json +111 -0
  411. package/scripts/generate-icons.ts +87 -0
  412. package/scripts/pre-publish-check.sh +142 -0
  413. package/scripts/setup-hooks.sh +134 -0
  414. package/scripts/validate-branch-name.sh +47 -0
  415. package/scripts/validate-commit-msg.sh +42 -0
  416. package/shared/constants/engines.ts +134 -0
  417. package/shared/types/database/connection.ts +16 -0
  418. package/shared/types/database/index.ts +6 -0
  419. package/shared/types/database/schema.ts +141 -0
  420. package/shared/types/engine/index.ts +45 -0
  421. package/shared/types/filesystem/index.ts +22 -0
  422. package/shared/types/git.ts +171 -0
  423. package/shared/types/messaging/index.ts +239 -0
  424. package/shared/types/messaging/tool.ts +526 -0
  425. package/shared/types/network/api.ts +18 -0
  426. package/shared/types/network/index.ts +5 -0
  427. package/shared/types/stores/app.ts +23 -0
  428. package/shared/types/stores/dialog.ts +21 -0
  429. package/shared/types/stores/index.ts +3 -0
  430. package/shared/types/stores/settings.ts +15 -0
  431. package/shared/types/terminal/index.ts +44 -0
  432. package/shared/types/ui/components.ts +61 -0
  433. package/shared/types/ui/icons.ts +23 -0
  434. package/shared/types/ui/index.ts +22 -0
  435. package/shared/types/ui/notifications.ts +14 -0
  436. package/shared/types/ui/theme.ts +12 -0
  437. package/shared/types/websocket/index.ts +43 -0
  438. package/shared/types/window.d.ts +13 -0
  439. package/shared/utils/anonymous-user.ts +168 -0
  440. package/shared/utils/async.ts +10 -0
  441. package/shared/utils/diff-calculator.ts +184 -0
  442. package/shared/utils/file-type-detection.ts +166 -0
  443. package/shared/utils/logger.ts +158 -0
  444. package/shared/utils/message-formatter.ts +79 -0
  445. package/shared/utils/path.ts +47 -0
  446. package/shared/utils/ws-client.ts +768 -0
  447. package/shared/utils/ws-server.ts +660 -0
  448. package/static/audio/notification.ogg +0 -0
  449. package/static/favicon.svg +8 -0
  450. package/static/fonts/dm-sans/dm-sans-italic-latin-ext.woff2 +0 -0
  451. package/static/fonts/dm-sans/dm-sans-italic-latin.woff2 +0 -0
  452. package/static/fonts/dm-sans/dm-sans-normal-latin-ext.woff2 +0 -0
  453. package/static/fonts/dm-sans/dm-sans-normal-latin.woff2 +0 -0
  454. package/static/fonts/dm-sans.css +96 -0
  455. package/svelte.config.js +20 -0
  456. package/vite.config.ts +33 -0
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * Stream Manager - Background Service for Chat Streams (Optimized)
3
+ *
4
+ * High-performance chat stream manager with:
5
+ * - Event-driven push model (no polling)
6
+ * - Background processing that continues even when browser is closed
7
+ * - Reconnection to active streams after browser refresh
8
+ * - Multiple concurrent streams per user/session
9
+ */
10
+
11
+ import { EventEmitter } from 'events';
12
+ import type { SDKMessage, SSEEventData, SDKCompactBoundaryMessage, SDKPartialAssistantMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
13
+ import type { DatabaseMessage } from '$shared/types/database/schema';
14
+ import type { EngineType } from '$shared/types/engine';
15
+ import { getProjectEngine, initializeProjectEngine } from '../engine';
16
+ import { messageQueries, sessionQueries } from '../database/queries';
17
+ import { snapshotService } from '../snapshot/snapshot-service';
18
+ import { projectContextService } from '../mcp/project-context';
19
+ import { browserMcpControl } from '../preview';
20
+ import { debug } from '$shared/utils/logger';
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ export interface StreamState {
27
+ streamId: string;
28
+ chatSessionId: string;
29
+ projectId?: string;
30
+ projectPath?: string;
31
+ processId: string;
32
+ engine: EngineType;
33
+ status: 'active' | 'completed' | 'error' | 'cancelled';
34
+ startedAt: Date;
35
+ completedAt?: Date;
36
+ messages: SSEEventData[];
37
+ currentMessage?: SDKMessage;
38
+ currentPartialText?: string;
39
+ currentReasoningText?: string;
40
+ error?: string;
41
+ abortController?: AbortController;
42
+ streamPromise?: Promise<void>;
43
+ sdkSessionId?: string;
44
+ hasCompactBoundary?: boolean;
45
+ eventSeq: number; // Sequence number for deduplication
46
+ }
47
+
48
+ interface StreamRequest {
49
+ projectPath: string;
50
+ projectId?: string;
51
+ prompt: SDKUserMessage;
52
+ messages: any[];
53
+ chatSessionId: string;
54
+ engine?: EngineType;
55
+ model?: string;
56
+ temperature?: number;
57
+ senderId?: string;
58
+ senderName?: string;
59
+ claudeAccountId?: number;
60
+ }
61
+
62
+ // ============================================================================
63
+ // Stream Event Types
64
+ // ============================================================================
65
+
66
+ export type StreamEventType =
67
+ | 'connection'
68
+ | 'message'
69
+ | 'partial'
70
+ | 'notification'
71
+ | 'complete'
72
+ | 'error'
73
+ | 'cancelled';
74
+
75
+ export interface StreamEvent {
76
+ type: StreamEventType;
77
+ streamId: string;
78
+ processId: string;
79
+ data: any;
80
+ timestamp: string;
81
+ seq: number; // Sequence number for deduplication
82
+ }
83
+
84
+ // ============================================================================
85
+ // Stream Manager
86
+ // ============================================================================
87
+
88
+ class StreamManager extends EventEmitter {
89
+ private activeStreams = new Map<string, StreamState>();
90
+ private sessionStreams = new Map<string, string>(); // composite key -> streamId
91
+ /** Guard against duplicate lifecycle events (e.g. if both inner and outer error paths fire) */
92
+ private lifecycleEmitted = new Set<string>();
93
+
94
+ constructor() {
95
+ super();
96
+ // Increase max listeners for high concurrency
97
+ this.setMaxListeners(1000);
98
+ }
99
+
100
+ /**
101
+ * Emit a global lifecycle event when a stream reaches a terminal state.
102
+ * This event fires regardless of per-connection subscribers.
103
+ * Used by the WS layer to send cross-project notifications (presence, sound, push).
104
+ */
105
+ private emitStreamLifecycle(streamState: StreamState, status: 'completed' | 'error' | 'cancelled'): void {
106
+ if (this.lifecycleEmitted.has(streamState.streamId)) return;
107
+ this.lifecycleEmitted.add(streamState.streamId);
108
+
109
+ this.emit('stream:lifecycle', {
110
+ status,
111
+ streamId: streamState.streamId,
112
+ projectId: streamState.projectId,
113
+ chatSessionId: streamState.chatSessionId,
114
+ timestamp: (streamState.completedAt || new Date()).toISOString()
115
+ });
116
+
117
+ // Clean up guard after 60s (no need to keep forever)
118
+ setTimeout(() => this.lifecycleEmitted.delete(streamState.streamId), 60000);
119
+ }
120
+
121
+ /**
122
+ * Start a new background stream
123
+ */
124
+ async startStream(request: StreamRequest): Promise<string> {
125
+ const streamId = crypto.randomUUID();
126
+ const processId = crypto.randomUUID();
127
+
128
+ // Check if there's already an active stream for this chat session + project
129
+ const sessionKey = this.getSessionKey(request.projectId, request.chatSessionId);
130
+ const existingStreamId = this.sessionStreams.get(sessionKey);
131
+ if (existingStreamId) {
132
+ const existingStream = this.activeStreams.get(existingStreamId);
133
+ if (existingStream && existingStream.status === 'active') {
134
+ if (existingStream.projectId === request.projectId) {
135
+ return existingStreamId;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Initialize stream state
141
+ const streamState: StreamState = {
142
+ streamId,
143
+ chatSessionId: request.chatSessionId,
144
+ projectId: request.projectId,
145
+ projectPath: request.projectPath,
146
+ processId,
147
+ engine: request.engine || 'claude-code',
148
+ status: 'active',
149
+ startedAt: new Date(),
150
+ messages: [],
151
+ abortController: new AbortController(),
152
+ eventSeq: 0 // Initialize sequence for deduplication
153
+ };
154
+
155
+ this.activeStreams.set(streamId, streamState);
156
+ this.sessionStreams.set(sessionKey, streamId);
157
+
158
+ // Save engine+model+account to session for persistence across refresh/switch
159
+ if (request.chatSessionId) {
160
+ try {
161
+ const compoundModelId = `${request.engine || 'claude-code'}:${request.model || 'sonnet'}`;
162
+ sessionQueries.updateEngineModel(
163
+ request.chatSessionId,
164
+ request.engine || 'claude-code',
165
+ compoundModelId
166
+ );
167
+ if (request.claudeAccountId !== undefined) {
168
+ sessionQueries.updateClaudeAccountId(request.chatSessionId, request.claudeAccountId);
169
+ }
170
+ } catch (error) {
171
+ debug.error('chat', 'Failed to save engine/model to session:', error);
172
+ }
173
+ }
174
+
175
+ // Register session -> projectId mapping for MCP context
176
+ if (request.projectId) {
177
+ projectContextService.registerSession(request.chatSessionId, request.projectId);
178
+ projectContextService.registerStream(streamId, request.projectId, request.chatSessionId);
179
+ }
180
+
181
+ // Emit connection event immediately
182
+ this.emitStreamEvent(streamState, 'connection', {
183
+ processId,
184
+ timestamp: streamState.startedAt.toISOString()
185
+ });
186
+
187
+ // Start background processing
188
+ streamState.streamPromise = this.processStream(streamState, request).catch(error => {
189
+ streamState.status = 'error';
190
+ streamState.error = this.extractErrorDetail(error);
191
+ streamState.completedAt = new Date();
192
+
193
+ this.emitStreamEvent(streamState, 'error', {
194
+ processId: streamState.processId,
195
+ error: streamState.error,
196
+ timestamp: streamState.completedAt.toISOString()
197
+ });
198
+
199
+ this.emitStreamLifecycle(streamState, 'error');
200
+ });
201
+
202
+ // Auto-cleanup after 5 minutes (fallback for completed/error streams)
203
+ setTimeout(() => {
204
+ if (streamState.status !== 'active') {
205
+ this.cleanupStream(streamId);
206
+ }
207
+ }, 5 * 60 * 1000);
208
+
209
+ return streamId;
210
+ }
211
+
212
+ /**
213
+ * Emit a stream event to all subscribers
214
+ */
215
+ private emitStreamEvent(streamState: StreamState, type: StreamEventType, data: any): void {
216
+ // Increment sequence number for deduplication
217
+ streamState.eventSeq++;
218
+
219
+ // Attach engine type to all event data for frontend metadata
220
+ if (data && typeof data === 'object') {
221
+ data.engine = streamState.engine;
222
+ }
223
+
224
+ const event: StreamEvent = {
225
+ type,
226
+ streamId: streamState.streamId,
227
+ processId: streamState.processId,
228
+ data,
229
+ timestamp: new Date().toISOString(),
230
+ seq: streamState.eventSeq
231
+ };
232
+
233
+ // Emit to stream-specific channel only
234
+ // (session channel removed to prevent duplicate dispatches)
235
+ this.emit(`stream:${streamState.streamId}`, event);
236
+ }
237
+
238
+ /**
239
+ * Subscribe to a stream's events
240
+ */
241
+ subscribeToStream(streamId: string, handler: (event: StreamEvent) => void): () => void {
242
+ const eventName = `stream:${streamId}`;
243
+ this.on(eventName, handler);
244
+
245
+ // Return unsubscribe function
246
+ return () => {
247
+ this.off(eventName, handler);
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Subscribe to a session's events (for reconnection)
253
+ */
254
+ subscribeToSession(projectId: string | undefined, chatSessionId: string, handler: (event: StreamEvent) => void): () => void {
255
+ const sessionKey = this.getSessionKey(projectId, chatSessionId);
256
+ const eventName = `session:${sessionKey}`;
257
+ this.on(eventName, handler);
258
+
259
+ return () => {
260
+ this.off(eventName, handler);
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Process stream in background
266
+ */
267
+ private async processStream(streamState: StreamState, requestData: StreamRequest): Promise<void> {
268
+ // Track user message ID for stream-end snapshot capture
269
+ let userMessageId: string | undefined;
270
+
271
+ try {
272
+ const { projectPath, prompt, chatSessionId, engine: engineType = 'claude-code', model, temperature, claudeAccountId } = requestData;
273
+
274
+ // Validate project path
275
+ const projectPathExists = projectPath ? await this.existsSync(projectPath) : false;
276
+ if (!projectPath) {
277
+ throw new Error('Project path is required. Please select a valid project directory.');
278
+ }
279
+ if (!projectPathExists) {
280
+ throw new Error(`Project path does not exist: ${projectPath}. Please select a valid project directory.`);
281
+ }
282
+ const actualProjectPath = projectPath;
283
+
284
+ // Get resume session ID
285
+ let resumeSessionId: string | undefined = undefined;
286
+ if (chatSessionId) {
287
+ try {
288
+ const chatSession = sessionQueries.getById(chatSessionId);
289
+ if (chatSession?.latest_sdk_session_id) {
290
+ resumeSessionId = chatSession.latest_sdk_session_id;
291
+ }
292
+ } catch (error) {
293
+ debug.error('chat', 'Failed to get chat session for resume:', error);
294
+ }
295
+ }
296
+
297
+ // Prepare user message
298
+ const userMessage = {
299
+ ...(prompt as SDKMessage),
300
+ resume: resumeSessionId ?? null
301
+ } as SDKMessage & { resume: string | null };
302
+
303
+ // Save user message
304
+ const userMessageTimestamp = new Date().toISOString();
305
+ const savedMessage = await this.saveMessage(
306
+ userMessage as SDKMessage,
307
+ chatSessionId,
308
+ userMessageTimestamp,
309
+ requestData.senderId,
310
+ requestData.senderName
311
+ );
312
+
313
+ // Track user message ID for stream-end snapshot
314
+ userMessageId = savedMessage?.id;
315
+
316
+ // Add user message to stream state and emit
317
+ // Keep SDK message clean — non-SDK info goes as transport fields
318
+ streamState.messages.push({
319
+ processId: streamState.processId,
320
+ message: userMessage,
321
+ timestamp: userMessageTimestamp,
322
+ message_id: savedMessage?.id,
323
+ parent_message_id: savedMessage?.parent_message_id || null,
324
+ sender_id: requestData.senderId,
325
+ sender_name: requestData.senderName
326
+ });
327
+
328
+ // Emit user message event
329
+ this.emitStreamEvent(streamState, 'message', {
330
+ processId: streamState.processId,
331
+ message: userMessage,
332
+ timestamp: userMessageTimestamp,
333
+ message_id: savedMessage?.id,
334
+ parent_message_id: savedMessage?.parent_message_id || null,
335
+ sender_id: requestData.senderId,
336
+ sender_name: requestData.senderName
337
+ });
338
+
339
+ // Check for cancellation after saving user message (async DB operation)
340
+ if ((streamState.status as string) === 'cancelled' || streamState.abortController?.signal.aborted) {
341
+ debug.log('chat', 'Stream cancelled after saving user message, skipping query');
342
+ return;
343
+ }
344
+
345
+ let sdkSessionId: string | null = null;
346
+ // Track last assistant text for deduplication — some engines (Claude Code) can
347
+ // emit identical error messages multiple times (e.g. on process exit retry).
348
+ let lastAssistantTextContent: string | null = null;
349
+
350
+ // Get per-project engine instance for stream isolation.
351
+ // Each project gets its own engine so cancel/abort only affects this project.
352
+ const projectId = streamState.projectId || 'default';
353
+
354
+ // Check if already cancelled BEFORE initializing the engine.
355
+ // This prevents starting a new engine query with an already-aborted controller,
356
+ // which can cause unhandled rejections in the SDK and crash the server.
357
+ // Note: status is mutated to 'cancelled' by cancelStream() from a different async context;
358
+ // TypeScript's control flow analysis doesn't track this, so we cast to string.
359
+ if ((streamState.status as string) === 'cancelled' || streamState.abortController?.signal.aborted) {
360
+ debug.log('chat', 'Stream cancelled before engine initialization, skipping query');
361
+ return;
362
+ }
363
+
364
+ const engine = await initializeProjectEngine(projectId, engineType);
365
+
366
+ // Check again after async engine initialization — cancellation may have occurred
367
+ // while awaiting initialization
368
+ if ((streamState.status as string) === 'cancelled' || streamState.abortController?.signal.aborted) {
369
+ debug.log('chat', 'Stream cancelled during engine initialization, skipping query');
370
+ return;
371
+ }
372
+
373
+ // Stream messages through the engine adapter
374
+ for await (const message of engine.streamQuery({
375
+ projectPath: actualProjectPath,
376
+ prompt: prompt,
377
+ resume: resumeSessionId,
378
+ model: model || 'sonnet',
379
+ includePartialMessages: true,
380
+ abortController: streamState.abortController,
381
+ ...(claudeAccountId !== undefined && { claudeAccountId }),
382
+ })) {
383
+ // Check if cancelled (cancelStream() already set status and emitted event)
384
+ if ((streamState.status as string) === 'cancelled' || streamState.abortController?.signal.aborted) {
385
+ break;
386
+ }
387
+
388
+ // Update SDK session ID
389
+ if (!sdkSessionId && message.session_id) {
390
+ sdkSessionId = message.session_id;
391
+ streamState.sdkSessionId = sdkSessionId;
392
+ if (chatSessionId) {
393
+ try {
394
+ sessionQueries.updateLatestSdkSessionId(chatSessionId, sdkSessionId);
395
+ } catch (error) {
396
+ // Ignore
397
+ }
398
+ }
399
+ }
400
+
401
+ // Handle system init message - Check MCP server status, then skip DB save.
402
+ // System init messages are SDK metadata (not conversation content) and don't need
403
+ // to be persisted or snapshotted. Saving them adds unnecessary latency from git ops.
404
+ if (message.type === 'system' && message.subtype === 'init') {
405
+ const systemMessage = message as any;
406
+
407
+ // Check for failed MCP servers
408
+ if (systemMessage.mcp_servers && Array.isArray(systemMessage.mcp_servers)) {
409
+ const failedServers = systemMessage.mcp_servers.filter(
410
+ (server: any) => server.status !== 'connected'
411
+ );
412
+
413
+ // Emit warning notification for each failed server
414
+ failedServers.forEach((server: any) => {
415
+ debug.warn('mcp', `MCP server connection failed: ${server.name} (${server.status})`);
416
+
417
+ this.emitStreamEvent(streamState, 'notification', {
418
+ notification: {
419
+ type: 'warning',
420
+ title: 'MCP Server Connection Failed',
421
+ message: `Failed to connect to custom MCP server "${server.name}". Status: ${server.status}`,
422
+ icon: 'lucide:alert-triangle'
423
+ },
424
+ timestamp: new Date().toISOString()
425
+ });
426
+ });
427
+
428
+ // Log successful connections
429
+ const connectedServers = systemMessage.mcp_servers.filter(
430
+ (server: any) => server.status === 'connected'
431
+ );
432
+ if (connectedServers.length > 0) {
433
+ debug.log('mcp', `✓ Connected MCP servers: ${connectedServers.map((s: any) => s.name).join(', ')}`);
434
+ }
435
+ }
436
+
437
+ // Skip DB save — system init is engine metadata, not conversation content
438
+ continue;
439
+ }
440
+
441
+ // Handle compact boundary messages
442
+ if (message.type === 'system' && message.subtype === 'compact_boundary') {
443
+ const compactMessage = message as SDKCompactBoundaryMessage;
444
+ streamState.hasCompactBoundary = true;
445
+
446
+ streamState.messages.push({
447
+ processId: streamState.processId,
448
+ message,
449
+ timestamp: new Date().toISOString(),
450
+ compactBoundary: {
451
+ trigger: compactMessage.compact_metadata.trigger,
452
+ preTokens: compactMessage.compact_metadata.pre_tokens
453
+ }
454
+ });
455
+
456
+ this.emitStreamEvent(streamState, 'notification', {
457
+ notification: {
458
+ type: 'info',
459
+ title: 'Conversation Compacted',
460
+ message: `Conversation history has been compacted (${compactMessage.compact_metadata.trigger} trigger).`,
461
+ icon: 'lucide:layers'
462
+ },
463
+ timestamp: new Date().toISOString()
464
+ });
465
+ continue;
466
+ }
467
+
468
+ // Handle partial messages (streaming events)
469
+ if (message.type === 'stream_event') {
470
+ const partialMessage = message as SDKPartialAssistantMessage;
471
+ const event = partialMessage.event;
472
+ const isReasoning = message.metadata?.reasoning === true;
473
+
474
+ if (event.type === 'message_start') {
475
+ if (isReasoning) {
476
+ streamState.currentReasoningText = '';
477
+ } else {
478
+ streamState.currentPartialText = '';
479
+ }
480
+ this.emitStreamEvent(streamState, 'partial', {
481
+ processId: streamState.processId,
482
+ eventType: 'start',
483
+ partialText: '',
484
+ deltaText: '',
485
+ ...(isReasoning && { reasoning: true }),
486
+ timestamp: new Date().toISOString()
487
+ });
488
+ } else if (event.type === 'content_block_start') {
489
+ debug.log('chat', `[SM] content_block_start: type=${(event as any).content_block?.type}`);
490
+ // Claude Code: detect thinking blocks
491
+ if ((event as any).content_block?.type === 'thinking') {
492
+ streamState.currentReasoningText = '';
493
+ this.emitStreamEvent(streamState, 'partial', {
494
+ processId: streamState.processId,
495
+ eventType: 'start',
496
+ partialText: '',
497
+ deltaText: '',
498
+ reasoning: true,
499
+ timestamp: new Date().toISOString()
500
+ });
501
+ } else if ((event as any).content_block?.type === 'text') {
502
+ // Reset partial text for new text content block
503
+ // Don't emit the initial text — deltas will provide the content
504
+ // This prevents double-counting if content_block_start.text repeats
505
+ // the first content_block_delta.text
506
+ streamState.currentPartialText = '';
507
+ }
508
+ } else if (event.type === 'content_block_delta') {
509
+ debug.log('chat', `[SM] content_block_delta: deltaType=${(event as any).delta?.type}, hasThinking=${'thinking' in ((event as any).delta || {})}, hasText=${'text' in ((event as any).delta || {})}`);
510
+ // Claude Code: thinking deltas
511
+ if (event.delta && 'thinking' in (event.delta as any)) {
512
+ const thinkingText = (event.delta as any).thinking;
513
+ streamState.currentReasoningText = (streamState.currentReasoningText || '') + thinkingText;
514
+ this.emitStreamEvent(streamState, 'partial', {
515
+ processId: streamState.processId,
516
+ eventType: 'update',
517
+ partialText: streamState.currentReasoningText,
518
+ deltaText: thinkingText,
519
+ reasoning: true,
520
+ timestamp: new Date().toISOString()
521
+ });
522
+ } else if (event.delta && 'text' in event.delta) {
523
+ if (isReasoning) {
524
+ // Open Code: reasoning delta (packaged as text_delta with metadata.reasoning flag)
525
+ const deltaText = event.delta.text;
526
+ streamState.currentReasoningText = (streamState.currentReasoningText || '') + deltaText;
527
+ this.emitStreamEvent(streamState, 'partial', {
528
+ processId: streamState.processId,
529
+ eventType: 'update',
530
+ partialText: streamState.currentReasoningText,
531
+ deltaText: deltaText,
532
+ reasoning: true,
533
+ timestamp: new Date().toISOString()
534
+ });
535
+ } else {
536
+ // Regular text delta
537
+ const deltaText = event.delta.text;
538
+ streamState.currentPartialText = (streamState.currentPartialText || '') + deltaText;
539
+ this.emitStreamEvent(streamState, 'partial', {
540
+ processId: streamState.processId,
541
+ eventType: 'update',
542
+ partialText: streamState.currentPartialText,
543
+ deltaText: deltaText,
544
+ timestamp: new Date().toISOString()
545
+ });
546
+ }
547
+ }
548
+ } else if (event.type === 'content_block_stop') {
549
+ // Claude Code: end of a thinking block — emit reasoning end and clear state
550
+ if (streamState.currentReasoningText) {
551
+ this.emitStreamEvent(streamState, 'partial', {
552
+ processId: streamState.processId,
553
+ eventType: 'end',
554
+ partialText: streamState.currentReasoningText,
555
+ deltaText: '',
556
+ reasoning: true,
557
+ timestamp: new Date().toISOString()
558
+ });
559
+ // Clear so subsequent content_block_stop (text/tool) doesn't re-trigger
560
+ streamState.currentReasoningText = '';
561
+ }
562
+ } else if (event.type === 'message_stop') {
563
+ if (isReasoning) {
564
+ this.emitStreamEvent(streamState, 'partial', {
565
+ processId: streamState.processId,
566
+ eventType: 'end',
567
+ partialText: streamState.currentReasoningText || '',
568
+ deltaText: '',
569
+ reasoning: true,
570
+ timestamp: new Date().toISOString()
571
+ });
572
+ } else {
573
+ this.emitStreamEvent(streamState, 'partial', {
574
+ processId: streamState.processId,
575
+ eventType: 'end',
576
+ partialText: streamState.currentPartialText || '',
577
+ deltaText: '',
578
+ timestamp: new Date().toISOString()
579
+ });
580
+ }
581
+ }
582
+ continue;
583
+ }
584
+
585
+ // Split Claude Code assistant messages that contain thinking blocks
586
+ // into separate reasoning + text messages
587
+ if (message.type === 'assistant' && !message.metadata?.reasoning
588
+ && Array.isArray(message.message?.content)) {
589
+ const content = (message as any).message.content;
590
+ const contentTypes = content.map((b: any) => b.type);
591
+ debug.log('chat', `[SM] assistant message content types: ${JSON.stringify(contentTypes)}`);
592
+ const thinkingBlocks = content.filter((b: any) => b.type === 'thinking');
593
+
594
+ if (thinkingBlocks.length > 0) {
595
+ const reasoningText = thinkingBlocks
596
+ .map((b: any) => b.thinking || '')
597
+ .join('\n');
598
+ const otherBlocks = content.filter((b: any) => b.type !== 'thinking');
599
+
600
+ // 1. Emit reasoning message
601
+ // Synthetic reasoning message — extracted thinking blocks as text
602
+ const reasoningMsg = {
603
+ type: 'assistant' as const,
604
+ message: {
605
+ ...message.message,
606
+ content: [{ type: 'text' as const, text: reasoningText }],
607
+ },
608
+ uuid: crypto.randomUUID(),
609
+ session_id: message.session_id,
610
+ parent_tool_use_id: null,
611
+ metadata: { reasoning: true },
612
+ } as unknown as EngineSDKMessage;
613
+
614
+ const reasoningTimestamp = new Date().toISOString();
615
+ let savedReasoningId: string | undefined;
616
+ let savedReasoningParentId: string | null = null;
617
+ if (chatSessionId) {
618
+ const saved = await this.saveMessage(
619
+ reasoningMsg, chatSessionId, reasoningTimestamp,
620
+ requestData.senderId, requestData.senderName
621
+ );
622
+ savedReasoningId = saved?.id;
623
+ savedReasoningParentId = saved?.parent_message_id || null;
624
+ }
625
+
626
+ this.emitStreamEvent(streamState, 'message', {
627
+ processId: streamState.processId,
628
+ message: reasoningMsg,
629
+ timestamp: reasoningTimestamp,
630
+ message_id: savedReasoningId,
631
+ parent_message_id: savedReasoningParentId,
632
+ sender_id: requestData.senderId,
633
+ sender_name: requestData.senderName
634
+ });
635
+
636
+ // 2. Strip thinking blocks from original message
637
+ (message as any).message.content = otherBlocks.length > 0
638
+ ? otherBlocks
639
+ : [{ type: 'text', text: '' }];
640
+ }
641
+ }
642
+
643
+ // Deduplicate consecutive identical assistant messages.
644
+ // Some engines (e.g. Claude Code on process exit) can emit the same error
645
+ // text multiple times. Skip if current text matches the previous assistant message.
646
+ if (message.type === 'assistant' && !message.metadata?.reasoning) {
647
+ const content = message.message?.content;
648
+ if (Array.isArray(content)) {
649
+ const currentText = content
650
+ .filter((c: any) => c.type === 'text')
651
+ .map((c: any) => c.text as string)
652
+ .join('');
653
+ if (currentText && currentText === lastAssistantTextContent) {
654
+ debug.warn('chat', 'Skipping duplicate consecutive assistant message');
655
+ continue;
656
+ }
657
+ if (currentText) lastAssistantTextContent = currentText;
658
+ }
659
+ } else if (message.type !== 'assistant') {
660
+ // Reset tracker on non-assistant messages (user/system/tool result)
661
+ lastAssistantTextContent = null;
662
+ }
663
+
664
+ // Handle complete messages
665
+ const usage = message.type === 'assistant' && message.message?.usage
666
+ ? message.message.usage
667
+ : undefined;
668
+
669
+ const messageTimestamp = new Date().toISOString();
670
+
671
+ // Save to database first to get message_id and parent_message_id
672
+ let savedMsgId: string | undefined;
673
+ let savedParentId: string | null = null;
674
+ if (chatSessionId) {
675
+ const saved = await this.saveMessage(
676
+ message,
677
+ chatSessionId,
678
+ messageTimestamp,
679
+ requestData.senderId,
680
+ requestData.senderName
681
+ );
682
+ savedMsgId = saved?.id;
683
+ savedParentId = saved?.parent_message_id || null;
684
+ }
685
+
686
+ streamState.messages.push({
687
+ processId: streamState.processId,
688
+ message,
689
+ usage,
690
+ timestamp: messageTimestamp,
691
+ message_id: savedMsgId,
692
+ parent_message_id: savedParentId,
693
+ sender_id: requestData.senderId,
694
+ sender_name: requestData.senderName
695
+ });
696
+
697
+ streamState.currentMessage = message;
698
+
699
+ // Emit message event
700
+ this.emitStreamEvent(streamState, 'message', {
701
+ processId: streamState.processId,
702
+ message,
703
+ usage,
704
+ timestamp: messageTimestamp,
705
+ message_id: savedMsgId,
706
+ parent_message_id: savedParentId,
707
+ sender_id: requestData.senderId,
708
+ sender_name: requestData.senderName
709
+ });
710
+ }
711
+
712
+ // Only mark as completed if not already cancelled/errored
713
+ if (streamState.status === 'active') {
714
+ streamState.status = 'completed';
715
+ streamState.completedAt = new Date();
716
+
717
+ this.emitStreamEvent(streamState, 'complete', {
718
+ processId: streamState.processId,
719
+ timestamp: streamState.completedAt.toISOString()
720
+ });
721
+
722
+ this.emitStreamLifecycle(streamState, 'completed');
723
+ }
724
+
725
+ // Auto-release MCP control when stream completes
726
+ browserMcpControl.releaseControl();
727
+ debug.log('mcp', '✅ Auto-released MCP control on stream completion');
728
+
729
+ } catch (error) {
730
+ // Don't overwrite status if already cancelled by cancelStream()
731
+ if (streamState.status !== 'cancelled') {
732
+ streamState.status = 'error';
733
+ streamState.error = this.extractErrorDetail(error);
734
+ streamState.completedAt = new Date();
735
+
736
+ const errorTimestamp = streamState.completedAt.toISOString();
737
+
738
+ // Build a synthetic assistant message for the error so it is saved to DB
739
+ // and persists across browser refresh (not just an ephemeral UI injection).
740
+ const errorAssistantMsg = {
741
+ type: 'assistant' as const,
742
+ uuid: crypto.randomUUID(),
743
+ session_id: streamState.sdkSessionId || '',
744
+ parent_tool_use_id: null,
745
+ message: {
746
+ role: 'assistant' as const,
747
+ content: [{ type: 'text' as const, text: `**Error:** ${streamState.error}` }],
748
+ },
749
+ } as EngineSDKMessage;
750
+
751
+ // Save error message to DB (no snapshot needed for error messages)
752
+ let savedErrorMsgId: string | undefined;
753
+ let savedErrorParentId: string | null = null;
754
+ if (requestData.chatSessionId) {
755
+ const saved = await this.saveMessage(
756
+ errorAssistantMsg,
757
+ requestData.chatSessionId,
758
+ errorTimestamp,
759
+ requestData.senderId,
760
+ requestData.senderName
761
+ );
762
+ savedErrorMsgId = saved?.id;
763
+ savedErrorParentId = saved?.parent_message_id || null;
764
+ }
765
+
766
+ // Emit as chat:message so the error appears in the conversation list
767
+ // and is loaded from DB on browser refresh
768
+ this.emitStreamEvent(streamState, 'message', {
769
+ processId: streamState.processId,
770
+ message: errorAssistantMsg,
771
+ timestamp: errorTimestamp,
772
+ message_id: savedErrorMsgId,
773
+ parent_message_id: savedErrorParentId,
774
+ sender_id: requestData.senderId,
775
+ sender_name: requestData.senderName,
776
+ });
777
+
778
+ // Emit chat:error to signal stream termination (triggers isLoading=false,
779
+ // sound/push notification, and stream cleanup on the frontend)
780
+ this.emitStreamEvent(streamState, 'error', {
781
+ processId: streamState.processId,
782
+ error: streamState.error,
783
+ timestamp: errorTimestamp
784
+ });
785
+
786
+ this.emitStreamLifecycle(streamState, 'error');
787
+ }
788
+
789
+ // Auto-release MCP control on stream error
790
+ browserMcpControl.releaseControl();
791
+ debug.log('mcp', '✅ Auto-released MCP control on stream error');
792
+ } finally {
793
+ // Capture snapshot ONCE at stream end (regardless of completion/error/cancel).
794
+ // Associates the snapshot with the user message (checkpoint) that triggered the stream.
795
+ const { projectPath, projectId, chatSessionId } = requestData;
796
+ if (projectPath && projectId && chatSessionId && userMessageId) {
797
+ snapshotService.captureSnapshot(projectPath, projectId, chatSessionId, userMessageId)
798
+ .then(() => debug.log('chat', `Stream-end snapshot captured for message: ${userMessageId}`))
799
+ .catch(err => debug.error('chat', 'Failed to capture stream-end snapshot:', err));
800
+ }
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Get stream state by ID
806
+ */
807
+ getStream(streamId: string): StreamState | undefined {
808
+ return this.activeStreams.get(streamId);
809
+ }
810
+
811
+ /**
812
+ * Get active stream for a chat session
813
+ */
814
+ getSessionStream(chatSessionId: string, projectId?: string): StreamState | undefined {
815
+ if (projectId) {
816
+ const sessionKey = this.getSessionKey(projectId, chatSessionId);
817
+ const streamIdWithProject = this.sessionStreams.get(sessionKey);
818
+ if (streamIdWithProject) {
819
+ const stream = this.activeStreams.get(streamIdWithProject);
820
+ if (stream) return stream;
821
+ }
822
+ }
823
+
824
+ // Legacy fallback
825
+ const streamId = this.sessionStreams.get(chatSessionId);
826
+ if (!streamId) return undefined;
827
+ return this.activeStreams.get(streamId);
828
+ }
829
+
830
+ /**
831
+ * Generate session key
832
+ */
833
+ private getSessionKey(projectId: string | undefined, chatSessionId: string): string {
834
+ return projectId ? `${projectId}-${chatSessionId}` : chatSessionId;
835
+ }
836
+
837
+ /**
838
+ * Cancel an active stream
839
+ */
840
+ async cancelStream(streamId: string): Promise<boolean> {
841
+ const streamState = this.activeStreams.get(streamId);
842
+ if (!streamState || streamState.status !== 'active') {
843
+ return false;
844
+ }
845
+
846
+ // Mark as cancelled FIRST to prevent further message processing
847
+ streamState.status = 'cancelled';
848
+ streamState.completedAt = new Date();
849
+
850
+ // Save partial text to DB before cancelling (persists across refresh/project switch)
851
+ if (streamState.currentPartialText && streamState.chatSessionId) {
852
+ try {
853
+ const partialMessage = {
854
+ type: 'assistant' as const,
855
+ parent_tool_use_id: null,
856
+ message: {
857
+ role: 'assistant' as const,
858
+ content: [{ type: 'text' as const, text: streamState.currentPartialText }]
859
+ },
860
+ session_id: streamState.sdkSessionId || '',
861
+ partialText: streamState.currentPartialText
862
+ };
863
+
864
+ const timestamp = new Date().toISOString();
865
+ const currentHead = sessionQueries.getHead(streamState.chatSessionId);
866
+
867
+ const savedMessage = messageQueries.create({
868
+ session_id: streamState.chatSessionId,
869
+ sdk_message: partialMessage as any,
870
+ timestamp,
871
+ parent_message_id: currentHead || undefined
872
+ });
873
+
874
+ sessionQueries.updateHead(streamState.chatSessionId, savedMessage.id);
875
+ debug.log('chat', 'Saved partial text on cancel:', savedMessage.id);
876
+ } catch (error) {
877
+ debug.error('chat', 'Failed to save partial text on cancel:', error);
878
+ }
879
+ }
880
+
881
+ // Abort the stream-manager's controller FIRST.
882
+ // This ensures processStream() sees the abort signal at its next check point
883
+ // and avoids starting a new engine query with an already-aborted controller.
884
+ streamState.abortController?.abort();
885
+
886
+ // Cancel the per-project engine — this is safe because each project
887
+ // has its own engine instance (via getProjectEngine), so cancel()
888
+ // only affects this project's active query/session, not other projects.
889
+ // Wrapped in try/catch to prevent SDK errors from crashing the server.
890
+ const projectId = streamState.projectId || 'default';
891
+ try {
892
+ const engine = getProjectEngine(projectId, streamState.engine);
893
+ if (engine.isActive) {
894
+ await engine.cancel();
895
+ }
896
+ } catch (error) {
897
+ debug.error('chat', 'Error cancelling engine (non-fatal):', error);
898
+ }
899
+
900
+ this.emitStreamEvent(streamState, 'cancelled', {
901
+ processId: streamState.processId,
902
+ timestamp: streamState.completedAt.toISOString()
903
+ });
904
+
905
+ this.emitStreamLifecycle(streamState, 'cancelled');
906
+
907
+ // Auto-release MCP control on stream cancellation
908
+ browserMcpControl.releaseControl();
909
+ debug.log('mcp', '✅ Auto-released MCP control on stream cancellation');
910
+
911
+ return true;
912
+ }
913
+
914
+ /**
915
+ * Clean up a stream
916
+ */
917
+ private cleanupStream(streamId: string): void {
918
+ const streamState = this.activeStreams.get(streamId);
919
+ if (streamState) {
920
+ const sessionKey = this.getSessionKey(streamState.projectId, streamState.chatSessionId);
921
+ this.sessionStreams.delete(sessionKey);
922
+ this.sessionStreams.delete(streamState.chatSessionId);
923
+ this.activeStreams.delete(streamId);
924
+
925
+ // Cleanup project context service
926
+ projectContextService.unregisterStream(streamId);
927
+
928
+ // Remove all listeners for this stream
929
+ this.removeAllListeners(`stream:${streamId}`);
930
+ this.removeAllListeners(`session:${sessionKey}`);
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Get all active streams
936
+ */
937
+ getActiveStreams(): StreamState[] {
938
+ return Array.from(this.activeStreams.values())
939
+ .filter(stream => stream.status === 'active');
940
+ }
941
+
942
+ /**
943
+ * Save message to database
944
+ */
945
+ private async saveMessage(
946
+ message: EngineSDKMessage,
947
+ sessionId: string,
948
+ timestamp: string,
949
+ senderId?: string,
950
+ senderName?: string
951
+ ): Promise<DatabaseMessage | null> {
952
+ try {
953
+ const currentHead = sessionQueries.getHead(sessionId);
954
+
955
+ const savedMessage = messageQueries.create({
956
+ session_id: sessionId,
957
+ sdk_message: message,
958
+ timestamp,
959
+ sender_id: senderId,
960
+ sender_name: senderName,
961
+ parent_message_id: currentHead || undefined
962
+ });
963
+
964
+ sessionQueries.updateHead(sessionId, savedMessage.id);
965
+
966
+ // Update checkpoint_tree_state when saving a new checkpoint (real user message)
967
+ if (message.type === 'user') {
968
+ try {
969
+ const { isCheckpointMessage, buildCheckpointTree, getCheckpointPathToRoot } = await import('../../lib/snapshot/helpers');
970
+ const { checkpointQueries } = await import('../database/queries');
971
+
972
+ // Check if this new message is a checkpoint
973
+ if (isCheckpointMessage(savedMessage)) {
974
+ const allMessages = messageQueries.getAllBySessionId(sessionId);
975
+ const { parentMap } = buildCheckpointTree(allMessages);
976
+
977
+ // Update active children along path from root to this new checkpoint
978
+ const checkpointPath = getCheckpointPathToRoot(savedMessage.id, parentMap);
979
+ if (checkpointPath.length > 1) {
980
+ checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
981
+ }
982
+
983
+ debug.log('snapshot', `Checkpoint tree state updated for new checkpoint ${savedMessage.id.slice(0, 8)}`);
984
+ }
985
+ } catch (err) {
986
+ debug.error('snapshot', 'Failed to update checkpoint tree state:', err);
987
+ }
988
+ }
989
+
990
+ return savedMessage;
991
+ } catch (error) {
992
+ debug.error('chat', 'Failed to save message to database:', error);
993
+ return null;
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Extract detailed error info from an error object.
999
+ * Handles engine-specific error shapes (Anthropic APIError, OpenCode SDK errors).
1000
+ */
1001
+ private extractErrorDetail(error: unknown): string {
1002
+ if (!error) return 'Unknown error';
1003
+ if (typeof error === 'string') return this.normalizeErrorText(error);
1004
+ if (!(error instanceof Error)) return this.normalizeErrorText(String(error));
1005
+
1006
+ const err = error as Record<string, any>;
1007
+ let message = err.message || 'Unknown error';
1008
+
1009
+ // Enrich with status code if available (Anthropic APIError has .status)
1010
+ if (err.status && !message.includes(String(err.status))) {
1011
+ message += ` (status ${err.status})`;
1012
+ }
1013
+
1014
+ // Enrich with nested error body (Anthropic APIError has .error.message)
1015
+ if (err.error?.message && !message.includes(err.error.message)) {
1016
+ message += ` - ${err.error.message}`;
1017
+ }
1018
+
1019
+ return this.normalizeErrorText(message);
1020
+ }
1021
+
1022
+ /**
1023
+ * Strip redundant error class name prefixes from error text.
1024
+ *
1025
+ * Error text often passes through multiple wrapping layers (SDK → adapter → stream-manager),
1026
+ * each potentially adding class names and "Error:" prefixes. This strips them:
1027
+ *
1028
+ * "APIError: Bad Request: ..." → "Bad Request: ..."
1029
+ * "UnknownError: Error: Unable to connect..." → "Unable to connect..."
1030
+ * "Claude Code process exited with code 1" → unchanged
1031
+ */
1032
+ private normalizeErrorText(text: string): string {
1033
+ let cleaned = text;
1034
+
1035
+ // Strip error class name prefixes (e.g. "APIError: ", "UnknownError: ", "BadRequestError: ")
1036
+ cleaned = cleaned.replace(/^[A-Za-z]*Error:\s*/, '');
1037
+
1038
+ // Strip a second redundant "Error: " that may remain (e.g. "UnknownError: Error: ...")
1039
+ cleaned = cleaned.replace(/^Error:\s*/, '');
1040
+
1041
+ return cleaned.trim() || text;
1042
+ }
1043
+
1044
+ /**
1045
+ * Check if file exists
1046
+ */
1047
+ private async existsSync(filePath: string): Promise<boolean> {
1048
+ try {
1049
+ const file = Bun.file(filePath);
1050
+ await file.stat();
1051
+ return true;
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Get all streams for a specific project
1059
+ */
1060
+ getProjectStreams(projectId: string): StreamState[] {
1061
+ const projectStreams: StreamState[] = [];
1062
+ this.activeStreams.forEach(stream => {
1063
+ if (stream.projectId === projectId) {
1064
+ projectStreams.push(stream);
1065
+ }
1066
+ });
1067
+ return projectStreams;
1068
+ }
1069
+
1070
+ /**
1071
+ * Get all streams
1072
+ */
1073
+ getAllStreams(): StreamState[] {
1074
+ return Array.from(this.activeStreams.values());
1075
+ }
1076
+
1077
+ /**
1078
+ * Check if project has active streams
1079
+ */
1080
+ hasActiveStreams(projectId: string): boolean {
1081
+ for (const stream of this.activeStreams.values()) {
1082
+ if (stream.projectId === projectId && stream.status === 'active') {
1083
+ return true;
1084
+ }
1085
+ }
1086
+ return false;
1087
+ }
1088
+
1089
+ /**
1090
+ * Clean up completed streams for a project
1091
+ */
1092
+ cleanupProjectStreams(projectId: string): void {
1093
+ const streamsToClean: string[] = [];
1094
+
1095
+ this.activeStreams.forEach((stream, streamId) => {
1096
+ if (stream.projectId === projectId &&
1097
+ (stream.status === 'completed' || stream.status === 'error' || stream.status === 'cancelled')) {
1098
+ streamsToClean.push(streamId);
1099
+ }
1100
+ });
1101
+
1102
+ streamsToClean.forEach(streamId => {
1103
+ this.cleanupStream(streamId);
1104
+ });
1105
+ }
1106
+
1107
+ /**
1108
+ * Clean up all completed streams
1109
+ */
1110
+ cleanupAllCompletedStreams(): void {
1111
+ const streamsToClean: string[] = [];
1112
+
1113
+ this.activeStreams.forEach((stream, streamId) => {
1114
+ if (stream.status !== 'active') {
1115
+ streamsToClean.push(streamId);
1116
+ }
1117
+ });
1118
+
1119
+ streamsToClean.forEach(streamId => {
1120
+ this.cleanupStream(streamId);
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ // Export singleton instance
1126
+ export const streamManager = new StreamManager();