@myrialabs/clopen 0.0.4 → 0.0.6

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 (453) hide show
  1. package/.env.example +5 -5
  2. package/.github/workflows/{release.yml → ci.yml} +86 -60
  3. package/CONTRIBUTING.md +499 -499
  4. package/LICENSE +21 -21
  5. package/README.md +209 -209
  6. package/backend/index.ts +168 -156
  7. package/backend/lib/chat/helpers.ts +42 -42
  8. package/backend/lib/chat/index.ts +1 -1
  9. package/backend/lib/chat/stream-manager.ts +1126 -1126
  10. package/backend/lib/database/README.md +76 -76
  11. package/backend/lib/database/index.ts +118 -118
  12. package/backend/lib/database/migrations/001_create_projects_table.ts +30 -30
  13. package/backend/lib/database/migrations/002_create_chat_sessions_table.ts +32 -32
  14. package/backend/lib/database/migrations/003_create_messages_table.ts +31 -31
  15. package/backend/lib/database/migrations/004_create_prompt_templates_table.ts +34 -34
  16. package/backend/lib/database/migrations/005_create_settings_table.ts +23 -23
  17. package/backend/lib/database/migrations/006_add_user_to_messages.ts +57 -57
  18. package/backend/lib/database/migrations/007_create_stream_states_table.ts +40 -40
  19. package/backend/lib/database/migrations/008_create_message_snapshots_table.ts +61 -61
  20. package/backend/lib/database/migrations/009_add_delta_snapshot_fields.ts +41 -41
  21. package/backend/lib/database/migrations/010_add_soft_delete_and_branch_support.ts +70 -70
  22. package/backend/lib/database/migrations/011_git_like_commit_graph.ts +156 -156
  23. package/backend/lib/database/migrations/012_add_file_change_statistics.ts +41 -41
  24. package/backend/lib/database/migrations/013_checkpoint_tree_state.ts +118 -118
  25. package/backend/lib/database/migrations/014_add_engine_to_sessions.ts +18 -18
  26. package/backend/lib/database/migrations/015_add_model_to_sessions.ts +18 -18
  27. package/backend/lib/database/migrations/016_create_user_projects_table.ts +34 -34
  28. package/backend/lib/database/migrations/017_add_current_session_to_user_projects.ts +32 -32
  29. package/backend/lib/database/migrations/018_create_claude_accounts_table.ts +24 -24
  30. package/backend/lib/database/migrations/019_add_claude_account_to_sessions.ts +18 -18
  31. package/backend/lib/database/migrations/020_add_snapshot_tree_hash.ts +32 -32
  32. package/backend/lib/database/migrations/021_drop_prompt_templates_table.ts +33 -33
  33. package/backend/lib/database/migrations/index.ts +153 -153
  34. package/backend/lib/database/queries/checkpoint-queries.ts +87 -87
  35. package/backend/lib/database/queries/engine-queries.ts +75 -75
  36. package/backend/lib/database/queries/index.ts +8 -8
  37. package/backend/lib/database/queries/message-queries.ts +471 -471
  38. package/backend/lib/database/queries/project-queries.ts +117 -117
  39. package/backend/lib/database/queries/session-queries.ts +270 -270
  40. package/backend/lib/database/queries/settings-queries.ts +33 -33
  41. package/backend/lib/database/queries/snapshot-queries.ts +325 -325
  42. package/backend/lib/database/queries/utils-queries.ts +58 -58
  43. package/backend/lib/database/seeders/index.ts +12 -12
  44. package/backend/lib/database/seeders/settings_seeder.ts +83 -83
  45. package/backend/lib/database/utils/connection.ts +173 -173
  46. package/backend/lib/database/utils/index.ts +3 -3
  47. package/backend/lib/database/utils/migration-runner.ts +117 -117
  48. package/backend/lib/database/utils/seeder-runner.ts +120 -120
  49. package/backend/lib/engine/adapters/claude/environment.ts +160 -164
  50. package/backend/lib/engine/adapters/claude/error-handler.ts +60 -60
  51. package/backend/lib/engine/adapters/claude/index.ts +1 -1
  52. package/backend/lib/engine/adapters/claude/path-utils.ts +38 -38
  53. package/backend/lib/engine/adapters/claude/stream.ts +177 -177
  54. package/backend/lib/engine/adapters/opencode/index.ts +2 -2
  55. package/backend/lib/engine/adapters/opencode/message-converter.ts +862 -862
  56. package/backend/lib/engine/adapters/opencode/server.ts +104 -104
  57. package/backend/lib/engine/adapters/opencode/stream.ts +755 -755
  58. package/backend/lib/engine/index.ts +196 -196
  59. package/backend/lib/engine/types.ts +58 -58
  60. package/backend/lib/files/file-operations.ts +478 -478
  61. package/backend/lib/files/file-reading.ts +308 -308
  62. package/backend/lib/files/file-watcher.ts +383 -383
  63. package/backend/lib/files/path-browsing.ts +382 -382
  64. package/backend/lib/git/git-executor.ts +89 -88
  65. package/backend/lib/git/git-parser.ts +411 -411
  66. package/backend/lib/git/git-service.ts +505 -505
  67. package/backend/lib/mcp/README.md +1144 -1144
  68. package/backend/lib/mcp/config.ts +317 -316
  69. package/backend/lib/mcp/index.ts +35 -35
  70. package/backend/lib/mcp/project-context.ts +236 -236
  71. package/backend/lib/mcp/servers/browser-automation/actions.ts +156 -156
  72. package/backend/lib/mcp/servers/browser-automation/browser.ts +419 -419
  73. package/backend/lib/mcp/servers/browser-automation/index.ts +791 -791
  74. package/backend/lib/mcp/servers/browser-automation/inspection.ts +501 -501
  75. package/backend/lib/mcp/servers/helper.ts +143 -143
  76. package/backend/lib/mcp/servers/index.ts +44 -44
  77. package/backend/lib/mcp/servers/weather/get-temperature.ts +56 -56
  78. package/backend/lib/mcp/servers/weather/index.ts +31 -31
  79. package/backend/lib/mcp/stdio-server.ts +103 -103
  80. package/backend/lib/mcp/types.ts +65 -65
  81. package/backend/lib/preview/browser/browser-audio-capture.ts +86 -86
  82. package/backend/lib/preview/browser/browser-console-manager.ts +262 -262
  83. package/backend/lib/preview/browser/browser-dialog-handler.ts +222 -222
  84. package/backend/lib/preview/browser/browser-interaction-handler.ts +421 -421
  85. package/backend/lib/preview/browser/browser-mcp-control.ts +415 -415
  86. package/backend/lib/preview/browser/browser-native-ui-handler.ts +512 -512
  87. package/backend/lib/preview/browser/browser-navigation-tracker.ts +103 -103
  88. package/backend/lib/preview/browser/browser-pool.ts +357 -357
  89. package/backend/lib/preview/browser/browser-preview-service.ts +882 -882
  90. package/backend/lib/preview/browser/browser-tab-manager.ts +935 -935
  91. package/backend/lib/preview/browser/browser-video-capture.ts +695 -695
  92. package/backend/lib/preview/browser/scripts/audio-stream.ts +292 -292
  93. package/backend/lib/preview/browser/scripts/cursor-tracking.ts +85 -85
  94. package/backend/lib/preview/browser/scripts/video-stream.ts +438 -438
  95. package/backend/lib/preview/browser/types.ts +359 -359
  96. package/backend/lib/preview/index.ts +23 -23
  97. package/backend/lib/project/index.ts +1 -1
  98. package/backend/lib/project/status-manager.ts +181 -181
  99. package/backend/lib/shared/env.ts +117 -0
  100. package/backend/lib/shared/index.ts +5 -2
  101. package/backend/lib/shared/port-utils.ts +25 -25
  102. package/backend/lib/shared/process-manager.ts +280 -280
  103. package/backend/lib/snapshot/blob-store.ts +227 -227
  104. package/backend/lib/snapshot/gitignore.ts +307 -307
  105. package/backend/lib/snapshot/helpers.ts +397 -397
  106. package/backend/lib/snapshot/snapshot-service.ts +483 -483
  107. package/backend/lib/terminal/helpers.ts +14 -14
  108. package/backend/lib/terminal/index.ts +7 -7
  109. package/backend/lib/terminal/pty-manager.ts +3 -3
  110. package/backend/lib/terminal/pty-session-manager.ts +370 -387
  111. package/backend/lib/terminal/shell-utils.ts +315 -312
  112. package/backend/lib/terminal/stream-manager.ts +292 -292
  113. package/backend/lib/tunnel/global-tunnel-manager.ts +266 -243
  114. package/backend/lib/tunnel/project-tunnel-manager.ts +311 -311
  115. package/backend/lib/user/helpers.ts +87 -87
  116. package/backend/lib/utils/ws.ts +944 -944
  117. package/backend/lib/vite-dev.ts +295 -295
  118. package/backend/middleware/cors.ts +16 -15
  119. package/backend/middleware/error-handler.ts +50 -49
  120. package/backend/middleware/logger.ts +9 -9
  121. package/backend/types/api.ts +24 -24
  122. package/backend/ws/README.md +1505 -1505
  123. package/backend/ws/chat/background.ts +198 -198
  124. package/backend/ws/chat/index.ts +21 -21
  125. package/backend/ws/chat/stream.ts +707 -707
  126. package/backend/ws/engine/claude/accounts.ts +399 -401
  127. package/backend/ws/engine/claude/index.ts +13 -13
  128. package/backend/ws/engine/claude/status.ts +43 -43
  129. package/backend/ws/engine/index.ts +14 -14
  130. package/backend/ws/engine/opencode/index.ts +11 -11
  131. package/backend/ws/engine/opencode/status.ts +30 -30
  132. package/backend/ws/engine/utils.ts +36 -36
  133. package/backend/ws/files/index.ts +30 -30
  134. package/backend/ws/files/read.ts +189 -189
  135. package/backend/ws/files/search.ts +453 -453
  136. package/backend/ws/files/watch.ts +124 -124
  137. package/backend/ws/files/write.ts +143 -143
  138. package/backend/ws/git/branch.ts +106 -106
  139. package/backend/ws/git/commit.ts +39 -39
  140. package/backend/ws/git/conflict.ts +68 -68
  141. package/backend/ws/git/diff.ts +69 -69
  142. package/backend/ws/git/index.ts +24 -24
  143. package/backend/ws/git/log.ts +41 -41
  144. package/backend/ws/git/remote.ts +214 -214
  145. package/backend/ws/git/staging.ts +84 -84
  146. package/backend/ws/git/status.ts +90 -90
  147. package/backend/ws/index.ts +69 -69
  148. package/backend/ws/mcp/index.ts +61 -61
  149. package/backend/ws/messages/crud.ts +74 -74
  150. package/backend/ws/messages/index.ts +14 -14
  151. package/backend/ws/preview/browser/cleanup.ts +129 -129
  152. package/backend/ws/preview/browser/console.ts +114 -114
  153. package/backend/ws/preview/browser/interact.ts +513 -513
  154. package/backend/ws/preview/browser/mcp.ts +129 -129
  155. package/backend/ws/preview/browser/native-ui.ts +235 -235
  156. package/backend/ws/preview/browser/stats.ts +55 -55
  157. package/backend/ws/preview/browser/tab-info.ts +126 -126
  158. package/backend/ws/preview/browser/tab.ts +166 -166
  159. package/backend/ws/preview/browser/webcodecs.ts +293 -293
  160. package/backend/ws/preview/index.ts +146 -146
  161. package/backend/ws/projects/crud.ts +113 -113
  162. package/backend/ws/projects/index.ts +25 -25
  163. package/backend/ws/projects/presence.ts +46 -46
  164. package/backend/ws/projects/status.ts +116 -116
  165. package/backend/ws/sessions/crud.ts +327 -327
  166. package/backend/ws/sessions/index.ts +33 -33
  167. package/backend/ws/settings/crud.ts +112 -112
  168. package/backend/ws/settings/index.ts +14 -14
  169. package/backend/ws/snapshot/index.ts +17 -17
  170. package/backend/ws/snapshot/restore.ts +173 -173
  171. package/backend/ws/snapshot/timeline.ts +141 -141
  172. package/backend/ws/system/index.ts +14 -14
  173. package/backend/ws/system/operations.ts +49 -49
  174. package/backend/ws/terminal/index.ts +40 -40
  175. package/backend/ws/terminal/persistence.ts +153 -153
  176. package/backend/ws/terminal/session.ts +382 -382
  177. package/backend/ws/terminal/stream.ts +79 -79
  178. package/backend/ws/tunnel/index.ts +14 -14
  179. package/backend/ws/tunnel/operations.ts +91 -91
  180. package/backend/ws/types.ts +20 -20
  181. package/backend/ws/user/crud.ts +156 -156
  182. package/backend/ws/user/index.ts +14 -14
  183. package/bin/clopen.ts +307 -307
  184. package/bun.lock +1353 -1352
  185. package/frontend/App.svelte +38 -34
  186. package/frontend/app.css +313 -313
  187. package/frontend/lib/app-environment.ts +10 -10
  188. package/frontend/lib/components/chat/ChatInterface.svelte +406 -406
  189. package/frontend/lib/components/chat/formatters/ErrorMessage.svelte +56 -56
  190. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +223 -223
  191. package/frontend/lib/components/chat/formatters/TextMessage.svelte +394 -394
  192. package/frontend/lib/components/chat/formatters/Tools.svelte +69 -69
  193. package/frontend/lib/components/chat/formatters/index.ts +2 -2
  194. package/frontend/lib/components/chat/input/ChatInput.svelte +421 -421
  195. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +78 -78
  196. package/frontend/lib/components/chat/input/components/DragDropOverlay.svelte +30 -30
  197. package/frontend/lib/components/chat/input/components/EditModeIndicator.svelte +33 -33
  198. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +619 -619
  199. package/frontend/lib/components/chat/input/components/FileAttachmentPreview.svelte +48 -48
  200. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +31 -31
  201. package/frontend/lib/components/chat/input/composables/use-animations.svelte.ts +201 -201
  202. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +148 -148
  203. package/frontend/lib/components/chat/input/composables/use-file-handling.svelte.ts +216 -216
  204. package/frontend/lib/components/chat/input/composables/use-input-state.svelte.ts +357 -357
  205. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +57 -57
  206. package/frontend/lib/components/chat/message/ChatMessage.svelte +478 -478
  207. package/frontend/lib/components/chat/message/ChatMessages.svelte +541 -541
  208. package/frontend/lib/components/chat/message/DateSeparator.svelte +86 -86
  209. package/frontend/lib/components/chat/message/MessageBubble.svelte +86 -86
  210. package/frontend/lib/components/chat/message/MessageHeader.svelte +157 -157
  211. package/frontend/lib/components/chat/modal/DebugModal.svelte +59 -59
  212. package/frontend/lib/components/chat/modal/TokenUsageModal.svelte +124 -124
  213. package/frontend/lib/components/chat/shared/index.ts +1 -1
  214. package/frontend/lib/components/chat/shared/utils.ts +115 -115
  215. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +35 -35
  216. package/frontend/lib/components/chat/tools/BashTool.svelte +45 -45
  217. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +139 -139
  218. package/frontend/lib/components/chat/tools/EditTool.svelte +47 -47
  219. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +31 -31
  220. package/frontend/lib/components/chat/tools/GlobTool.svelte +50 -50
  221. package/frontend/lib/components/chat/tools/GrepTool.svelte +89 -89
  222. package/frontend/lib/components/chat/tools/KillShellTool.svelte +25 -25
  223. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +30 -30
  224. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +37 -37
  225. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +33 -33
  226. package/frontend/lib/components/chat/tools/ReadTool.svelte +40 -40
  227. package/frontend/lib/components/chat/tools/TaskTool.svelte +63 -63
  228. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +74 -74
  229. package/frontend/lib/components/chat/tools/WebFetchTool.svelte +34 -34
  230. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +83 -83
  231. package/frontend/lib/components/chat/tools/WriteTool.svelte +32 -32
  232. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +78 -78
  233. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +407 -407
  234. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +45 -45
  235. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +18 -18
  236. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +26 -26
  237. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +53 -53
  238. package/frontend/lib/components/chat/tools/components/index.ts +7 -7
  239. package/frontend/lib/components/chat/tools/index.ts +25 -25
  240. package/frontend/lib/components/chat/widgets/FloatingTodoList.svelte +248 -248
  241. package/frontend/lib/components/chat/widgets/TokenUsage.svelte +78 -78
  242. package/frontend/lib/components/checkpoint/TimelineModal.svelte +391 -391
  243. package/frontend/lib/components/checkpoint/timeline/TimelineEdge.svelte +26 -26
  244. package/frontend/lib/components/checkpoint/timeline/TimelineGraph.svelte +86 -86
  245. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +108 -108
  246. package/frontend/lib/components/checkpoint/timeline/TimelineVersionGroup.svelte +59 -59
  247. package/frontend/lib/components/checkpoint/timeline/animation.ts +168 -168
  248. package/frontend/lib/components/checkpoint/timeline/config.ts +44 -44
  249. package/frontend/lib/components/checkpoint/timeline/graph-builder.ts +304 -304
  250. package/frontend/lib/components/checkpoint/timeline/types.ts +65 -65
  251. package/frontend/lib/components/checkpoint/timeline/utils.ts +53 -53
  252. package/frontend/lib/components/common/Alert.svelte +138 -138
  253. package/frontend/lib/components/common/AvatarBubble.svelte +55 -55
  254. package/frontend/lib/components/common/Button.svelte +71 -71
  255. package/frontend/lib/components/common/Card.svelte +102 -102
  256. package/frontend/lib/components/common/Checkbox.svelte +48 -48
  257. package/frontend/lib/components/common/Dialog.svelte +248 -248
  258. package/frontend/lib/components/common/FolderBrowser.svelte +842 -842
  259. package/frontend/lib/components/common/Icon.svelte +57 -57
  260. package/frontend/lib/components/common/Input.svelte +72 -72
  261. package/frontend/lib/components/common/Lightbox.svelte +232 -232
  262. package/frontend/lib/components/common/LoadingScreen.svelte +52 -52
  263. package/frontend/lib/components/common/LoadingSpinner.svelte +48 -48
  264. package/frontend/lib/components/common/Modal.svelte +177 -177
  265. package/frontend/lib/components/common/ModalProvider.svelte +27 -27
  266. package/frontend/lib/components/common/ModelSelector.svelte +110 -110
  267. package/frontend/lib/components/common/MonacoEditor.svelte +568 -568
  268. package/frontend/lib/components/common/NotificationToast.svelte +113 -113
  269. package/frontend/lib/components/common/PageTemplate.svelte +75 -75
  270. package/frontend/lib/components/common/ProjectUserAvatars.svelte +79 -79
  271. package/frontend/lib/components/common/Select.svelte +97 -97
  272. package/frontend/lib/components/common/Textarea.svelte +79 -79
  273. package/frontend/lib/components/common/ThemeToggle.svelte +44 -44
  274. package/frontend/lib/components/common/lucide-icons.ts +1642 -1642
  275. package/frontend/lib/components/common/material-icons.ts +1082 -1082
  276. package/frontend/lib/components/common/xterm/XTerm.svelte +809 -795
  277. package/frontend/lib/components/common/xterm/index.ts +15 -15
  278. package/frontend/lib/components/common/xterm/terminal-config.ts +67 -67
  279. package/frontend/lib/components/common/xterm/types.ts +30 -30
  280. package/frontend/lib/components/common/xterm/xterm-service.ts +379 -353
  281. package/frontend/lib/components/files/FileNode.svelte +383 -383
  282. package/frontend/lib/components/files/FileTree.svelte +681 -681
  283. package/frontend/lib/components/files/FileViewer.svelte +728 -728
  284. package/frontend/lib/components/files/SearchResults.svelte +303 -303
  285. package/frontend/lib/components/git/BranchManager.svelte +458 -458
  286. package/frontend/lib/components/git/ChangesSection.svelte +107 -107
  287. package/frontend/lib/components/git/CommitForm.svelte +76 -76
  288. package/frontend/lib/components/git/ConflictResolver.svelte +158 -158
  289. package/frontend/lib/components/git/DiffViewer.svelte +364 -364
  290. package/frontend/lib/components/git/FileChangeItem.svelte +97 -97
  291. package/frontend/lib/components/git/GitButton.svelte +33 -33
  292. package/frontend/lib/components/git/GitLog.svelte +361 -361
  293. package/frontend/lib/components/git/GitModal.svelte +80 -80
  294. package/frontend/lib/components/history/HistoryModal.svelte +563 -563
  295. package/frontend/lib/components/history/HistoryView.svelte +614 -614
  296. package/frontend/lib/components/index.ts +34 -34
  297. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +549 -549
  298. package/frontend/lib/components/preview/browser/components/Canvas.svelte +1058 -1058
  299. package/frontend/lib/components/preview/browser/components/ConsolePanel.svelte +756 -756
  300. package/frontend/lib/components/preview/browser/components/Container.svelte +450 -450
  301. package/frontend/lib/components/preview/browser/components/ContextMenu.svelte +236 -236
  302. package/frontend/lib/components/preview/browser/components/SelectDropdown.svelte +224 -224
  303. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +338 -338
  304. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +35 -35
  305. package/frontend/lib/components/preview/browser/core/cleanup.svelte.ts +155 -155
  306. package/frontend/lib/components/preview/browser/core/coordinator.svelte.ts +837 -837
  307. package/frontend/lib/components/preview/browser/core/interactions.svelte.ts +113 -113
  308. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +296 -296
  309. package/frontend/lib/components/preview/browser/core/native-ui-handlers.svelte.ts +391 -391
  310. package/frontend/lib/components/preview/browser/core/stream-handler.svelte.ts +231 -231
  311. package/frontend/lib/components/preview/browser/core/tab-manager.svelte.ts +210 -210
  312. package/frontend/lib/components/preview/browser/core/tab-operations.svelte.ts +239 -239
  313. package/frontend/lib/components/preview/index.ts +1 -1
  314. package/frontend/lib/components/settings/SettingsModal.svelte +235 -235
  315. package/frontend/lib/components/settings/SettingsView.svelte +36 -36
  316. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +51 -51
  317. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +160 -160
  318. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +76 -76
  319. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +917 -917
  320. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +187 -187
  321. package/frontend/lib/components/settings/general/DataManagementSettings.svelte +203 -203
  322. package/frontend/lib/components/settings/general/GeneralSettings.svelte +10 -10
  323. package/frontend/lib/components/settings/model/ModelSettings.svelte +357 -357
  324. package/frontend/lib/components/settings/notifications/NotificationSettings.svelte +205 -205
  325. package/frontend/lib/components/settings/user/UserSettings.svelte +197 -197
  326. package/frontend/lib/components/terminal/Terminal.svelte +367 -367
  327. package/frontend/lib/components/terminal/TerminalTabs.svelte +87 -87
  328. package/frontend/lib/components/terminal/TerminalView.svelte +54 -54
  329. package/frontend/lib/components/tunnel/TunnelActive.svelte +157 -142
  330. package/frontend/lib/components/tunnel/TunnelButton.svelte +60 -54
  331. package/frontend/lib/components/tunnel/TunnelInactive.svelte +285 -284
  332. package/frontend/lib/components/tunnel/TunnelModal.svelte +48 -47
  333. package/frontend/lib/components/tunnel/TunnelQRCode.svelte +49 -49
  334. package/frontend/lib/components/workspace/DesktopNavigator.svelte +382 -382
  335. package/frontend/lib/components/workspace/MobileNavigator.svelte +394 -403
  336. package/frontend/lib/components/workspace/PanelContainer.svelte +100 -100
  337. package/frontend/lib/components/workspace/PanelHeader.svelte +505 -505
  338. package/frontend/lib/components/workspace/ViewMenu.svelte +162 -162
  339. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +169 -169
  340. package/frontend/lib/components/workspace/layout/DesktopLayout.svelte +15 -15
  341. package/frontend/lib/components/workspace/layout/MobileLayout.svelte +17 -17
  342. package/frontend/lib/components/workspace/layout/split-pane/Container.svelte +42 -42
  343. package/frontend/lib/components/workspace/layout/split-pane/Handle.svelte +84 -84
  344. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +37 -37
  345. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +274 -274
  346. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +1261 -1261
  347. package/frontend/lib/components/workspace/panels/GitPanel.svelte +1560 -1560
  348. package/frontend/lib/components/workspace/panels/PreviewPanel.svelte +150 -150
  349. package/frontend/lib/components/workspace/panels/TerminalPanel.svelte +73 -73
  350. package/frontend/lib/constants/preview.ts +44 -44
  351. package/frontend/lib/services/chat/chat.service.ts +704 -704
  352. package/frontend/lib/services/chat/index.ts +6 -6
  353. package/frontend/lib/services/notification/global-stream-monitor.ts +86 -86
  354. package/frontend/lib/services/notification/index.ts +7 -7
  355. package/frontend/lib/services/notification/push.service.ts +143 -143
  356. package/frontend/lib/services/notification/sound.service.ts +126 -126
  357. package/frontend/lib/services/preview/browser/browser-console.service.ts +61 -61
  358. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +1499 -1499
  359. package/frontend/lib/services/preview/browser/mcp-integration.svelte.ts +67 -67
  360. package/frontend/lib/services/preview/index.ts +22 -22
  361. package/frontend/lib/services/project/index.ts +7 -7
  362. package/frontend/lib/services/project/status.service.ts +159 -159
  363. package/frontend/lib/services/snapshot/snapshot.service.ts +47 -47
  364. package/frontend/lib/services/terminal/background/index.ts +129 -129
  365. package/frontend/lib/services/terminal/background/session-restore.ts +273 -273
  366. package/frontend/lib/services/terminal/background/stream-manager.ts +285 -285
  367. package/frontend/lib/services/terminal/index.ts +13 -13
  368. package/frontend/lib/services/terminal/persistence.service.ts +260 -260
  369. package/frontend/lib/services/terminal/project.service.ts +952 -952
  370. package/frontend/lib/services/terminal/session.service.ts +363 -363
  371. package/frontend/lib/services/terminal/terminal.service.ts +369 -369
  372. package/frontend/lib/stores/core/app.svelte.ts +117 -117
  373. package/frontend/lib/stores/core/files.svelte.ts +72 -72
  374. package/frontend/lib/stores/core/presence.svelte.ts +48 -48
  375. package/frontend/lib/stores/core/projects.svelte.ts +317 -317
  376. package/frontend/lib/stores/core/sessions.svelte.ts +383 -383
  377. package/frontend/lib/stores/features/claude-accounts.svelte.ts +58 -58
  378. package/frontend/lib/stores/features/models.svelte.ts +89 -89
  379. package/frontend/lib/stores/features/settings.svelte.ts +87 -87
  380. package/frontend/lib/stores/features/terminal.svelte.ts +700 -700
  381. package/frontend/lib/stores/features/tunnel.svelte.ts +163 -161
  382. package/frontend/lib/stores/features/user.svelte.ts +95 -95
  383. package/frontend/lib/stores/ui/chat-input.svelte.ts +56 -56
  384. package/frontend/lib/stores/ui/chat-model.svelte.ts +61 -61
  385. package/frontend/lib/stores/ui/dialog.svelte.ts +58 -58
  386. package/frontend/lib/stores/ui/edit-mode.svelte.ts +214 -214
  387. package/frontend/lib/stores/ui/notification.svelte.ts +166 -166
  388. package/frontend/lib/stores/ui/settings-modal.svelte.ts +88 -88
  389. package/frontend/lib/stores/ui/theme.svelte.ts +179 -179
  390. package/frontend/lib/stores/ui/workspace.svelte.ts +754 -754
  391. package/frontend/lib/types/native-ui.ts +73 -73
  392. package/frontend/lib/utils/chat/date-separator.ts +38 -38
  393. package/frontend/lib/utils/chat/message-grouper.ts +218 -218
  394. package/frontend/lib/utils/chat/message-processor.ts +134 -134
  395. package/frontend/lib/utils/chat/tool-handler.ts +160 -160
  396. package/frontend/lib/utils/chat/virtual-scroll.svelte.ts +142 -142
  397. package/frontend/lib/utils/click-outside.ts +20 -20
  398. package/frontend/lib/utils/context-manager.ts +256 -256
  399. package/frontend/lib/utils/file-icon-mappings.ts +768 -768
  400. package/frontend/lib/utils/folder-icon-mappings.ts +1029 -1029
  401. package/frontend/lib/utils/git-status.ts +68 -68
  402. package/frontend/lib/utils/platform.ts +112 -112
  403. package/frontend/lib/utils/port-check.ts +64 -64
  404. package/frontend/lib/utils/terminalFormatter.ts +206 -206
  405. package/frontend/lib/utils/theme.ts +6 -6
  406. package/frontend/lib/utils/tree-visualizer.ts +320 -320
  407. package/frontend/lib/utils/ws.ts +44 -44
  408. package/frontend/main.ts +13 -13
  409. package/index.html +70 -70
  410. package/package.json +111 -111
  411. package/scripts/generate-icons.ts +86 -86
  412. package/scripts/pre-publish-check.sh +142 -142
  413. package/scripts/setup-hooks.sh +134 -134
  414. package/scripts/validate-branch-name.sh +47 -47
  415. package/scripts/validate-commit-msg.sh +42 -42
  416. package/shared/constants/engines.ts +134 -134
  417. package/shared/types/database/connection.ts +15 -15
  418. package/shared/types/database/index.ts +5 -5
  419. package/shared/types/database/schema.ts +140 -140
  420. package/shared/types/engine/index.ts +45 -45
  421. package/shared/types/filesystem/index.ts +21 -21
  422. package/shared/types/git.ts +171 -171
  423. package/shared/types/messaging/index.ts +238 -238
  424. package/shared/types/messaging/tool.ts +525 -525
  425. package/shared/types/network/api.ts +17 -17
  426. package/shared/types/network/index.ts +4 -4
  427. package/shared/types/stores/app.ts +22 -22
  428. package/shared/types/stores/dialog.ts +20 -20
  429. package/shared/types/stores/index.ts +2 -2
  430. package/shared/types/stores/settings.ts +15 -15
  431. package/shared/types/terminal/index.ts +43 -43
  432. package/shared/types/ui/components.ts +60 -60
  433. package/shared/types/ui/icons.ts +22 -22
  434. package/shared/types/ui/index.ts +21 -21
  435. package/shared/types/ui/notifications.ts +13 -13
  436. package/shared/types/ui/theme.ts +11 -11
  437. package/shared/types/websocket/index.ts +43 -43
  438. package/shared/types/window.d.ts +12 -12
  439. package/shared/utils/anonymous-user.ts +167 -167
  440. package/shared/utils/async.ts +10 -10
  441. package/shared/utils/diff-calculator.ts +184 -184
  442. package/shared/utils/file-type-detection.ts +165 -165
  443. package/shared/utils/logger.ts +144 -158
  444. package/shared/utils/message-formatter.ts +79 -79
  445. package/shared/utils/path.ts +47 -47
  446. package/shared/utils/ws-client.ts +768 -768
  447. package/shared/utils/ws-server.ts +660 -660
  448. package/static/favicon.svg +7 -7
  449. package/static/fonts/dm-sans.css +96 -96
  450. package/svelte.config.js +20 -20
  451. package/tsconfig.json +41 -41
  452. package/vite.config.ts +33 -33
  453. package/.github/workflows/test.yml +0 -40
@@ -1,755 +1,755 @@
1
- /**
2
- * Open Code Engine Adapter
3
- *
4
- * Wraps the @opencode-ai/sdk into the AIEngine interface.
5
- * Converts Open Code messages/events → SDKMessage (Claude format)
6
- * so stream-manager and frontend remain unchanged.
7
- *
8
- * Server lifecycle is managed by ./server.ts (bun-pty spawn).
9
- * This file only contains the OpenCodeEngine class (per-project instance).
10
- */
11
-
12
- import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
13
- import type { AIEngine, EngineQueryOptions } from '../../types';
14
- import type { EngineModel } from '$shared/types/engine';
15
- import type {
16
- Provider,
17
- Model,
18
- EventMessageUpdated,
19
- EventMessagePartUpdated,
20
- EventSessionIdle,
21
- EventSessionStatus,
22
- EventSessionError,
23
- Part,
24
- ToolPart,
25
- Message as OCMessage,
26
- } from '@opencode-ai/sdk';
27
- import {
28
- convertAssistantMessages,
29
- convertResultMessage,
30
- convertSystemInitMessage,
31
- convertStreamStart,
32
- convertPartialTextDelta,
33
- convertStreamStop,
34
- convertToolUseOnly,
35
- convertToolResultOnly,
36
- convertReasoningMessage,
37
- convertPartialReasoningDelta,
38
- convertReasoningStreamStart,
39
- convertReasoningStreamStop,
40
- getToolInput,
41
- } from './message-converter';
42
- import { ensureClient, getClient } from './server';
43
- import { debug } from '$shared/utils/logger';
44
-
45
- /** Map SDK Model.status to our category */
46
- function mapStatusToCategory(status: Model['status']): EngineModel['category'] {
47
- switch (status) {
48
- case 'deprecated': return 'legacy';
49
- case 'alpha':
50
- case 'beta': return 'stable';
51
- default: return 'latest';
52
- }
53
- }
54
-
55
- /** Build capability tags from SDK Model.capabilities */
56
- function buildCapabilityTags(model: Model): string[] {
57
- const tags: string[] = [];
58
- const caps = model.capabilities;
59
-
60
- if (caps.reasoning) tags.push('Reasoning');
61
- if (caps.attachment) tags.push('Attachments');
62
-
63
- return tags;
64
- }
65
-
66
- // ============================================================================
67
- // OpenCode Engine (per-project instance)
68
- // ============================================================================
69
-
70
- export class OpenCodeEngine implements AIEngine {
71
- readonly name = 'opencode' as const;
72
- private _isInitialized = false;
73
- private _isActive = false;
74
- private activeAbortController: AbortController | null = null;
75
- private activeSessionId: string | null = null;
76
- private activeProjectPath: string | null = null;
77
-
78
- get isInitialized(): boolean {
79
- return this._isInitialized;
80
- }
81
-
82
- get isActive(): boolean {
83
- return this._isActive;
84
- }
85
-
86
- /**
87
- * Initialize this per-project engine instance.
88
- * Delegates to the shared client singleton (concurrency-safe).
89
- */
90
- async initialize(): Promise<void> {
91
- if (this._isInitialized) return;
92
-
93
- await ensureClient();
94
- this._isInitialized = true;
95
- debug.log('engine', 'Open Code engine instance initialized (shared client)');
96
- }
97
-
98
- /**
99
- * Cleanup this per-project instance.
100
- * Does NOT dispose the shared client — that's handled by disposeOpenCodeClient().
101
- */
102
- async dispose(): Promise<void> {
103
- await this.cancel();
104
- this._isInitialized = false;
105
- debug.log('engine', 'Open Code engine instance disposed');
106
- }
107
-
108
- async getAvailableModels(): Promise<EngineModel[]> {
109
- const client = await ensureClient();
110
-
111
- try {
112
- // config.providers() returns { data: { providers: Provider[] } }
113
- const response = await client.config.providers();
114
- const providers: Provider[] = response.data?.providers ?? [];
115
- const models: EngineModel[] = [];
116
-
117
- for (const provider of providers) {
118
- // Provider.models is Record<string, Model>
119
- const providerModels: Record<string, Model> = provider.models ?? {};
120
-
121
- for (const [modelKey, model] of Object.entries(providerModels)) {
122
- const modelId = model.id || modelKey;
123
- const compoundId = `opencode:${provider.id}/${modelId}`;
124
-
125
- models.push({
126
- id: compoundId,
127
- engine: 'opencode',
128
- modelId: `${provider.id}/${modelId}`,
129
- name: model.name || modelId,
130
- provider: provider.id,
131
- description: `${model.name || modelId} via ${provider.name || provider.id}`,
132
- capabilities: buildCapabilityTags(model),
133
- contextWindow: model.limit.context,
134
- category: mapStatusToCategory(model.status),
135
- });
136
- }
137
- }
138
-
139
- debug.log('engine', `Fetched ${models.length} models from ${providers.length} Open Code providers`);
140
- return models;
141
- } catch (error) {
142
- debug.error('engine', 'Failed to fetch Open Code providers:', error);
143
- }
144
-
145
- return [];
146
- }
147
-
148
- /**
149
- * Stream a query through Open Code SDK, yielding SDKMessage (Claude format)
150
- *
151
- * Flow: subscribe to events FIRST, then send prompt asynchronously.
152
- * This ensures no events are missed between sending and subscribing.
153
- */
154
- async *streamQuery(options: EngineQueryOptions): AsyncGenerator<EngineSDKMessage, void, unknown> {
155
- const client = await ensureClient();
156
-
157
- const {
158
- projectPath,
159
- prompt,
160
- resume,
161
- model = 'claude-sonnet',
162
- abortController
163
- } = options;
164
-
165
- this.activeAbortController = abortController || new AbortController();
166
- this._isActive = true;
167
- this.activeProjectPath = projectPath;
168
-
169
- debug.log('chat', 'Open Code - Stream Query');
170
- debug.log('chat', { prompt });
171
-
172
- try {
173
- const promptParts = this.extractPromptParts(prompt);
174
-
175
- // Create or fork a session
176
- // When resuming, fork the session to create a new branch
177
- // (like Claude Code's forkSession: true) so each checkpoint
178
- // gets its own conversation branch
179
- let sessionId: string;
180
-
181
- if (resume) {
182
- try {
183
- const forkResult = await client.session.fork({
184
- path: { id: resume },
185
- query: { directory: projectPath }
186
- });
187
- sessionId = forkResult.data?.id || resume;
188
- debug.log('engine', `Forked Open Code session: ${resume} → ${sessionId}`);
189
- } catch (forkError) {
190
- // Fallback to resuming the same session if fork fails
191
- debug.warn('engine', 'Failed to fork Open Code session, falling back to resume:', forkError);
192
- sessionId = resume;
193
- }
194
- } else {
195
- const sessionResult = await client.session.create({
196
- query: { directory: projectPath }
197
- });
198
- sessionId = sessionResult.data?.id || crypto.randomUUID();
199
- }
200
-
201
- this.activeSessionId = sessionId;
202
-
203
- yield convertSystemInitMessage(sessionId, model);
204
- yield convertStreamStart(sessionId);
205
-
206
- // 1. Subscribe to event stream FIRST (before sending prompt)
207
- const eventResult = await client.event.subscribe({
208
- query: { directory: projectPath },
209
- signal: this.activeAbortController.signal
210
- });
211
-
212
- // 2. Send prompt asynchronously (non-blocking) — parse "providerId/modelId"
213
- const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
214
-
215
- client.session.promptAsync({
216
- path: { id: sessionId },
217
- body: {
218
- parts: promptParts as any,
219
- ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
220
- },
221
- query: { directory: projectPath },
222
- }).catch(error => {
223
- debug.error('engine', 'Open Code promptAsync error:', error);
224
- });
225
-
226
- // 3. Process event stream — emit messages progressively (like Claude Code)
227
- // Each assistant message becomes its own bubble in the UI
228
- const messageParts = new Map<string, Part[]>();
229
- const assistantMessages = new Map<string, OCMessage>(); // All tracked assistant messages
230
- const emittedMessageIds = new Set<string>(); // Already yielded message IDs
231
- let currentAssistantId: string | null = null; // Currently active assistant message
232
- let streamingText = '';
233
- const emittedToolParts = new Set<string>(); // Tool parts already emitted as tool_use
234
- const completedToolParts = new Set<string>(); // Tool parts whose tool_result was emitted
235
- const emittedReasoningParts = new Set<string>(); // Reasoning parts already flushed
236
- let reasoningStreamActive = false; // Whether reasoning is currently streaming
237
- let reasoningText = ''; // Accumulated reasoning text
238
-
239
- /**
240
- * Flush active reasoning stream: stop reasoning stream, emit final reasoning message.
241
- */
242
- const flushReasoning = function* (msg: OCMessage) {
243
- if (!reasoningStreamActive) return;
244
- yield convertReasoningStreamStop(sessionId);
245
- reasoningStreamActive = false;
246
- if (reasoningText) {
247
- yield convertReasoningMessage(reasoningText, msg, sessionId);
248
- }
249
- reasoningText = '';
250
- };
251
-
252
- /**
253
- * Finalize and yield an assistant message by ID
254
- * Emits stop stream event, the assembled message, and restarts stream for next message
255
- */
256
- const finalizeMessage = function* (msgId: string) {
257
- const msg = assistantMessages.get(msgId);
258
- if (!msg || emittedMessageIds.has(msgId)) return;
259
-
260
- // Flush any active reasoning before finalizing
261
- yield* flushReasoning(msg);
262
-
263
- emittedMessageIds.add(msgId);
264
-
265
- // Stop current stream
266
- yield convertStreamStop(sessionId);
267
-
268
- const parts = messageParts.get(msgId) || [];
269
- // Filter out tool parts and reasoning parts already emitted
270
- const remainingParts = parts.filter(p => {
271
- if (p.type === 'tool') return !emittedToolParts.has(p.id);
272
- if (p.type === 'reasoning') return false; // Already emitted as reasoning message
273
- return true;
274
- });
275
-
276
- if (remainingParts.length > 0) {
277
- const splitMessages = convertAssistantMessages(msg, remainingParts, sessionId);
278
- for (const m of splitMessages) {
279
- yield m;
280
- }
281
- }
282
-
283
- // Restart stream for the next message
284
- yield convertStreamStart(sessionId);
285
-
286
- // Reset streaming text for new message
287
- streamingText = '';
288
- };
289
-
290
- // Track whether message.part.delta events are being received.
291
- // When active, skip delta processing in message.part.updated to prevent duplication.
292
- let receivedPartDelta = false;
293
-
294
- if (eventResult?.stream) {
295
- for await (const event of eventResult.stream) {
296
- if (this.activeAbortController?.signal.aborted) break;
297
-
298
- const evt = event as { type: string; properties: Record<string, unknown> };
299
- debug.log('engine', `[OC] event: ${evt.type}`);
300
-
301
- switch (evt.type) {
302
- case 'message.updated': {
303
- const { info } = (event as EventMessageUpdated).properties;
304
- // Only track assistant messages for our session
305
- if (info.role === 'assistant' && info.sessionID === sessionId) {
306
- assistantMessages.set(info.id, info);
307
-
308
- // If a NEW assistant message arrives and we had a previous one,
309
- // finalize the previous message (emit it as a separate bubble)
310
- if (currentAssistantId && currentAssistantId !== info.id) {
311
- yield* finalizeMessage(currentAssistantId);
312
- }
313
- currentAssistantId = info.id;
314
- }
315
- break;
316
- }
317
-
318
- case 'message.part.updated': {
319
- const props = (event as EventMessagePartUpdated).properties;
320
- const part = props.part;
321
-
322
- debug.log('engine', `[OC] part.updated: type=${part.type}, partId=${part.id}, msgId=${part.messageID}, session=${part.sessionID === sessionId ? 'match' : 'skip'}`);
323
-
324
- // Only process parts belonging to our session
325
- if (part.sessionID !== sessionId) break;
326
-
327
- // Only process parts for tracked assistant messages (skip user message parts)
328
- if (!assistantMessages.has(part.messageID)) {
329
- debug.log('engine', `[OC] part.updated: skipped — messageID not in assistantMessages`);
330
- break;
331
- }
332
-
333
- // Accumulate parts per message
334
- const msgId = part.messageID;
335
- if (!messageParts.has(msgId)) {
336
- messageParts.set(msgId, []);
337
- }
338
-
339
- const parts = messageParts.get(msgId)!;
340
- const existingIdx = parts.findIndex(p => p.id === part.id);
341
- if (existingIdx >= 0) {
342
- parts[existingIdx] = part;
343
- } else {
344
- parts.push(part);
345
- }
346
-
347
- // Progressive tool rendering: emit tool_use immediately, tool_result when done
348
- if (part.type === 'tool') {
349
- const toolPart = part as ToolPart;
350
- const msg = assistantMessages.get(msgId);
351
-
352
- // Flush reasoning before tool rendering to preserve order
353
- if (msg && reasoningStreamActive) {
354
- yield* flushReasoning(msg);
355
- }
356
-
357
- if (msg && !emittedToolParts.has(part.id)) {
358
- // Only emit tool_use when input is available
359
- // Pending tools may have empty input ({}) — wait for next update
360
- const resolvedInput = getToolInput(toolPart);
361
- const hasInput = Object.keys(resolvedInput).length > 0
362
- || toolPart.state.status !== 'pending';
363
-
364
- if (hasInput) {
365
- emittedToolParts.add(part.id);
366
-
367
- yield convertStreamStop(sessionId);
368
- yield convertToolUseOnly(toolPart, msg, sessionId);
369
-
370
- // If already completed on first sight, emit result immediately too
371
- if (toolPart.state.status === 'completed' || toolPart.state.status === 'error') {
372
- completedToolParts.add(part.id);
373
- yield convertToolResultOnly(toolPart, sessionId);
374
- }
375
-
376
- yield convertStreamStart(sessionId);
377
- streamingText = '';
378
- }
379
- } else if (
380
- (toolPart.state.status === 'completed' || toolPart.state.status === 'error')
381
- && !completedToolParts.has(part.id)
382
- ) {
383
- // Tool completed later — emit tool_result
384
- completedToolParts.add(part.id);
385
-
386
- yield convertStreamStop(sessionId);
387
- yield convertToolResultOnly(toolPart, sessionId);
388
- yield convertStreamStart(sessionId);
389
- streamingText = '';
390
- }
391
-
392
- break;
393
- }
394
-
395
- // Handle reasoning parts — start reasoning stream (only once per part)
396
- if (part.type === 'reasoning') {
397
- if (!reasoningStreamActive && !emittedReasoningParts.has(part.id)) {
398
- reasoningStreamActive = true;
399
- reasoningText = '';
400
- emittedReasoningParts.add(part.id);
401
- yield convertReasoningStreamStart(sessionId);
402
- debug.log('engine', `[OC] reasoning stream started for part=${part.id}`);
403
- }
404
- break;
405
- }
406
-
407
- // When a text part appears and reasoning was active, flush reasoning first
408
- if (part.type === 'text' && reasoningStreamActive) {
409
- const msg = assistantMessages.get(msgId);
410
- if (msg) {
411
- yield* flushReasoning(msg);
412
- }
413
- }
414
-
415
- // Skip non-text/non-reasoning parts (step-start, step-finish, etc.)
416
- if (part.type !== 'text') {
417
- debug.log('engine', `[OC] part.updated: skipped non-text type=${part.type}`);
418
- break;
419
- }
420
-
421
- // Only stream text for the current active message
422
- if (msgId !== currentAssistantId) {
423
- debug.log('engine', `[OC] part.updated: text skipped — msgId=${msgId} !== currentAssistantId=${currentAssistantId}`);
424
- break;
425
- }
426
-
427
- // Stream text deltas — skip if message.part.delta events handle it
428
- // to prevent double-counting the same delta
429
- if (receivedPartDelta) {
430
- // message.part.delta handles text streaming — just update accumulated text
431
- if ((part as any).text) {
432
- // Sync streamingText with the authoritative accumulated text from the part
433
- // This handles any drift without emitting duplicate deltas
434
- streamingText = (part as any).text;
435
- }
436
- break;
437
- }
438
-
439
- const hasDelta = !!(props as any).delta;
440
- const hasText = !!(part as any).text;
441
- debug.log('engine', `[OC] text streaming: hasDelta=${hasDelta}, hasText=${hasText}, textLen=${(part as any).text?.length || 0}, streamingTextLen=${streamingText.length}`);
442
-
443
- if ((props as any).delta) {
444
- streamingText += (props as any).delta;
445
- yield convertPartialTextDelta((props as any).delta, sessionId);
446
- } else if ((part as any).text) {
447
- const newText = (part as any).text;
448
- if (newText.length > streamingText.length) {
449
- const diff = newText.slice(streamingText.length);
450
- streamingText = newText;
451
- yield convertPartialTextDelta(diff, sessionId);
452
- }
453
- }
454
- break;
455
- }
456
-
457
- case 'session.idle': {
458
- // Session finished — flush reasoning and emit the last assistant message
459
- if (currentAssistantId && !emittedMessageIds.has(currentAssistantId)) {
460
- const msg = assistantMessages.get(currentAssistantId);
461
- if (msg) {
462
- // Flush any active reasoning
463
- yield* flushReasoning(msg);
464
- }
465
- yield convertStreamStop(sessionId);
466
- if (msg) {
467
- const parts = messageParts.get(currentAssistantId) || [];
468
- // Filter out tool parts and reasoning parts already emitted
469
- const remainingParts = parts.filter(p => {
470
- if (p.type === 'tool') return !emittedToolParts.has(p.id);
471
- if (p.type === 'reasoning') return false;
472
- return true;
473
- });
474
- if (remainingParts.length > 0) {
475
- const splitMsgs1 = convertAssistantMessages(msg, remainingParts, sessionId);
476
- for (const m of splitMsgs1) {
477
- yield m;
478
- }
479
- }
480
- }
481
- emittedMessageIds.add(currentAssistantId);
482
- } else {
483
- yield convertStreamStop(sessionId);
484
- }
485
-
486
- // Emit result message with token usage from the last assistant message
487
- if (currentAssistantId) {
488
- const lastMsg = assistantMessages.get(currentAssistantId);
489
- if (lastMsg && lastMsg.role === 'assistant') {
490
- yield convertResultMessage(lastMsg, sessionId);
491
- }
492
- }
493
-
494
- return; // Done
495
- }
496
-
497
- case 'session.status': {
498
- const { status } = (event as EventSessionStatus).properties;
499
- if (status.type === 'idle') {
500
- // Same as session.idle
501
- if (currentAssistantId && !emittedMessageIds.has(currentAssistantId)) {
502
- const msg = assistantMessages.get(currentAssistantId);
503
- if (msg) {
504
- yield* flushReasoning(msg);
505
- }
506
- yield convertStreamStop(sessionId);
507
- if (msg) {
508
- const parts = messageParts.get(currentAssistantId) || [];
509
- const remainingParts = parts.filter(p => {
510
- if (p.type === 'tool') return !emittedToolParts.has(p.id);
511
- if (p.type === 'reasoning') return false;
512
- return true;
513
- });
514
- if (remainingParts.length > 0) {
515
- const splitMsgs2 = convertAssistantMessages(msg, remainingParts, sessionId);
516
- for (const m of splitMsgs2) {
517
- yield m;
518
- }
519
- }
520
- }
521
- } else {
522
- yield convertStreamStop(sessionId);
523
- }
524
- return;
525
- }
526
- break;
527
- }
528
-
529
- // Handle message.part.delta — newer OpenCode servers send text deltas
530
- // through this event instead of (or alongside) message.part.updated
531
- // When both fire for the same text, only this handler processes deltas
532
- case 'message.part.delta': {
533
- receivedPartDelta = true;
534
- const deltaProps = evt.properties as {
535
- sessionID?: string;
536
- messageID?: string;
537
- partID?: string;
538
- field?: string;
539
- delta?: string;
540
- };
541
-
542
- // Only process deltas for our session
543
- if (deltaProps.sessionID !== sessionId) break;
544
- // Only process deltas for tracked assistant messages
545
- if (!deltaProps.messageID || !assistantMessages.has(deltaProps.messageID)) break;
546
- // Only stream for the current active message
547
- if (deltaProps.messageID !== currentAssistantId) break;
548
- // Only stream text field deltas
549
- if (deltaProps.field !== 'text') break;
550
-
551
- // Handle reasoning part deltas — stream as reasoning instead of text
552
- if (deltaProps.partID && deltaProps.messageID && messageParts.has(deltaProps.messageID)) {
553
- const knownParts = messageParts.get(deltaProps.messageID)!;
554
- const knownPart = knownParts.find(p => p.id === deltaProps.partID);
555
- if (knownPart && knownPart.type === 'reasoning') {
556
- if (deltaProps.delta) {
557
- reasoningText += deltaProps.delta;
558
- yield convertPartialReasoningDelta(deltaProps.delta, sessionId);
559
- }
560
- break;
561
- }
562
- // Skip other non-text parts (step-start, etc.)
563
- if (knownPart && knownPart.type !== 'text') {
564
- break;
565
- }
566
- }
567
-
568
- if (deltaProps.delta) {
569
- debug.log('engine', `[OC] part.delta: field=${deltaProps.field}, deltaLen=${deltaProps.delta.length}`);
570
- streamingText += deltaProps.delta;
571
-
572
- // Also update the accumulated text in the tracked part
573
- if (deltaProps.partID && messageParts.has(deltaProps.messageID)) {
574
- const existingParts = messageParts.get(deltaProps.messageID)!;
575
- const textPart = existingParts.find(p => p.id === deltaProps.partID && p.type === 'text');
576
- if (textPart && 'text' in textPart) {
577
- (textPart as any).text = (textPart as any).text + deltaProps.delta;
578
- }
579
- }
580
-
581
- yield convertPartialTextDelta(deltaProps.delta, sessionId);
582
- }
583
- break;
584
- }
585
-
586
- case 'session.error': {
587
- const { error } = (event as EventSessionError).properties;
588
- // Extract a human-readable error message.
589
- // OpenCode SDK errors follow: { name: string, data: { message, statusCode?, providerID?, responseBody? } }
590
- // Don't prepend error class names (e.g. "APIError") — those are SDK
591
- // implementation details, not useful for the end user.
592
- let errorMsg = 'Unknown Open Code error';
593
- if (error) {
594
- const errObj = error as Record<string, any>;
595
- const name = errObj.name || '';
596
- const dataMsg = errObj.data?.message || '';
597
- const statusCode = errObj.data?.statusCode;
598
- const providerID = errObj.data?.providerID;
599
- const responseBody = errObj.data?.responseBody;
600
-
601
- if (dataMsg) {
602
- errorMsg = dataMsg;
603
- } else if (responseBody) {
604
- // No data.message — try to parse responseBody for the actual error
605
- try {
606
- const body = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
607
- errorMsg = body?.error?.message || body?.message || String(responseBody);
608
- } catch {
609
- errorMsg = String(responseBody);
610
- }
611
- } else if (name) {
612
- errorMsg = name;
613
- } else if (typeof error === 'string') {
614
- errorMsg = error;
615
- } else {
616
- errorMsg = JSON.stringify(error);
617
- }
618
-
619
- // Append status code
620
- if (statusCode) {
621
- errorMsg += ` (status ${statusCode})`;
622
- }
623
-
624
- // Append provider ID to help identify which provider failed
625
- if (providerID) {
626
- errorMsg += ` [provider: ${providerID}]`;
627
- }
628
- }
629
- debug.error('engine', '[OC] session.error:', errorMsg);
630
- throw new Error(errorMsg);
631
- }
632
- }
633
- }
634
- }
635
-
636
- } catch (error) {
637
- if (error instanceof Error) {
638
- if (error.name === 'AbortError' || error.message.includes('aborted')) {
639
- return;
640
- }
641
- }
642
- throw error;
643
- } finally {
644
- this._isActive = false;
645
- this.activeAbortController = null;
646
- this.activeSessionId = null;
647
- this.activeProjectPath = null;
648
- }
649
- }
650
-
651
- async cancel(): Promise<void> {
652
- // Abort the OpenCode session on the server so it stops processing
653
- const client = getClient();
654
- if (client && this.activeSessionId) {
655
- try {
656
- await client.session.abort({
657
- path: { id: this.activeSessionId },
658
- ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
659
- });
660
- debug.log('engine', 'Open Code session aborted:', this.activeSessionId);
661
- } catch (error) {
662
- debug.warn('engine', 'Failed to abort Open Code session:', error);
663
- }
664
- }
665
-
666
- if (this.activeAbortController) {
667
- this.activeAbortController.abort();
668
- this.activeAbortController = null;
669
- }
670
- this._isActive = false;
671
- this.activeSessionId = null;
672
- this.activeProjectPath = null;
673
- }
674
-
675
- /**
676
- * Cancel a specific session on the OpenCode server.
677
- * Used by stream-manager for per-project isolation (instead of global cancel).
678
- */
679
- async cancelSession(sessionId: string, projectPath?: string): Promise<void> {
680
- const client = getClient();
681
- if (!client || !sessionId) return;
682
- try {
683
- await client.session.abort({
684
- path: { id: sessionId },
685
- ...(projectPath && { query: { directory: projectPath } }),
686
- });
687
- debug.log('engine', 'Open Code session aborted (per-stream):', sessionId);
688
- } catch (error) {
689
- debug.warn('engine', 'Failed to abort Open Code session:', error);
690
- }
691
- }
692
-
693
- async interrupt(): Promise<void> {
694
- // Open Code SDK doesn't have a separate interrupt — use cancel
695
- await this.cancel();
696
- }
697
-
698
- /**
699
- * Extract prompt parts (text + file attachments) from SDKUserMessage.
700
- * Converts Claude-format image/document blocks to OpenCode FilePartInput format.
701
- */
702
- private extractPromptParts(prompt: SDKUserMessage): Array<
703
- | { type: 'text'; text: string }
704
- | { type: 'file'; mime: string; filename?: string; url: string }
705
- > {
706
- const msg = prompt as Record<string, unknown>;
707
- const message = msg.message as Record<string, unknown> | undefined;
708
- if (!message) return [{ type: 'text', text: '' }];
709
-
710
- if (typeof message.content === 'string') {
711
- return [{ type: 'text', text: message.content }];
712
- }
713
-
714
- if (Array.isArray(message.content)) {
715
- const parts: Array<
716
- | { type: 'text'; text: string }
717
- | { type: 'file'; mime: string; filename?: string; url: string }
718
- > = [];
719
-
720
- for (const block of message.content as Array<Record<string, unknown>>) {
721
- if (block.type === 'text') {
722
- parts.push({ type: 'text', text: block.text as string });
723
- } else if (block.type === 'image' && block.source) {
724
- // Claude format: { type: 'image', source: { type: 'base64', media_type, data } }
725
- const source = block.source as Record<string, unknown>;
726
- if (source.type === 'base64' && source.data && source.media_type) {
727
- parts.push({
728
- type: 'file',
729
- mime: source.media_type as string,
730
- url: `data:${source.media_type};base64,${source.data}`,
731
- });
732
- }
733
- } else if (block.type === 'document' && block.source) {
734
- // Claude format: { type: 'document', source: { type: 'base64', media_type, data }, title }
735
- const source = block.source as Record<string, unknown>;
736
- if (source.type === 'base64' && source.data && source.media_type) {
737
- parts.push({
738
- type: 'file',
739
- mime: source.media_type as string,
740
- filename: (block.title as string) || undefined,
741
- url: `data:${source.media_type};base64,${source.data}`,
742
- });
743
- }
744
- }
745
- }
746
-
747
- if (parts.length === 0) {
748
- parts.push({ type: 'text', text: '' });
749
- }
750
- return parts;
751
- }
752
-
753
- return [{ type: 'text', text: '' }];
754
- }
755
- }
1
+ /**
2
+ * Open Code Engine Adapter
3
+ *
4
+ * Wraps the @opencode-ai/sdk into the AIEngine interface.
5
+ * Converts Open Code messages/events → SDKMessage (Claude format)
6
+ * so stream-manager and frontend remain unchanged.
7
+ *
8
+ * Server lifecycle is managed by ./server.ts (bun-pty spawn).
9
+ * This file only contains the OpenCodeEngine class (per-project instance).
10
+ */
11
+
12
+ import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
13
+ import type { AIEngine, EngineQueryOptions } from '../../types';
14
+ import type { EngineModel } from '$shared/types/engine';
15
+ import type {
16
+ Provider,
17
+ Model,
18
+ EventMessageUpdated,
19
+ EventMessagePartUpdated,
20
+ EventSessionIdle,
21
+ EventSessionStatus,
22
+ EventSessionError,
23
+ Part,
24
+ ToolPart,
25
+ Message as OCMessage,
26
+ } from '@opencode-ai/sdk';
27
+ import {
28
+ convertAssistantMessages,
29
+ convertResultMessage,
30
+ convertSystemInitMessage,
31
+ convertStreamStart,
32
+ convertPartialTextDelta,
33
+ convertStreamStop,
34
+ convertToolUseOnly,
35
+ convertToolResultOnly,
36
+ convertReasoningMessage,
37
+ convertPartialReasoningDelta,
38
+ convertReasoningStreamStart,
39
+ convertReasoningStreamStop,
40
+ getToolInput,
41
+ } from './message-converter';
42
+ import { ensureClient, getClient } from './server';
43
+ import { debug } from '$shared/utils/logger';
44
+
45
+ /** Map SDK Model.status to our category */
46
+ function mapStatusToCategory(status: Model['status']): EngineModel['category'] {
47
+ switch (status) {
48
+ case 'deprecated': return 'legacy';
49
+ case 'alpha':
50
+ case 'beta': return 'stable';
51
+ default: return 'latest';
52
+ }
53
+ }
54
+
55
+ /** Build capability tags from SDK Model.capabilities */
56
+ function buildCapabilityTags(model: Model): string[] {
57
+ const tags: string[] = [];
58
+ const caps = model.capabilities;
59
+
60
+ if (caps.reasoning) tags.push('Reasoning');
61
+ if (caps.attachment) tags.push('Attachments');
62
+
63
+ return tags;
64
+ }
65
+
66
+ // ============================================================================
67
+ // OpenCode Engine (per-project instance)
68
+ // ============================================================================
69
+
70
+ export class OpenCodeEngine implements AIEngine {
71
+ readonly name = 'opencode' as const;
72
+ private _isInitialized = false;
73
+ private _isActive = false;
74
+ private activeAbortController: AbortController | null = null;
75
+ private activeSessionId: string | null = null;
76
+ private activeProjectPath: string | null = null;
77
+
78
+ get isInitialized(): boolean {
79
+ return this._isInitialized;
80
+ }
81
+
82
+ get isActive(): boolean {
83
+ return this._isActive;
84
+ }
85
+
86
+ /**
87
+ * Initialize this per-project engine instance.
88
+ * Delegates to the shared client singleton (concurrency-safe).
89
+ */
90
+ async initialize(): Promise<void> {
91
+ if (this._isInitialized) return;
92
+
93
+ await ensureClient();
94
+ this._isInitialized = true;
95
+ debug.log('engine', 'Open Code engine instance initialized (shared client)');
96
+ }
97
+
98
+ /**
99
+ * Cleanup this per-project instance.
100
+ * Does NOT dispose the shared client — that's handled by disposeOpenCodeClient().
101
+ */
102
+ async dispose(): Promise<void> {
103
+ await this.cancel();
104
+ this._isInitialized = false;
105
+ debug.log('engine', 'Open Code engine instance disposed');
106
+ }
107
+
108
+ async getAvailableModels(): Promise<EngineModel[]> {
109
+ const client = await ensureClient();
110
+
111
+ try {
112
+ // config.providers() returns { data: { providers: Provider[] } }
113
+ const response = await client.config.providers();
114
+ const providers: Provider[] = response.data?.providers ?? [];
115
+ const models: EngineModel[] = [];
116
+
117
+ for (const provider of providers) {
118
+ // Provider.models is Record<string, Model>
119
+ const providerModels: Record<string, Model> = provider.models ?? {};
120
+
121
+ for (const [modelKey, model] of Object.entries(providerModels)) {
122
+ const modelId = model.id || modelKey;
123
+ const compoundId = `opencode:${provider.id}/${modelId}`;
124
+
125
+ models.push({
126
+ id: compoundId,
127
+ engine: 'opencode',
128
+ modelId: `${provider.id}/${modelId}`,
129
+ name: model.name || modelId,
130
+ provider: provider.id,
131
+ description: `${model.name || modelId} via ${provider.name || provider.id}`,
132
+ capabilities: buildCapabilityTags(model),
133
+ contextWindow: model.limit.context,
134
+ category: mapStatusToCategory(model.status),
135
+ });
136
+ }
137
+ }
138
+
139
+ debug.log('engine', `Fetched ${models.length} models from ${providers.length} Open Code providers`);
140
+ return models;
141
+ } catch (error) {
142
+ debug.error('engine', 'Failed to fetch Open Code providers:', error);
143
+ }
144
+
145
+ return [];
146
+ }
147
+
148
+ /**
149
+ * Stream a query through Open Code SDK, yielding SDKMessage (Claude format)
150
+ *
151
+ * Flow: subscribe to events FIRST, then send prompt asynchronously.
152
+ * This ensures no events are missed between sending and subscribing.
153
+ */
154
+ async *streamQuery(options: EngineQueryOptions): AsyncGenerator<EngineSDKMessage, void, unknown> {
155
+ const client = await ensureClient();
156
+
157
+ const {
158
+ projectPath,
159
+ prompt,
160
+ resume,
161
+ model = 'claude-sonnet',
162
+ abortController
163
+ } = options;
164
+
165
+ this.activeAbortController = abortController || new AbortController();
166
+ this._isActive = true;
167
+ this.activeProjectPath = projectPath;
168
+
169
+ debug.log('chat', 'Open Code - Stream Query');
170
+ debug.log('chat', { prompt });
171
+
172
+ try {
173
+ const promptParts = this.extractPromptParts(prompt);
174
+
175
+ // Create or fork a session
176
+ // When resuming, fork the session to create a new branch
177
+ // (like Claude Code's forkSession: true) so each checkpoint
178
+ // gets its own conversation branch
179
+ let sessionId: string;
180
+
181
+ if (resume) {
182
+ try {
183
+ const forkResult = await client.session.fork({
184
+ path: { id: resume },
185
+ query: { directory: projectPath }
186
+ });
187
+ sessionId = forkResult.data?.id || resume;
188
+ debug.log('engine', `Forked Open Code session: ${resume} → ${sessionId}`);
189
+ } catch (forkError) {
190
+ // Fallback to resuming the same session if fork fails
191
+ debug.warn('engine', 'Failed to fork Open Code session, falling back to resume:', forkError);
192
+ sessionId = resume;
193
+ }
194
+ } else {
195
+ const sessionResult = await client.session.create({
196
+ query: { directory: projectPath }
197
+ });
198
+ sessionId = sessionResult.data?.id || crypto.randomUUID();
199
+ }
200
+
201
+ this.activeSessionId = sessionId;
202
+
203
+ yield convertSystemInitMessage(sessionId, model);
204
+ yield convertStreamStart(sessionId);
205
+
206
+ // 1. Subscribe to event stream FIRST (before sending prompt)
207
+ const eventResult = await client.event.subscribe({
208
+ query: { directory: projectPath },
209
+ signal: this.activeAbortController.signal
210
+ });
211
+
212
+ // 2. Send prompt asynchronously (non-blocking) — parse "providerId/modelId"
213
+ const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
214
+
215
+ client.session.promptAsync({
216
+ path: { id: sessionId },
217
+ body: {
218
+ parts: promptParts as any,
219
+ ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
220
+ },
221
+ query: { directory: projectPath },
222
+ }).catch(error => {
223
+ debug.error('engine', 'Open Code promptAsync error:', error);
224
+ });
225
+
226
+ // 3. Process event stream — emit messages progressively (like Claude Code)
227
+ // Each assistant message becomes its own bubble in the UI
228
+ const messageParts = new Map<string, Part[]>();
229
+ const assistantMessages = new Map<string, OCMessage>(); // All tracked assistant messages
230
+ const emittedMessageIds = new Set<string>(); // Already yielded message IDs
231
+ let currentAssistantId: string | null = null; // Currently active assistant message
232
+ let streamingText = '';
233
+ const emittedToolParts = new Set<string>(); // Tool parts already emitted as tool_use
234
+ const completedToolParts = new Set<string>(); // Tool parts whose tool_result was emitted
235
+ const emittedReasoningParts = new Set<string>(); // Reasoning parts already flushed
236
+ let reasoningStreamActive = false; // Whether reasoning is currently streaming
237
+ let reasoningText = ''; // Accumulated reasoning text
238
+
239
+ /**
240
+ * Flush active reasoning stream: stop reasoning stream, emit final reasoning message.
241
+ */
242
+ const flushReasoning = function* (msg: OCMessage) {
243
+ if (!reasoningStreamActive) return;
244
+ yield convertReasoningStreamStop(sessionId);
245
+ reasoningStreamActive = false;
246
+ if (reasoningText) {
247
+ yield convertReasoningMessage(reasoningText, msg, sessionId);
248
+ }
249
+ reasoningText = '';
250
+ };
251
+
252
+ /**
253
+ * Finalize and yield an assistant message by ID
254
+ * Emits stop stream event, the assembled message, and restarts stream for next message
255
+ */
256
+ const finalizeMessage = function* (msgId: string) {
257
+ const msg = assistantMessages.get(msgId);
258
+ if (!msg || emittedMessageIds.has(msgId)) return;
259
+
260
+ // Flush any active reasoning before finalizing
261
+ yield* flushReasoning(msg);
262
+
263
+ emittedMessageIds.add(msgId);
264
+
265
+ // Stop current stream
266
+ yield convertStreamStop(sessionId);
267
+
268
+ const parts = messageParts.get(msgId) || [];
269
+ // Filter out tool parts and reasoning parts already emitted
270
+ const remainingParts = parts.filter(p => {
271
+ if (p.type === 'tool') return !emittedToolParts.has(p.id);
272
+ if (p.type === 'reasoning') return false; // Already emitted as reasoning message
273
+ return true;
274
+ });
275
+
276
+ if (remainingParts.length > 0) {
277
+ const splitMessages = convertAssistantMessages(msg, remainingParts, sessionId);
278
+ for (const m of splitMessages) {
279
+ yield m;
280
+ }
281
+ }
282
+
283
+ // Restart stream for the next message
284
+ yield convertStreamStart(sessionId);
285
+
286
+ // Reset streaming text for new message
287
+ streamingText = '';
288
+ };
289
+
290
+ // Track whether message.part.delta events are being received.
291
+ // When active, skip delta processing in message.part.updated to prevent duplication.
292
+ let receivedPartDelta = false;
293
+
294
+ if (eventResult?.stream) {
295
+ for await (const event of eventResult.stream) {
296
+ if (this.activeAbortController?.signal.aborted) break;
297
+
298
+ const evt = event as { type: string; properties: Record<string, unknown> };
299
+ debug.log('engine', `[OC] event: ${evt.type}`);
300
+
301
+ switch (evt.type) {
302
+ case 'message.updated': {
303
+ const { info } = (event as EventMessageUpdated).properties;
304
+ // Only track assistant messages for our session
305
+ if (info.role === 'assistant' && info.sessionID === sessionId) {
306
+ assistantMessages.set(info.id, info);
307
+
308
+ // If a NEW assistant message arrives and we had a previous one,
309
+ // finalize the previous message (emit it as a separate bubble)
310
+ if (currentAssistantId && currentAssistantId !== info.id) {
311
+ yield* finalizeMessage(currentAssistantId);
312
+ }
313
+ currentAssistantId = info.id;
314
+ }
315
+ break;
316
+ }
317
+
318
+ case 'message.part.updated': {
319
+ const props = (event as EventMessagePartUpdated).properties;
320
+ const part = props.part;
321
+
322
+ debug.log('engine', `[OC] part.updated: type=${part.type}, partId=${part.id}, msgId=${part.messageID}, session=${part.sessionID === sessionId ? 'match' : 'skip'}`);
323
+
324
+ // Only process parts belonging to our session
325
+ if (part.sessionID !== sessionId) break;
326
+
327
+ // Only process parts for tracked assistant messages (skip user message parts)
328
+ if (!assistantMessages.has(part.messageID)) {
329
+ debug.log('engine', `[OC] part.updated: skipped — messageID not in assistantMessages`);
330
+ break;
331
+ }
332
+
333
+ // Accumulate parts per message
334
+ const msgId = part.messageID;
335
+ if (!messageParts.has(msgId)) {
336
+ messageParts.set(msgId, []);
337
+ }
338
+
339
+ const parts = messageParts.get(msgId)!;
340
+ const existingIdx = parts.findIndex(p => p.id === part.id);
341
+ if (existingIdx >= 0) {
342
+ parts[existingIdx] = part;
343
+ } else {
344
+ parts.push(part);
345
+ }
346
+
347
+ // Progressive tool rendering: emit tool_use immediately, tool_result when done
348
+ if (part.type === 'tool') {
349
+ const toolPart = part as ToolPart;
350
+ const msg = assistantMessages.get(msgId);
351
+
352
+ // Flush reasoning before tool rendering to preserve order
353
+ if (msg && reasoningStreamActive) {
354
+ yield* flushReasoning(msg);
355
+ }
356
+
357
+ if (msg && !emittedToolParts.has(part.id)) {
358
+ // Only emit tool_use when input is available
359
+ // Pending tools may have empty input ({}) — wait for next update
360
+ const resolvedInput = getToolInput(toolPart);
361
+ const hasInput = Object.keys(resolvedInput).length > 0
362
+ || toolPart.state.status !== 'pending';
363
+
364
+ if (hasInput) {
365
+ emittedToolParts.add(part.id);
366
+
367
+ yield convertStreamStop(sessionId);
368
+ yield convertToolUseOnly(toolPart, msg, sessionId);
369
+
370
+ // If already completed on first sight, emit result immediately too
371
+ if (toolPart.state.status === 'completed' || toolPart.state.status === 'error') {
372
+ completedToolParts.add(part.id);
373
+ yield convertToolResultOnly(toolPart, sessionId);
374
+ }
375
+
376
+ yield convertStreamStart(sessionId);
377
+ streamingText = '';
378
+ }
379
+ } else if (
380
+ (toolPart.state.status === 'completed' || toolPart.state.status === 'error')
381
+ && !completedToolParts.has(part.id)
382
+ ) {
383
+ // Tool completed later — emit tool_result
384
+ completedToolParts.add(part.id);
385
+
386
+ yield convertStreamStop(sessionId);
387
+ yield convertToolResultOnly(toolPart, sessionId);
388
+ yield convertStreamStart(sessionId);
389
+ streamingText = '';
390
+ }
391
+
392
+ break;
393
+ }
394
+
395
+ // Handle reasoning parts — start reasoning stream (only once per part)
396
+ if (part.type === 'reasoning') {
397
+ if (!reasoningStreamActive && !emittedReasoningParts.has(part.id)) {
398
+ reasoningStreamActive = true;
399
+ reasoningText = '';
400
+ emittedReasoningParts.add(part.id);
401
+ yield convertReasoningStreamStart(sessionId);
402
+ debug.log('engine', `[OC] reasoning stream started for part=${part.id}`);
403
+ }
404
+ break;
405
+ }
406
+
407
+ // When a text part appears and reasoning was active, flush reasoning first
408
+ if (part.type === 'text' && reasoningStreamActive) {
409
+ const msg = assistantMessages.get(msgId);
410
+ if (msg) {
411
+ yield* flushReasoning(msg);
412
+ }
413
+ }
414
+
415
+ // Skip non-text/non-reasoning parts (step-start, step-finish, etc.)
416
+ if (part.type !== 'text') {
417
+ debug.log('engine', `[OC] part.updated: skipped non-text type=${part.type}`);
418
+ break;
419
+ }
420
+
421
+ // Only stream text for the current active message
422
+ if (msgId !== currentAssistantId) {
423
+ debug.log('engine', `[OC] part.updated: text skipped — msgId=${msgId} !== currentAssistantId=${currentAssistantId}`);
424
+ break;
425
+ }
426
+
427
+ // Stream text deltas — skip if message.part.delta events handle it
428
+ // to prevent double-counting the same delta
429
+ if (receivedPartDelta) {
430
+ // message.part.delta handles text streaming — just update accumulated text
431
+ if ((part as any).text) {
432
+ // Sync streamingText with the authoritative accumulated text from the part
433
+ // This handles any drift without emitting duplicate deltas
434
+ streamingText = (part as any).text;
435
+ }
436
+ break;
437
+ }
438
+
439
+ const hasDelta = !!(props as any).delta;
440
+ const hasText = !!(part as any).text;
441
+ debug.log('engine', `[OC] text streaming: hasDelta=${hasDelta}, hasText=${hasText}, textLen=${(part as any).text?.length || 0}, streamingTextLen=${streamingText.length}`);
442
+
443
+ if ((props as any).delta) {
444
+ streamingText += (props as any).delta;
445
+ yield convertPartialTextDelta((props as any).delta, sessionId);
446
+ } else if ((part as any).text) {
447
+ const newText = (part as any).text;
448
+ if (newText.length > streamingText.length) {
449
+ const diff = newText.slice(streamingText.length);
450
+ streamingText = newText;
451
+ yield convertPartialTextDelta(diff, sessionId);
452
+ }
453
+ }
454
+ break;
455
+ }
456
+
457
+ case 'session.idle': {
458
+ // Session finished — flush reasoning and emit the last assistant message
459
+ if (currentAssistantId && !emittedMessageIds.has(currentAssistantId)) {
460
+ const msg = assistantMessages.get(currentAssistantId);
461
+ if (msg) {
462
+ // Flush any active reasoning
463
+ yield* flushReasoning(msg);
464
+ }
465
+ yield convertStreamStop(sessionId);
466
+ if (msg) {
467
+ const parts = messageParts.get(currentAssistantId) || [];
468
+ // Filter out tool parts and reasoning parts already emitted
469
+ const remainingParts = parts.filter(p => {
470
+ if (p.type === 'tool') return !emittedToolParts.has(p.id);
471
+ if (p.type === 'reasoning') return false;
472
+ return true;
473
+ });
474
+ if (remainingParts.length > 0) {
475
+ const splitMsgs1 = convertAssistantMessages(msg, remainingParts, sessionId);
476
+ for (const m of splitMsgs1) {
477
+ yield m;
478
+ }
479
+ }
480
+ }
481
+ emittedMessageIds.add(currentAssistantId);
482
+ } else {
483
+ yield convertStreamStop(sessionId);
484
+ }
485
+
486
+ // Emit result message with token usage from the last assistant message
487
+ if (currentAssistantId) {
488
+ const lastMsg = assistantMessages.get(currentAssistantId);
489
+ if (lastMsg && lastMsg.role === 'assistant') {
490
+ yield convertResultMessage(lastMsg, sessionId);
491
+ }
492
+ }
493
+
494
+ return; // Done
495
+ }
496
+
497
+ case 'session.status': {
498
+ const { status } = (event as EventSessionStatus).properties;
499
+ if (status.type === 'idle') {
500
+ // Same as session.idle
501
+ if (currentAssistantId && !emittedMessageIds.has(currentAssistantId)) {
502
+ const msg = assistantMessages.get(currentAssistantId);
503
+ if (msg) {
504
+ yield* flushReasoning(msg);
505
+ }
506
+ yield convertStreamStop(sessionId);
507
+ if (msg) {
508
+ const parts = messageParts.get(currentAssistantId) || [];
509
+ const remainingParts = parts.filter(p => {
510
+ if (p.type === 'tool') return !emittedToolParts.has(p.id);
511
+ if (p.type === 'reasoning') return false;
512
+ return true;
513
+ });
514
+ if (remainingParts.length > 0) {
515
+ const splitMsgs2 = convertAssistantMessages(msg, remainingParts, sessionId);
516
+ for (const m of splitMsgs2) {
517
+ yield m;
518
+ }
519
+ }
520
+ }
521
+ } else {
522
+ yield convertStreamStop(sessionId);
523
+ }
524
+ return;
525
+ }
526
+ break;
527
+ }
528
+
529
+ // Handle message.part.delta — newer OpenCode servers send text deltas
530
+ // through this event instead of (or alongside) message.part.updated
531
+ // When both fire for the same text, only this handler processes deltas
532
+ case 'message.part.delta': {
533
+ receivedPartDelta = true;
534
+ const deltaProps = evt.properties as {
535
+ sessionID?: string;
536
+ messageID?: string;
537
+ partID?: string;
538
+ field?: string;
539
+ delta?: string;
540
+ };
541
+
542
+ // Only process deltas for our session
543
+ if (deltaProps.sessionID !== sessionId) break;
544
+ // Only process deltas for tracked assistant messages
545
+ if (!deltaProps.messageID || !assistantMessages.has(deltaProps.messageID)) break;
546
+ // Only stream for the current active message
547
+ if (deltaProps.messageID !== currentAssistantId) break;
548
+ // Only stream text field deltas
549
+ if (deltaProps.field !== 'text') break;
550
+
551
+ // Handle reasoning part deltas — stream as reasoning instead of text
552
+ if (deltaProps.partID && deltaProps.messageID && messageParts.has(deltaProps.messageID)) {
553
+ const knownParts = messageParts.get(deltaProps.messageID)!;
554
+ const knownPart = knownParts.find(p => p.id === deltaProps.partID);
555
+ if (knownPart && knownPart.type === 'reasoning') {
556
+ if (deltaProps.delta) {
557
+ reasoningText += deltaProps.delta;
558
+ yield convertPartialReasoningDelta(deltaProps.delta, sessionId);
559
+ }
560
+ break;
561
+ }
562
+ // Skip other non-text parts (step-start, etc.)
563
+ if (knownPart && knownPart.type !== 'text') {
564
+ break;
565
+ }
566
+ }
567
+
568
+ if (deltaProps.delta) {
569
+ debug.log('engine', `[OC] part.delta: field=${deltaProps.field}, deltaLen=${deltaProps.delta.length}`);
570
+ streamingText += deltaProps.delta;
571
+
572
+ // Also update the accumulated text in the tracked part
573
+ if (deltaProps.partID && messageParts.has(deltaProps.messageID)) {
574
+ const existingParts = messageParts.get(deltaProps.messageID)!;
575
+ const textPart = existingParts.find(p => p.id === deltaProps.partID && p.type === 'text');
576
+ if (textPart && 'text' in textPart) {
577
+ (textPart as any).text = (textPart as any).text + deltaProps.delta;
578
+ }
579
+ }
580
+
581
+ yield convertPartialTextDelta(deltaProps.delta, sessionId);
582
+ }
583
+ break;
584
+ }
585
+
586
+ case 'session.error': {
587
+ const { error } = (event as EventSessionError).properties;
588
+ // Extract a human-readable error message.
589
+ // OpenCode SDK errors follow: { name: string, data: { message, statusCode?, providerID?, responseBody? } }
590
+ // Don't prepend error class names (e.g. "APIError") — those are SDK
591
+ // implementation details, not useful for the end user.
592
+ let errorMsg = 'Unknown Open Code error';
593
+ if (error) {
594
+ const errObj = error as Record<string, any>;
595
+ const name = errObj.name || '';
596
+ const dataMsg = errObj.data?.message || '';
597
+ const statusCode = errObj.data?.statusCode;
598
+ const providerID = errObj.data?.providerID;
599
+ const responseBody = errObj.data?.responseBody;
600
+
601
+ if (dataMsg) {
602
+ errorMsg = dataMsg;
603
+ } else if (responseBody) {
604
+ // No data.message — try to parse responseBody for the actual error
605
+ try {
606
+ const body = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
607
+ errorMsg = body?.error?.message || body?.message || String(responseBody);
608
+ } catch {
609
+ errorMsg = String(responseBody);
610
+ }
611
+ } else if (name) {
612
+ errorMsg = name;
613
+ } else if (typeof error === 'string') {
614
+ errorMsg = error;
615
+ } else {
616
+ errorMsg = JSON.stringify(error);
617
+ }
618
+
619
+ // Append status code
620
+ if (statusCode) {
621
+ errorMsg += ` (status ${statusCode})`;
622
+ }
623
+
624
+ // Append provider ID to help identify which provider failed
625
+ if (providerID) {
626
+ errorMsg += ` [provider: ${providerID}]`;
627
+ }
628
+ }
629
+ debug.error('engine', '[OC] session.error:', errorMsg);
630
+ throw new Error(errorMsg);
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ } catch (error) {
637
+ if (error instanceof Error) {
638
+ if (error.name === 'AbortError' || error.message.includes('aborted')) {
639
+ return;
640
+ }
641
+ }
642
+ throw error;
643
+ } finally {
644
+ this._isActive = false;
645
+ this.activeAbortController = null;
646
+ this.activeSessionId = null;
647
+ this.activeProjectPath = null;
648
+ }
649
+ }
650
+
651
+ async cancel(): Promise<void> {
652
+ // Abort the OpenCode session on the server so it stops processing
653
+ const client = getClient();
654
+ if (client && this.activeSessionId) {
655
+ try {
656
+ await client.session.abort({
657
+ path: { id: this.activeSessionId },
658
+ ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
659
+ });
660
+ debug.log('engine', 'Open Code session aborted:', this.activeSessionId);
661
+ } catch (error) {
662
+ debug.warn('engine', 'Failed to abort Open Code session:', error);
663
+ }
664
+ }
665
+
666
+ if (this.activeAbortController) {
667
+ this.activeAbortController.abort();
668
+ this.activeAbortController = null;
669
+ }
670
+ this._isActive = false;
671
+ this.activeSessionId = null;
672
+ this.activeProjectPath = null;
673
+ }
674
+
675
+ /**
676
+ * Cancel a specific session on the OpenCode server.
677
+ * Used by stream-manager for per-project isolation (instead of global cancel).
678
+ */
679
+ async cancelSession(sessionId: string, projectPath?: string): Promise<void> {
680
+ const client = getClient();
681
+ if (!client || !sessionId) return;
682
+ try {
683
+ await client.session.abort({
684
+ path: { id: sessionId },
685
+ ...(projectPath && { query: { directory: projectPath } }),
686
+ });
687
+ debug.log('engine', 'Open Code session aborted (per-stream):', sessionId);
688
+ } catch (error) {
689
+ debug.warn('engine', 'Failed to abort Open Code session:', error);
690
+ }
691
+ }
692
+
693
+ async interrupt(): Promise<void> {
694
+ // Open Code SDK doesn't have a separate interrupt — use cancel
695
+ await this.cancel();
696
+ }
697
+
698
+ /**
699
+ * Extract prompt parts (text + file attachments) from SDKUserMessage.
700
+ * Converts Claude-format image/document blocks to OpenCode FilePartInput format.
701
+ */
702
+ private extractPromptParts(prompt: SDKUserMessage): Array<
703
+ | { type: 'text'; text: string }
704
+ | { type: 'file'; mime: string; filename?: string; url: string }
705
+ > {
706
+ const msg = prompt as Record<string, unknown>;
707
+ const message = msg.message as Record<string, unknown> | undefined;
708
+ if (!message) return [{ type: 'text', text: '' }];
709
+
710
+ if (typeof message.content === 'string') {
711
+ return [{ type: 'text', text: message.content }];
712
+ }
713
+
714
+ if (Array.isArray(message.content)) {
715
+ const parts: Array<
716
+ | { type: 'text'; text: string }
717
+ | { type: 'file'; mime: string; filename?: string; url: string }
718
+ > = [];
719
+
720
+ for (const block of message.content as Array<Record<string, unknown>>) {
721
+ if (block.type === 'text') {
722
+ parts.push({ type: 'text', text: block.text as string });
723
+ } else if (block.type === 'image' && block.source) {
724
+ // Claude format: { type: 'image', source: { type: 'base64', media_type, data } }
725
+ const source = block.source as Record<string, unknown>;
726
+ if (source.type === 'base64' && source.data && source.media_type) {
727
+ parts.push({
728
+ type: 'file',
729
+ mime: source.media_type as string,
730
+ url: `data:${source.media_type};base64,${source.data}`,
731
+ });
732
+ }
733
+ } else if (block.type === 'document' && block.source) {
734
+ // Claude format: { type: 'document', source: { type: 'base64', media_type, data }, title }
735
+ const source = block.source as Record<string, unknown>;
736
+ if (source.type === 'base64' && source.data && source.media_type) {
737
+ parts.push({
738
+ type: 'file',
739
+ mime: source.media_type as string,
740
+ filename: (block.title as string) || undefined,
741
+ url: `data:${source.media_type};base64,${source.data}`,
742
+ });
743
+ }
744
+ }
745
+ }
746
+
747
+ if (parts.length === 0) {
748
+ parts.push({ type: 'text', text: '' });
749
+ }
750
+ return parts;
751
+ }
752
+
753
+ return [{ type: 'text', text: '' }];
754
+ }
755
+ }