wind-agent-cli 1.37.2 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (510) hide show
  1. package/LICENSE +718 -718
  2. package/README.md +17 -2
  3. package/dist/api-docs.html +901 -901
  4. package/dist/assets/{CodeEditor-B2Wf7Fg0.js → CodeEditor-FvgsnJ7J.js} +6 -6
  5. package/dist/assets/FileTree-NLULrBDi.js +1 -0
  6. package/dist/assets/GitPanel-51KUBbEW.js +4 -0
  7. package/dist/assets/{MarkdownPreview-C0tqYnVu.js → MarkdownPreview-PEEWVWeS.js} +17 -27
  8. package/dist/assets/Shell-BEx66cW_.js +76 -0
  9. package/dist/assets/Shell-DrlLKa8f.css +1 -0
  10. package/dist/assets/StandaloneShell-BkuUCPZP.js +1 -0
  11. package/dist/assets/TaskMasterPanel-DfQG80SF.js +2 -0
  12. package/dist/assets/abnfDiagram-N423BO3Z-Cc0ul87U.js +1 -0
  13. package/dist/assets/arc-BAuugHw_.js +1 -0
  14. package/dist/assets/architectureDiagram-T3A2C74G-DA3G7QZD.js +36 -0
  15. package/dist/assets/{blockDiagram-VBNYF7ZC-DpaEf3mv.js → blockDiagram-VBNYF7ZC-tJ12IJs3.js} +4 -4
  16. package/dist/assets/c4Diagram-5PPSVZJV-5BTMCXL4.js +10 -0
  17. package/dist/assets/channel-BGDgNNQz.js +1 -0
  18. package/dist/assets/{chunk-2GRJ4B5K-DoSdNo8u.js → chunk-2GRJ4B5K-BPaI06Ou.js} +1 -1
  19. package/dist/assets/{chunk-2Q5K7J3B-CE7I3uMn.js → chunk-2Q5K7J3B-HV4zmsEe.js} +1 -1
  20. package/dist/assets/chunk-5RXB4S5H-BAwutJoU.js +231 -0
  21. package/dist/assets/{chunk-5VM5RSS4-B55XgqHZ.js → chunk-5VM5RSS4-B9QAJPrv.js} +1 -1
  22. package/dist/assets/{chunk-6Q2QTUOP-BS1KpefW.js → chunk-6Q2QTUOP-BOIW8bzI.js} +1 -1
  23. package/dist/assets/chunk-GF5L2VYU-DD13o00-.js +206 -0
  24. package/dist/assets/{chunk-JWPE2WC7-DOtkNhMm.js → chunk-JWPE2WC7-CFBm23VK.js} +1 -1
  25. package/dist/assets/{chunk-KBJHAD2P-BXLjviqM.js → chunk-KBJHAD2P-C0i0v8Op.js} +1 -1
  26. package/dist/assets/{chunk-RYQCIY6F-C50fjNNz.js → chunk-RYQCIY6F-DfeK0taV.js} +1 -1
  27. package/dist/assets/{chunk-XXDRQBXY-QBYqCkb7.js → chunk-XXDRQBXY-BKWvf-QE.js} +1 -1
  28. package/dist/assets/classDiagram-JCYQIIEL-C2SNM4oy.js +1 -0
  29. package/dist/assets/classDiagram-v2-OCEON4UE-C2SNM4oy.js +1 -0
  30. package/dist/assets/cose-bilkent-JH36ORCC-CBE8oM7L.js +1 -0
  31. package/dist/assets/{cynefin-VYW2F7L2-DnJa_OlQ.js → cynefin-VYW2F7L2-DziGp35A.js} +11 -23
  32. package/dist/assets/cynefinDiagram-MW4NZA55-CNvtHl39.js +62 -0
  33. package/dist/assets/cytoscape.esm-yzknjiTM.js +321 -0
  34. package/dist/assets/dagre-VZM6K2ZE-Bz9lsU7m.js +4 -0
  35. package/dist/assets/{diagram-7IWD3JNH-pFzs9HEu.js → diagram-7IWD3JNH-CV7D3VSd.js} +5 -5
  36. package/dist/assets/{diagram-B4RE2ZJO-CcehQC7x.js → diagram-B4RE2ZJO-Dj4EjDNC.js} +2 -2
  37. package/dist/assets/diagram-LBJQPF4R-wJ6FHvf-.js +24 -0
  38. package/dist/assets/diagram-Q27KOJAE-DMFYMhOB.js +24 -0
  39. package/dist/assets/diagram-UB23O5K3-DeBSbWAI.js +41 -0
  40. package/dist/assets/ebnfDiagram-BXEA7PRR-2NnJSGhT.js +1 -0
  41. package/dist/assets/erDiagram-JOGREHBK-DrSlg8hr.js +85 -0
  42. package/dist/assets/feature-chat-CvOCyaLe.js +88 -0
  43. package/dist/assets/feature-workspace-CAB6bgPy.js +39 -0
  44. package/dist/assets/{flowDiagram-UKHOOZJN-CJV8fQPR.js → flowDiagram-UKHOOZJN-C0pejU6W.js} +4 -4
  45. package/dist/assets/ganttDiagram-PKOTCBZU-BM0VCUUz.js +292 -0
  46. package/dist/assets/gitGraphDiagram-DS77QQ5N-SbLG0c5_.js +106 -0
  47. package/dist/assets/graph-DOmOIIwC.js +1 -0
  48. package/dist/assets/{index-GFhXZyl2.js → index-BflGfU1C.js} +3 -3
  49. package/dist/assets/index-Btkf0_rU.js +1 -0
  50. package/dist/assets/index-Cr5MbHuj.css +1 -0
  51. package/dist/assets/index-Gqazqfs3.js +1 -0
  52. package/dist/assets/index-nyrkoQK6.js +35 -0
  53. package/dist/assets/infoDiagram-6WML65LV-Cf5DSa66.js +2 -0
  54. package/dist/assets/ishikawaDiagram-WSZJBQD7-DwyFutKy.js +70 -0
  55. package/dist/assets/journeyDiagram-NVQOT4AX-Df0FRhLo.js +139 -0
  56. package/dist/assets/{kanban-definition-27J2QSJJ-B3iL0ztJ.js → kanban-definition-27J2QSJJ-HmpbjrZz.js} +6 -6
  57. package/dist/assets/{layout-D6BstSzF.js → layout-D-LzfAck.js} +1 -1
  58. package/dist/assets/{linear-Cwe9dEKA.js → linear-DOM01LBe.js} +1 -1
  59. package/dist/assets/map-DxJ2ADlA.js +1 -0
  60. package/dist/assets/mermaid.core-C2Ma-_S6.js +306 -0
  61. package/dist/assets/{mindmap-definition-FAOFIHXS-BgfBYMzr.js → mindmap-definition-FAOFIHXS-B4GOIn76.js} +4 -4
  62. package/dist/assets/pegDiagram-VL7TDLO6-BHur7aH2.js +1 -0
  63. package/dist/assets/{pieDiagram-7S7Q4E2Y-DeSrxc4I.js → pieDiagram-7S7Q4E2Y-Cw0UELLV.js} +2 -2
  64. package/dist/assets/quadrantDiagram-CIZ2JOQS-CB2BrX3g.js +7 -0
  65. package/dist/assets/railroadDiagram-AXF67PYL-DPYZYmLX.js +1 -0
  66. package/dist/assets/requirementDiagram-LRYGKXZP-DSX4IJ1n.js +84 -0
  67. package/dist/assets/sankeyDiagram-W5VNT64P-TenC3ZQo.js +40 -0
  68. package/dist/assets/sequenceDiagram-SI44F4Z6-DltGsf3F.js +162 -0
  69. package/dist/assets/sizeCapture-X5ZJPWSS-D7llwbJZ.js +1 -0
  70. package/dist/assets/stateDiagram-OKZ733FA-C-hAvbnY.js +1 -0
  71. package/dist/assets/stateDiagram-v2-UEYNNEHI-DaYSFcRQ.js +1 -0
  72. package/dist/assets/swimlanes-SLNWSIFB-DWD30fv6.js +2 -0
  73. package/dist/assets/swimlanesDiagram-ULZ7WXOC-LTkF2T6t.js +8 -0
  74. package/dist/assets/timeline-definition-Z64GVDOM-quWmimSE.js +120 -0
  75. package/dist/assets/vendor-data-DsEZeNPr.js +4 -0
  76. package/dist/assets/vendor-i18n-B6HjLCnN.js +1 -0
  77. package/dist/assets/vendor-markdown-DkITEES2.js +298 -0
  78. package/dist/assets/vendor-react-C2GTiUCy.js +10 -0
  79. package/dist/assets/vendor-ui-CAUHSABu.js +49 -0
  80. package/dist/assets/vennDiagram-T6HMQDX7-aUQcm8h5.js +34 -0
  81. package/dist/assets/{wardleyDiagram-T6FBY63Y-BNCpsFZV.js → wardleyDiagram-T6FBY63Y-BZ0bwsFQ.js} +2 -2
  82. package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
  83. package/dist/assets/xychartDiagram-ELKLHX3M-DOVzAq63.js +7 -0
  84. package/dist/clear-cache.html +85 -85
  85. package/dist/convert-icons.md +52 -52
  86. package/dist/favicon.png +0 -0
  87. package/dist/favicon.svg +8 -8
  88. package/dist/generate-icons.js +48 -48
  89. package/dist/icons/codex-white.svg +3 -3
  90. package/dist/icons/codex.svg +3 -3
  91. package/dist/icons/cursor-white.svg +11 -11
  92. package/dist/icons/icon-128x128.png +0 -0
  93. package/dist/icons/icon-128x128.svg +11 -11
  94. package/dist/icons/icon-144x144.png +0 -0
  95. package/dist/icons/icon-144x144.svg +11 -11
  96. package/dist/icons/icon-152x152.png +0 -0
  97. package/dist/icons/icon-152x152.svg +11 -11
  98. package/dist/icons/icon-180x180.png +0 -0
  99. package/dist/icons/icon-192x192.png +0 -0
  100. package/dist/icons/icon-192x192.svg +11 -11
  101. package/dist/icons/icon-384x384.png +0 -0
  102. package/dist/icons/icon-384x384.svg +11 -11
  103. package/dist/icons/icon-512x512.png +0 -0
  104. package/dist/icons/icon-512x512.svg +11 -11
  105. package/dist/icons/icon-72x72.png +0 -0
  106. package/dist/icons/icon-72x72.svg +11 -11
  107. package/dist/icons/icon-96x96.png +0 -0
  108. package/dist/icons/icon-96x96.svg +11 -11
  109. package/dist/icons/icon-maskable-192x192.png +0 -0
  110. package/dist/icons/icon-maskable-512x512.png +0 -0
  111. package/dist/icons/icon-template.svg +11 -11
  112. package/dist/icons/new-icon.png +0 -0
  113. package/dist/index.html +48 -58
  114. package/dist/logo-128.png +0 -0
  115. package/dist/logo-256.png +0 -0
  116. package/dist/logo.svg +17 -17
  117. package/dist/manifest.webmanifest +1 -0
  118. package/dist/push-sw.js +48 -0
  119. package/dist/screenshots/setup-screen-mobile.png +0 -0
  120. package/dist/screenshots/setup-screen.png +0 -0
  121. package/dist/sw.js +1 -124
  122. package/dist/workbox-ae5c0bb2.js +1 -0
  123. package/dist-server/server/index.js +1 -1
  124. package/dist-server/server/index.js.map +1 -1
  125. package/dist-server/server/load-env.js +4 -2
  126. package/dist-server/server/load-env.js.map +1 -1
  127. package/dist-server/server/modules/agent/agent.routes.js +1 -1
  128. package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
  129. package/dist-server/server/modules/browser-use/browser-use.service.js +1 -1
  130. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
  131. package/dist-server/server/modules/cli/cli.service.js +37 -37
  132. package/dist-server/server/modules/cli/cli.service.js.map +1 -1
  133. package/dist-server/server/modules/cli/sandbox.service.js +43 -43
  134. package/dist-server/server/modules/cli/sandbox.service.js.map +1 -1
  135. package/dist-server/server/modules/cli/tests/cli-environment-bootstrap.test.js +74 -74
  136. package/dist-server/server/modules/commands/commands.routes.js +25 -25
  137. package/dist-server/server/modules/commands/commands.routes.js.map +1 -1
  138. package/dist-server/server/modules/commands/tests/commands.test.js +1 -1
  139. package/dist-server/server/modules/commands/tests/commands.test.js.map +1 -1
  140. package/dist-server/server/modules/database/connection.js +1 -1
  141. package/dist-server/server/modules/database/connection.js.map +1 -1
  142. package/dist-server/server/modules/database/migrations.js +190 -190
  143. package/dist-server/server/modules/database/repositories/api-keys.js +3 -3
  144. package/dist-server/server/modules/database/repositories/github-tokens.js +2 -2
  145. package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js +28 -28
  146. package/dist-server/server/modules/database/repositories/notification-preferences.js +4 -4
  147. package/dist-server/server/modules/database/repositories/projects.db.js +61 -61
  148. package/dist-server/server/modules/database/repositories/provider-models.js +35 -35
  149. package/dist-server/server/modules/database/repositories/push-subscriptions.js +5 -5
  150. package/dist-server/server/modules/database/repositories/scan-state.db.js +5 -5
  151. package/dist-server/server/modules/database/repositories/sessions.db.js +93 -93
  152. package/dist-server/server/modules/database/schema.js +177 -177
  153. package/dist-server/server/modules/database/tests/projects.db.integration.test.js +3 -2
  154. package/dist-server/server/modules/database/tests/projects.db.integration.test.js.map +1 -1
  155. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +6 -6
  156. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +4 -3
  157. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  158. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +2 -1
  159. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  160. package/dist-server/server/modules/git/git.routes.js +17 -17
  161. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +1 -1
  162. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
  163. package/dist-server/server/modules/notifications/vapid-keys.service.js +1 -1
  164. package/dist-server/server/modules/notifications/vapid-keys.service.js.map +1 -1
  165. package/dist-server/server/modules/projects/tests/project-management.service.test.js +2 -1
  166. package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
  167. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js +2 -2
  168. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js.map +1 -1
  169. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +1 -1
  170. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  171. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +1 -1
  172. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  173. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  174. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +13 -13
  175. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js +9 -9
  176. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.test.js +17 -17
  177. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +25 -25
  178. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +27 -27
  179. package/dist-server/server/modules/providers/services/provider-token-usage.service.js +9 -9
  180. package/dist-server/server/modules/providers/services/sessions.service.js +1 -1
  181. package/dist-server/server/modules/providers/tests/claude-auth.test.js +8 -0
  182. package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
  183. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +1 -1
  184. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  185. package/dist-server/server/modules/providers/tests/mcp.test.js +3 -3
  186. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +98 -98
  187. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  188. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +18 -18
  189. package/dist-server/server/modules/providers/tests/provider.routes.test.js +1 -1
  190. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -1
  191. package/dist-server/server/modules/system/system.service.js +1 -1
  192. package/dist-server/server/modules/system/system.service.js.map +1 -1
  193. package/dist-server/server/modules/system/tests/system.service.test.js +1 -1
  194. package/dist-server/server/modules/system/tests/system.service.test.js.map +1 -1
  195. package/dist-server/server/modules/taskmaster/taskmaster.routes.js +423 -422
  196. package/dist-server/server/modules/taskmaster/taskmaster.routes.js.map +1 -1
  197. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +1 -1
  198. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  199. package/dist-server/server/modules/worktrees/services/worktree-list.service.js +1 -1
  200. package/dist-server/server/modules/worktrees/services/worktree-open.service.js +1 -1
  201. package/dist-server/server/modules/worktrees/services/worktree-remove.service.js +1 -1
  202. package/electron/cloud.js +260 -260
  203. package/electron/desktopNotifications.js +378 -378
  204. package/electron/desktopWindow.js +766 -766
  205. package/electron/launcher/index.html +14 -14
  206. package/electron/launcher/launcher.css +801 -801
  207. package/electron/launcher/launcher.js +687 -687
  208. package/electron/localServer.js +549 -549
  209. package/electron/main.js +944 -944
  210. package/electron/preload.cjs +60 -60
  211. package/electron/scripts/generate-macos-icon.js +62 -62
  212. package/electron/serverInstaller.js +277 -277
  213. package/electron/tabs.js +87 -87
  214. package/electron/viewHost.js +331 -331
  215. package/package.json +262 -247
  216. package/public/api-docs.html +901 -901
  217. package/scripts/build-client.mjs +30 -0
  218. package/scripts/fix-node-pty.js +67 -67
  219. package/scripts/generate-pwa-icons.ps1 +33 -0
  220. package/scripts/promote-dist-server.mjs +50 -50
  221. package/scripts/release/build-server-bundle.js +176 -176
  222. package/scripts/release/prepare-desktop-app.js +152 -152
  223. package/scripts/verify-pwa-build.mjs +37 -0
  224. package/server/index.ts +399 -399
  225. package/server/load-env.ts +49 -47
  226. package/server/modules/agent/agent.module.ts +52 -52
  227. package/server/modules/agent/agent.routes.ts +1287 -1287
  228. package/server/modules/agent/index.ts +2 -2
  229. package/server/modules/agent/tests/agent.routes.test.ts +205 -205
  230. package/server/modules/assets/assets.routes.ts +150 -150
  231. package/server/modules/assets/index.ts +3 -3
  232. package/server/modules/assets/services/image-assets.service.ts +127 -127
  233. package/server/modules/assets/tests/image-assets.service.test.ts +74 -74
  234. package/server/modules/auth/auth.middleware.ts +163 -163
  235. package/server/modules/auth/auth.module.ts +38 -38
  236. package/server/modules/auth/auth.routes.ts +57 -57
  237. package/server/modules/auth/auth.service.ts +155 -155
  238. package/server/modules/auth/index.ts +9 -9
  239. package/server/modules/auth/tests/auth.service.test.ts +95 -95
  240. package/server/modules/browser-use/browser-use-mcp.routes.ts +120 -120
  241. package/server/modules/browser-use/browser-use-mcp.ts +386 -386
  242. package/server/modules/browser-use/browser-use-runtime.ts +10 -10
  243. package/server/modules/browser-use/browser-use.routes.ts +96 -96
  244. package/server/modules/browser-use/browser-use.service.ts +836 -836
  245. package/server/modules/browser-use/index.ts +11 -11
  246. package/server/modules/browser-use/tests/browser-use.service.test.ts +10 -10
  247. package/server/modules/cli/cli.module.ts +93 -93
  248. package/server/modules/cli/cli.service.ts +248 -248
  249. package/server/modules/cli/cli.ts +19 -19
  250. package/server/modules/cli/index.ts +2 -2
  251. package/server/modules/cli/sandbox.service.ts +398 -398
  252. package/server/modules/cli/tests/cli-environment-bootstrap.test.ts +186 -186
  253. package/server/modules/cli/tests/cli.service.test.ts +86 -86
  254. package/server/modules/cli/tests/sandbox.service.test.ts +58 -58
  255. package/server/modules/commands/commands.module.ts +22 -22
  256. package/server/modules/commands/commands.routes.ts +587 -587
  257. package/server/modules/commands/index.ts +2 -2
  258. package/server/modules/commands/tests/commands.test.ts +110 -110
  259. package/server/modules/database/connection.ts +144 -143
  260. package/server/modules/database/index.ts +17 -17
  261. package/server/modules/database/init-db.ts +17 -17
  262. package/server/modules/database/migrations.ts +515 -515
  263. package/server/modules/database/repositories/api-keys.ts +119 -119
  264. package/server/modules/database/repositories/app-config.ts +53 -53
  265. package/server/modules/database/repositories/credentials.ts +106 -106
  266. package/server/modules/database/repositories/github-tokens.ts +100 -100
  267. package/server/modules/database/repositories/notification-channel-endpoints.ts +153 -153
  268. package/server/modules/database/repositories/notification-preferences.ts +117 -117
  269. package/server/modules/database/repositories/projects.db.ts +196 -196
  270. package/server/modules/database/repositories/provider-models.ts +168 -168
  271. package/server/modules/database/repositories/push-subscriptions.ts +80 -80
  272. package/server/modules/database/repositories/scan-state.db.ts +42 -42
  273. package/server/modules/database/repositories/sessions.db.ts +524 -524
  274. package/server/modules/database/repositories/users.ts +140 -140
  275. package/server/modules/database/repositories/vapid-keys.ts +57 -57
  276. package/server/modules/database/schema.ts +210 -210
  277. package/server/modules/database/tests/projects.db.integration.test.ts +73 -72
  278. package/server/modules/database/tests/provider-models.db.integration.test.ts +125 -125
  279. package/server/modules/database/tests/sessions-provider-mapping.test.ts +110 -109
  280. package/server/modules/database/tests/sessions.db.integration.test.ts +117 -116
  281. package/server/modules/file-tree/file-tree.module.ts +116 -116
  282. package/server/modules/file-tree/file-tree.routes.ts +258 -258
  283. package/server/modules/file-tree/file-tree.service.ts +675 -675
  284. package/server/modules/file-tree/index.ts +2 -2
  285. package/server/modules/file-tree/tests/file-tree.routes.test.ts +157 -157
  286. package/server/modules/file-tree/tests/file-tree.service.test.ts +371 -371
  287. package/server/modules/git/git-branch.service.ts +43 -43
  288. package/server/modules/git/git-parsing.service.ts +66 -66
  289. package/server/modules/git/git.module.ts +22 -22
  290. package/server/modules/git/git.routes.ts +1596 -1596
  291. package/server/modules/git/index.ts +2 -2
  292. package/server/modules/git/tests/git-branch.service.test.ts +60 -60
  293. package/server/modules/git/tests/git-init.routes.test.ts +132 -132
  294. package/server/modules/git/tests/git.test.ts +106 -106
  295. package/server/modules/notifications/index.ts +24 -24
  296. package/server/modules/notifications/notifications.routes.ts +127 -127
  297. package/server/modules/notifications/services/desktop-notification-clients.service.ts +124 -124
  298. package/server/modules/notifications/services/notification-orchestrator.service.js +310 -310
  299. package/server/modules/notifications/tests/notification-orchestrator.integration.test.ts +55 -55
  300. package/server/modules/notifications/vapid-keys.service.ts +38 -38
  301. package/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts +109 -109
  302. package/server/modules/plugins/index.ts +9 -9
  303. package/server/modules/plugins/plugin-process.service.ts +217 -217
  304. package/server/modules/plugins/plugin-registry.service.ts +459 -459
  305. package/server/modules/plugins/plugins.module.ts +33 -33
  306. package/server/modules/plugins/plugins.routes.ts +66 -66
  307. package/server/modules/plugins/plugins.service.ts +147 -147
  308. package/server/modules/plugins/tests/plugins.service.test.ts +32 -32
  309. package/server/modules/projects/index.ts +11 -11
  310. package/server/modules/projects/projects.routes.ts +273 -273
  311. package/server/modules/projects/services/project-clone.service.ts +323 -323
  312. package/server/modules/projects/services/project-delete.service.ts +90 -90
  313. package/server/modules/projects/services/project-management.service.ts +144 -144
  314. package/server/modules/projects/services/project-star.service.ts +78 -78
  315. package/server/modules/projects/services/projects-has-taskmaster.service.ts +248 -248
  316. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +314 -314
  317. package/server/modules/projects/tests/project-clone.service.test.ts +183 -183
  318. package/server/modules/projects/tests/project-management.service.test.ts +118 -117
  319. package/server/modules/projects/tests/project-star.service.test.ts +123 -123
  320. package/server/modules/projects/tests/projects-has-taskmaster.service.test.ts +105 -105
  321. package/server/modules/providers/README.md +380 -380
  322. package/server/modules/providers/index.ts +10 -10
  323. package/server/modules/providers/list/claude/claude-auth.provider.ts +160 -160
  324. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  325. package/server/modules/providers/list/claude/claude-models.provider.ts +294 -294
  326. package/server/modules/providers/list/claude/claude-runtime.provider.js +1068 -1068
  327. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +203 -203
  328. package/server/modules/providers/list/claude/claude-sessions.provider.ts +684 -684
  329. package/server/modules/providers/list/claude/claude-skills.provider.ts +265 -265
  330. package/server/modules/providers/list/claude/claude.provider.ts +30 -30
  331. package/server/modules/providers/list/codex/codex-app-server.client.ts +2 -2
  332. package/server/modules/providers/list/codex/codex-auth.provider.ts +100 -100
  333. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  334. package/server/modules/providers/list/codex/codex-models.provider.ts +123 -123
  335. package/server/modules/providers/list/codex/codex-runtime.provider.js +533 -533
  336. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +203 -203
  337. package/server/modules/providers/list/codex/codex-sessions.provider.ts +938 -938
  338. package/server/modules/providers/list/codex/codex-skills.provider.ts +73 -73
  339. package/server/modules/providers/list/codex/codex.provider.ts +28 -28
  340. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +143 -143
  341. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  342. package/server/modules/providers/list/cursor/cursor-models.provider.ts +119 -119
  343. package/server/modules/providers/list/cursor/cursor-runtime.provider.js +385 -385
  344. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +158 -159
  345. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +654 -654
  346. package/server/modules/providers/list/cursor/cursor-skills.provider.ts +39 -39
  347. package/server/modules/providers/list/cursor/cursor.provider.ts +30 -30
  348. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +110 -110
  349. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -228
  350. package/server/modules/providers/list/opencode/opencode-models.provider.ts +203 -203
  351. package/server/modules/providers/list/opencode/opencode-runtime.provider.js +434 -434
  352. package/server/modules/providers/list/opencode/opencode-runtime.provider.test.js +261 -261
  353. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +179 -179
  354. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +500 -500
  355. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -78
  356. package/server/modules/providers/list/opencode/opencode.provider.ts +30 -30
  357. package/server/modules/providers/provider.registry.ts +36 -36
  358. package/server/modules/providers/provider.routes.ts +877 -877
  359. package/server/modules/providers/services/mcp.service.ts +109 -109
  360. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  361. package/server/modules/providers/services/provider-capabilities.service.ts +97 -97
  362. package/server/modules/providers/services/provider-models.service.ts +401 -401
  363. package/server/modules/providers/services/provider-runtime.service.ts +108 -108
  364. package/server/modules/providers/services/provider-token-usage.service.ts +357 -357
  365. package/server/modules/providers/services/session-conversations-search.service.ts +1321 -1321
  366. package/server/modules/providers/services/session-synchronizer.service.ts +74 -74
  367. package/server/modules/providers/services/sessions-watcher.service.ts +247 -247
  368. package/server/modules/providers/services/sessions.service.ts +471 -471
  369. package/server/modules/providers/services/skills.service.ts +39 -39
  370. package/server/modules/providers/shared/base/abstract.provider.ts +32 -32
  371. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  372. package/server/modules/providers/shared/skills/skills.provider.ts +287 -287
  373. package/server/modules/providers/tests/claude-auth.test.ts +157 -150
  374. package/server/modules/providers/tests/claude-sessions.test.ts +61 -61
  375. package/server/modules/providers/tests/codex-sessions.test.ts +231 -231
  376. package/server/modules/providers/tests/mcp.test.ts +349 -349
  377. package/server/modules/providers/tests/opencode-models.test.ts +41 -41
  378. package/server/modules/providers/tests/opencode-sessions.test.ts +522 -522
  379. package/server/modules/providers/tests/provider-attachment-history.test.ts +247 -247
  380. package/server/modules/providers/tests/provider-models.service.test.ts +353 -353
  381. package/server/modules/providers/tests/provider-runtime.service.test.ts +138 -138
  382. package/server/modules/providers/tests/provider-token-usage.service.test.ts +186 -186
  383. package/server/modules/providers/tests/provider.routes.test.ts +244 -244
  384. package/server/modules/providers/tests/sessions-details.test.ts +73 -73
  385. package/server/modules/providers/tests/sessions.service.test.ts +127 -127
  386. package/server/modules/providers/tests/skills.test.ts +692 -692
  387. package/server/modules/settings/index.ts +2 -2
  388. package/server/modules/settings/settings.module.ts +50 -50
  389. package/server/modules/settings/settings.routes.ts +51 -51
  390. package/server/modules/settings/settings.service.ts +186 -186
  391. package/server/modules/settings/tests/settings.service.test.ts +54 -54
  392. package/server/modules/system/index.ts +2 -2
  393. package/server/modules/system/system.module.ts +62 -62
  394. package/server/modules/system/system.routes.ts +21 -21
  395. package/server/modules/system/system.service.ts +76 -76
  396. package/server/modules/system/tests/system.service.test.ts +118 -118
  397. package/server/modules/taskmaster/index.ts +2 -2
  398. package/server/modules/taskmaster/taskmaster.module.ts +24 -24
  399. package/server/modules/taskmaster/taskmaster.routes.ts +1483 -1482
  400. package/server/modules/taskmaster/taskmaster.service.ts +132 -132
  401. package/server/modules/taskmaster/tests/taskmaster.routes.test.ts +144 -144
  402. package/server/modules/taskmaster/tests/taskmaster.service.test.ts +97 -97
  403. package/server/modules/user/index.ts +2 -2
  404. package/server/modules/user/tests/user.service.test.ts +61 -61
  405. package/server/modules/user/user.module.ts +58 -58
  406. package/server/modules/user/user.routes.ts +50 -50
  407. package/server/modules/user/user.service.ts +85 -85
  408. package/server/modules/voice/index.ts +2 -2
  409. package/server/modules/voice/tests/voice.service.test.ts +98 -98
  410. package/server/modules/voice/voice.module.ts +47 -47
  411. package/server/modules/voice/voice.routes.ts +113 -113
  412. package/server/modules/voice/voice.service.ts +192 -192
  413. package/server/modules/websocket/README.md +273 -273
  414. package/server/modules/websocket/index.ts +3 -3
  415. package/server/modules/websocket/services/chat-run-registry.service.ts +343 -343
  416. package/server/modules/websocket/services/chat-session-writer.service.ts +145 -145
  417. package/server/modules/websocket/services/chat-websocket.service.ts +425 -425
  418. package/server/modules/websocket/services/plugin-websocket-proxy.service.ts +65 -65
  419. package/server/modules/websocket/services/shell-websocket.service.ts +503 -503
  420. package/server/modules/websocket/services/websocket-auth.service.ts +59 -59
  421. package/server/modules/websocket/services/websocket-server.service.ts +124 -124
  422. package/server/modules/websocket/services/websocket-state.service.ts +16 -16
  423. package/server/modules/websocket/services/websocket-writer.service.ts +38 -38
  424. package/server/modules/websocket/tests/chat-attachment-filter.test.ts +59 -59
  425. package/server/modules/websocket/tests/chat-run-registry.test.ts +282 -282
  426. package/server/modules/websocket/tests/shell-websocket.service.test.ts +100 -100
  427. package/server/modules/websocket/tests/websocket-heartbeat.service.test.ts +78 -78
  428. package/server/modules/worktrees/index.ts +2 -2
  429. package/server/modules/worktrees/services/worktree-create-and-open.service.ts +53 -53
  430. package/server/modules/worktrees/services/worktree-create.service.ts +104 -104
  431. package/server/modules/worktrees/services/worktree-git.service.ts +207 -207
  432. package/server/modules/worktrees/services/worktree-list.service.ts +143 -143
  433. package/server/modules/worktrees/services/worktree-merge.service.ts +157 -157
  434. package/server/modules/worktrees/services/worktree-open.service.ts +85 -85
  435. package/server/modules/worktrees/services/worktree-remove.service.ts +88 -88
  436. package/server/modules/worktrees/tests/worktree-create-and-open.service.test.ts +68 -68
  437. package/server/modules/worktrees/tests/worktree-create.service.test.ts +142 -142
  438. package/server/modules/worktrees/tests/worktree-git.service.test.ts +84 -84
  439. package/server/modules/worktrees/tests/worktree-list.service.test.ts +45 -45
  440. package/server/modules/worktrees/tests/worktree-merge.service.test.ts +213 -213
  441. package/server/modules/worktrees/tests/worktree-open.service.test.ts +122 -122
  442. package/server/modules/worktrees/tests/worktree-remove.service.test.ts +227 -227
  443. package/server/modules/worktrees/tests/worktrees.routes.test.ts +199 -199
  444. package/server/modules/worktrees/worktrees.module.ts +113 -113
  445. package/server/modules/worktrees/worktrees.routes.ts +116 -116
  446. package/server/shared/claude-cli-path.ts +139 -139
  447. package/server/shared/frontmatter.ts +18 -18
  448. package/server/shared/image-attachments.ts +435 -435
  449. package/server/shared/interfaces.ts +179 -179
  450. package/server/shared/tests/claude-cli-path.test.ts +61 -61
  451. package/server/shared/tests/image-attachments.test.ts +353 -353
  452. package/server/shared/tests/slice-tail-page.test.ts +42 -42
  453. package/server/shared/types.ts +1228 -1228
  454. package/server/shared/utils.ts +1161 -1161
  455. package/server/tsconfig.json +40 -40
  456. package/shared/networkHosts.js +22 -22
  457. package/dist/assets/FileTree-N2IkkHlv.js +0 -127
  458. package/dist/assets/GitPanel-DZCeuFZ2.js +0 -29
  459. package/dist/assets/PluginTabContent-Bnuf-fb8.js +0 -1
  460. package/dist/assets/Shell-BfFnMZ-f.js +0 -81
  461. package/dist/assets/Shell-qxJ8_QYu.css +0 -32
  462. package/dist/assets/StandaloneShell-QmyYwNAl.js +0 -1
  463. package/dist/assets/TaskMasterPanel-fMsmvN9L.js +0 -32
  464. package/dist/assets/abnfDiagram-N423BO3Z-DjPU7HLs.js +0 -1
  465. package/dist/assets/arc-Pdq6Tvr3.js +0 -1
  466. package/dist/assets/architectureDiagram-T3A2C74G-D0JnT0Vh.js +0 -36
  467. package/dist/assets/c4Diagram-5PPSVZJV-MpqlPGFi.js +0 -10
  468. package/dist/assets/channel-Bm0ZLE4z.js +0 -1
  469. package/dist/assets/chunk-5RXB4S5H-B4UQ8snD.js +0 -231
  470. package/dist/assets/chunk-GF5L2VYU-DkEPedJP.js +0 -206
  471. package/dist/assets/classDiagram-JCYQIIEL-DqEOcNxw.js +0 -1
  472. package/dist/assets/classDiagram-v2-OCEON4UE-DqEOcNxw.js +0 -1
  473. package/dist/assets/code-xml-VK1YfShP.js +0 -6
  474. package/dist/assets/cose-bilkent-JH36ORCC-DrVTo1Nh.js +0 -1
  475. package/dist/assets/cynefinDiagram-MW4NZA55-CCxBjw9E.js +0 -62
  476. package/dist/assets/cytoscape.esm-C10L9Icf.js +0 -331
  477. package/dist/assets/dagre-VZM6K2ZE-C_GvE4lu.js +0 -4
  478. package/dist/assets/diagram-LBJQPF4R-hfKcrrxe.js +0 -24
  479. package/dist/assets/diagram-Q27KOJAE-C1semJfK.js +0 -24
  480. package/dist/assets/diagram-UB23O5K3-57hvXQXO.js +0 -41
  481. package/dist/assets/ebnfDiagram-BXEA7PRR-9KIAlAVw.js +0 -1
  482. package/dist/assets/erDiagram-JOGREHBK-B7OzJ767.js +0 -85
  483. package/dist/assets/ganttDiagram-PKOTCBZU-CbMNWWQu.js +0 -292
  484. package/dist/assets/gitGraphDiagram-DS77QQ5N-I_vZ5waO.js +0 -106
  485. package/dist/assets/graph-Cx-EqisW.js +0 -1
  486. package/dist/assets/index-DFnVoqpx.js +0 -1174
  487. package/dist/assets/index-DlSeAkw_.js +0 -11
  488. package/dist/assets/index-ZHwIojzi.js +0 -1
  489. package/dist/assets/index-t1w8KNxn.css +0 -1
  490. package/dist/assets/infoDiagram-6WML65LV-Dh20Tsbq.js +0 -2
  491. package/dist/assets/ishikawaDiagram-WSZJBQD7-DH2Sm3ko.js +0 -70
  492. package/dist/assets/journeyDiagram-NVQOT4AX-BNFQqlVu.js +0 -139
  493. package/dist/assets/map-CV9uif0g.js +0 -1
  494. package/dist/assets/mermaid.core-BYRQSshi.js +0 -312
  495. package/dist/assets/pegDiagram-VL7TDLO6-CNYpECr8.js +0 -1
  496. package/dist/assets/quadrantDiagram-CIZ2JOQS-BXVVQsa6.js +0 -7
  497. package/dist/assets/railroadDiagram-AXF67PYL-JTroIW8F.js +0 -1
  498. package/dist/assets/requirementDiagram-LRYGKXZP-C1Z_inLz.js +0 -84
  499. package/dist/assets/sankeyDiagram-W5VNT64P-DCJgKFUk.js +0 -40
  500. package/dist/assets/sequenceDiagram-SI44F4Z6-D8Wrx5Vc.js +0 -162
  501. package/dist/assets/sizeCapture-X5ZJPWSS-CXqD68vD.js +0 -1
  502. package/dist/assets/stateDiagram-OKZ733FA-CF0Ekqjo.js +0 -1
  503. package/dist/assets/stateDiagram-v2-UEYNNEHI-DARLpEFJ.js +0 -1
  504. package/dist/assets/swimlanes-SLNWSIFB-DVIax_k4.js +0 -2
  505. package/dist/assets/swimlanesDiagram-ULZ7WXOC-CrgDgaY8.js +0 -8
  506. package/dist/assets/timeline-definition-Z64GVDOM-DSqk3ApG.js +0 -120
  507. package/dist/assets/vendor-react-whEHfigN.js +0 -59
  508. package/dist/assets/vennDiagram-T6HMQDX7-DNbTqyG5.js +0 -34
  509. package/dist/assets/xychartDiagram-ELKLHX3M-XsVZphHr.js +0 -7
  510. package/dist/manifest.json +0 -61
@@ -1,1068 +1,1068 @@
1
- /**
2
- * Claude SDK Integration
3
- *
4
- * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
- * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
- * and maintainability.
7
- *
8
- * Key features:
9
- * - Direct SDK integration without child processes
10
- * - Session management with abort capability
11
- * - Options mapping between CLI and SDK formats
12
- * - WebSocket message streaming
13
- */
14
-
15
- import crypto from 'crypto';
16
- import { promises as fs } from 'fs';
17
- import os from 'os';
18
- import path from 'path';
19
-
20
- import { query } from '@anthropic-ai/claude-agent-sdk';
21
-
22
- import {
23
- appendFilesInputTag,
24
- buildClaudeUserContent,
25
- normalizeImageDescriptors
26
- } from '@/shared/image-attachments.js';
27
- import { CLAUDE_PREDEFINED_MODELS } from '@/modules/providers/list/claude/claude-models.provider.js';
28
- import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
29
- import {
30
- createNotificationEvent,
31
- notifyBackgroundWorkCompleted,
32
- notifyRunFailed,
33
- notifyRunStopped,
34
- notifyUserIfEnabled
35
- } from '@/modules/notifications/index.js';
36
- import { createCompleteMessage, createNormalizedMessage } from '@/shared/utils.js';
37
-
38
- const activeSessions = new Map();
39
- const pendingToolApprovals = new Map();
40
- // Sessions cancelled via abort-session. The abort handler already sent the
41
- // terminal `complete` (aborted: true) to the client, so the run loop must not
42
- // emit a second one when its generator winds down.
43
- const abortedSessionIds = new Set();
44
- // Query instances interrupted because a newer run took over their session id
45
- // (see addSession). Their run loops must stay silent on wind-down: the map
46
- // entry, the abort flag, and all client-facing events belong to the new run.
47
- const supersededInstances = new WeakSet();
48
-
49
- const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
50
-
51
- // How long background work is allowed to keep running after a turn ends. This drives
52
- // two halves of the same behaviour:
53
- //
54
- // 1. Passed to the spawned CLI as CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, which is how
55
- // long it waits for still-running background *agents* before killing them.
56
- // 2. A backstop on how long we hold the SDK's stdin open after a turn's `result`.
57
- // The SDK closes stdin as soon as a turn ends, and the CLI reads that EOF as
58
- // "print wind-down" — killing background *shells* after a short grace period,
59
- // which the ceiling above does not cover. Holding stdin open also lets the CLI
60
- // push follow-up turns (background-task completions, Monitor notifications,
61
- // scheduled wake-ups).
62
- //
63
- // The hold normally ends long before this: a turn with nothing outstanding closes
64
- // stdin immediately, background work releases it as soon as it reports back, and a
65
- // new turn supersedes the previous hold. This ceiling only catches background work
66
- // that never reports at all, so an abandoned session cannot leak a CLI process
67
- // forever. The timer resets on every message, so it measures silence, not total time.
68
- const BG_WAIT_CEILING_MS = 30 * 60 * 1000;
69
-
70
- const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
71
-
72
- function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_PREDEFINED_MODELS) {
73
- const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
74
- const allowedEfforts = selectedModel?.effort?.values
75
- ?.map((value) => value.value) || [];
76
- return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
77
- ? effort
78
- : undefined;
79
- }
80
-
81
- function createRequestId() {
82
- if (typeof crypto.randomUUID === 'function') {
83
- return crypto.randomUUID();
84
- }
85
- return crypto.randomBytes(16).toString('hex');
86
- }
87
-
88
- function waitForToolApproval(requestId, options = {}) {
89
- const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
90
-
91
- return new Promise(resolve => {
92
- let settled = false;
93
-
94
- const finalize = (decision) => {
95
- if (settled) return;
96
- settled = true;
97
- cleanup();
98
- resolve(decision);
99
- };
100
-
101
- let timeout;
102
-
103
- const cleanup = () => {
104
- pendingToolApprovals.delete(requestId);
105
- if (timeout) clearTimeout(timeout);
106
- if (signal && abortHandler) {
107
- signal.removeEventListener('abort', abortHandler);
108
- }
109
- };
110
-
111
- // timeoutMs 0 = wait indefinitely (interactive tools)
112
- if (timeoutMs > 0) {
113
- timeout = setTimeout(() => {
114
- onCancel?.('timeout');
115
- finalize(null);
116
- }, timeoutMs);
117
- }
118
-
119
- const abortHandler = () => {
120
- onCancel?.('cancelled');
121
- finalize({ cancelled: true });
122
- };
123
-
124
- if (signal) {
125
- if (signal.aborted) {
126
- onCancel?.('cancelled');
127
- finalize({ cancelled: true });
128
- return;
129
- }
130
- signal.addEventListener('abort', abortHandler, { once: true });
131
- }
132
-
133
- const resolver = (decision) => {
134
- finalize(decision);
135
- };
136
- // Attach metadata for getPendingApprovalsForSession lookup
137
- if (metadata) {
138
- Object.assign(resolver, metadata);
139
- }
140
- pendingToolApprovals.set(requestId, resolver);
141
- });
142
- }
143
-
144
- function resolveToolApproval(requestId, decision) {
145
- const resolver = pendingToolApprovals.get(requestId);
146
- if (resolver) {
147
- resolver(decision);
148
- }
149
- }
150
-
151
- // Match stored permission entries against a tool + input combo.
152
- // This only supports exact tool names and the Bash(command:*) shorthand
153
- // used by the UI; it intentionally does not implement full glob semantics,
154
- // introduced to stay consistent with the UI's "Allow rule" format.
155
- function matchesToolPermission(entry, toolName, input) {
156
- if (!entry || !toolName) {
157
- return false;
158
- }
159
-
160
- if (entry === toolName) {
161
- return true;
162
- }
163
-
164
- const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
165
- if (toolName === 'Bash' && bashMatch) {
166
- const allowedPrefix = bashMatch[1];
167
- let command = '';
168
-
169
- if (typeof input === 'string') {
170
- command = input.trim();
171
- } else if (input && typeof input === 'object' && typeof input.command === 'string') {
172
- command = input.command.trim();
173
- }
174
-
175
- if (!command) {
176
- return false;
177
- }
178
-
179
- return command.startsWith(allowedPrefix);
180
- }
181
-
182
- return false;
183
- }
184
-
185
- function mapCliOptionsToSDK(options = {}) {
186
- const { providerSessionId, cwd, toolsSettings, permissionMode, effort } = options;
187
-
188
- const sdkOptions = {};
189
-
190
- // Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
191
- // Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
192
- sdkOptions.env = { ...process.env, CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: String(BG_WAIT_CEILING_MS) };
193
-
194
- // Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
195
- // which does not reliably follow npm's shell wrappers like cross-spawn does.
196
- sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
197
-
198
- if (cwd) {
199
- sdkOptions.cwd = cwd;
200
- }
201
-
202
- if (permissionMode && permissionMode !== 'default') {
203
- sdkOptions.permissionMode = permissionMode;
204
- }
205
-
206
- const settings = toolsSettings || {
207
- allowedTools: [],
208
- disallowedTools: [],
209
- skipPermissions: false
210
- };
211
-
212
- if (settings.skipPermissions && permissionMode !== 'plan') {
213
- sdkOptions.permissionMode = 'bypassPermissions';
214
- }
215
-
216
- let allowedTools = [...(settings.allowedTools || [])];
217
-
218
- if (permissionMode === 'plan') {
219
- const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
220
- for (const tool of planModeTools) {
221
- if (!allowedTools.includes(tool)) {
222
- allowedTools.push(tool);
223
- }
224
- }
225
- }
226
-
227
- sdkOptions.allowedTools = allowedTools;
228
-
229
- // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
230
- // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
231
- // but being explicit ensures forward compatibility and clarity.
232
- sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
233
-
234
- sdkOptions.disallowedTools = settings.disallowedTools || [];
235
-
236
- sdkOptions.model = options.model || CLAUDE_PREDEFINED_MODELS.DEFAULT;
237
-
238
- const resolvedEffort = resolveClaudeEffort(
239
- sdkOptions.model,
240
- effort,
241
- options.effortModels || CLAUDE_PREDEFINED_MODELS,
242
- );
243
- if (resolvedEffort) {
244
- sdkOptions.effort = resolvedEffort;
245
- }
246
-
247
- sdkOptions.systemPrompt = {
248
- type: 'preset',
249
- preset: 'claude_code'
250
- };
251
-
252
- sdkOptions.settingSources = ['project', 'user', 'local'];
253
-
254
- // The SDK resumes with the provider-native session id, never the app id.
255
- if (providerSessionId) {
256
- sdkOptions.resume = providerSessionId;
257
- }
258
-
259
- return sdkOptions;
260
- }
261
-
262
- /**
263
- * Adds a session to the active sessions map
264
- * @param {string} sessionId - Session identifier
265
- * @param {Object} queryInstance - SDK query instance
266
- * @param {Object} writer - WebSocket writer for reconnect support
267
- * @param {Function} releaseInput - Closes the held stdin stream so the CLI can exit
268
- */
269
- function addSession(sessionId, queryInstance, writer = null, releaseInput = null) {
270
- const existing = activeSessions.get(sessionId);
271
- // A different live instance under the same key means an earlier run was
272
- // superseded without being stopped (e.g. an abort that raced run setup and
273
- // found nothing to interrupt). Overwriting it here would strand its
274
- // generator forever — this map entry is the only handle for interrupting
275
- // it. Stop it directly rather than via abortClaudeSDKSession, whose
276
- // session-keyed abortedSessionIds flag would be consumed by the new run
277
- // and suppress its terminal `complete`.
278
- const superseding = Boolean(
279
- existing && existing.status === 'active' && existing.instance && existing.instance !== queryInstance
280
- );
281
- if (superseding) {
282
- supersededInstances.add(existing.instance);
283
- Promise.resolve()
284
- .then(() => existing.instance.interrupt())
285
- .catch((error) => {
286
- console.error(`Error interrupting superseded run for session ${sessionId}:`, error?.message || error);
287
- });
288
- existing.releaseInput?.();
289
- }
290
- const carried = superseding ? null : existing;
291
- activeSessions.set(sessionId, {
292
- instance: queryInstance,
293
- startTime: carried?.startTime || Date.now(),
294
- status: 'active',
295
- writer,
296
- // Re-registered mid-run once the provider session id lands; keep the closer.
297
- releaseInput: releaseInput || carried?.releaseInput || null
298
- });
299
- }
300
-
301
- /**
302
- * Removes a session from the active sessions map
303
- * @param {string} sessionId - Session identifier
304
- */
305
- function removeSession(sessionId) {
306
- activeSessions.delete(sessionId);
307
- }
308
-
309
- /**
310
- * Gets a session from the active sessions map
311
- * @param {string} sessionId - Session identifier
312
- * @returns {Object|undefined} Session data or undefined
313
- */
314
- function getSession(sessionId) {
315
- return activeSessions.get(sessionId);
316
- }
317
-
318
- /**
319
- * Gets all active session IDs
320
- * @returns {Array<string>} Array of active session IDs
321
- */
322
- function getAllSessions() {
323
- return Array.from(activeSessions.keys());
324
- }
325
-
326
- /**
327
- * Transforms SDK messages to WebSocket format expected by frontend
328
- * @param {Object} sdkMessage - SDK message object
329
- * @returns {Object} Transformed message ready for WebSocket
330
- */
331
- function transformMessage(sdkMessage) {
332
- // Extract parent_tool_use_id for subagent tool grouping
333
- if (sdkMessage.parent_tool_use_id) {
334
- return {
335
- ...sdkMessage,
336
- parentToolUseId: sdkMessage.parent_tool_use_id
337
- };
338
- }
339
- return sdkMessage;
340
- }
341
-
342
- function readNumber(value) {
343
- const parsed = Number(value);
344
- return Number.isFinite(parsed) ? parsed : 0;
345
- }
346
-
347
- /**
348
- * Extracts token usage from SDK messages.
349
- * Prefers per-step `message.usage` (Claude message payload), then falls back
350
- * to result-level usage/modelUsage for compatibility across SDK versions.
351
- * @param {Object} sdkMessage - SDK stream message
352
- * @returns {Object|null} Token budget object or null
353
- */
354
- function extractTokenBudget(sdkMessage) {
355
- if (!sdkMessage || typeof sdkMessage !== 'object') {
356
- return null;
357
- }
358
-
359
- const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
360
- if (messageUsage && typeof messageUsage === 'object') {
361
- const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
362
- const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
363
- const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
364
- const cacheTokens = cacheCreationTokens + cacheReadTokens;
365
- const inputTokens = directInputTokens + cacheTokens;
366
- const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
367
- const totalUsed = inputTokens + outputTokens;
368
- const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
369
-
370
- return {
371
- used: totalUsed,
372
- total: contextWindow,
373
- inputTokens,
374
- outputTokens,
375
- cacheReadTokens,
376
- cacheCreationTokens,
377
- cacheTokens,
378
- breakdown: {
379
- input: inputTokens,
380
- output: outputTokens,
381
- },
382
- };
383
- }
384
-
385
- if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
386
- return null;
387
- }
388
-
389
- // Fallback for older SDK messages with only modelUsage
390
- const modelKey = Object.keys(sdkMessage.modelUsage)[0];
391
- const modelData = sdkMessage.modelUsage[modelKey];
392
-
393
- if (!modelData || typeof modelData !== 'object') {
394
- return null;
395
- }
396
-
397
- const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
398
- const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
399
- const totalUsed = inputTokens + outputTokens;
400
- const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
401
-
402
- return {
403
- used: totalUsed,
404
- total: contextWindow,
405
- inputTokens,
406
- outputTokens,
407
- breakdown: {
408
- input: inputTokens,
409
- output: outputTokens,
410
- },
411
- };
412
- }
413
-
414
- // Tool calls that leave work running past the end of a turn. Bash only counts
415
- // when it is explicitly backgrounded; the rest defer or watch work by nature.
416
- const DEFERRED_WORK_TOOLS = new Set(['Monitor', 'ScheduleWakeup', 'CronCreate', 'TaskCreate']);
417
-
418
- /**
419
- * Detects tool calls that keep working after the turn's `result` arrives.
420
- *
421
- * Only turns that start background work need their CLI process held open; every
422
- * other turn can let it exit immediately, as it did before the hold existed.
423
- *
424
- * @param {Object} sdkMessage - SDK stream message
425
- * @returns {boolean} True when the message launches work that outlives the turn
426
- */
427
- function startsBackgroundWork(sdkMessage) {
428
- const content = sdkMessage?.message?.content;
429
- if (!Array.isArray(content)) {
430
- return false;
431
- }
432
-
433
- return content.some((block) => {
434
- if (block?.type !== 'tool_use') {
435
- return false;
436
- }
437
- if (block.name === 'Bash') {
438
- return block.input?.run_in_background === true;
439
- }
440
- return DEFERRED_WORK_TOOLS.has(block.name);
441
- });
442
- }
443
-
444
- /**
445
- * Builds the SDK user messages for one turn.
446
- *
447
- * Always returns SDKUserMessage records rather than a bare string: a string
448
- * prompt makes the SDK flag the query as single-turn and close stdin the moment
449
- * the turn's `result` arrives, which kills the CLI's background tasks. Plain
450
- * text turns carry string content; turns with image attachments carry the
451
- * prompt text plus one base64 `image` block per attachment (read from the
452
- * global `~/.cloudcli/assets` folder).
453
- *
454
- * @param {string} command - User prompt
455
- * @param {Array} images - Image descriptors ({ path, name?, mimeType? })
456
- * @param {Array} files - Non-image attachment descriptors
457
- * @param {string} cwd - Project working directory attachment paths resolve against
458
- * @returns {Promise<Array<Object>>} SDKUserMessage records for the turn
459
- */
460
- async function buildPromptMessages(command, images, files, cwd) {
461
- const promptWithFiles = appendFilesInputTag(command, files);
462
- const content = normalizeImageDescriptors(images).length === 0
463
- ? promptWithFiles
464
- : await buildClaudeUserContent(promptWithFiles, images, cwd);
465
-
466
- return [{
467
- type: 'user',
468
- message: {
469
- role: 'user',
470
- content
471
- },
472
- parent_tool_use_id: null,
473
- timestamp: new Date().toISOString()
474
- }];
475
- }
476
-
477
- /**
478
- * Wraps prompt messages in an async iterable that yields them and then parks.
479
- *
480
- * The SDK closes the CLI's stdin as soon as its input iterable is exhausted (and
481
- * immediately on `result` for string prompts). The CLI reads that EOF as the end
482
- * of the run and kills anything still going in the background, so the iterable
483
- * has to stay pending until we actually want the process gone.
484
- *
485
- * @param {Array<Object>} messages - SDKUserMessage records to send
486
- * @returns {{ stream: AsyncIterable, release: () => void }} Stream plus its closer
487
- */
488
- function createHeldPromptStream(messages) {
489
- let release;
490
- const held = new Promise((resolve) => { release = resolve; });
491
-
492
- const stream = (async function* () {
493
- for (const message of messages) {
494
- yield message;
495
- }
496
- // Keeps stdin open — the CLI stays alive until release() is called.
497
- await held;
498
- })();
499
-
500
- return { stream, release };
501
- }
502
-
503
- /**
504
- * Loads MCP server configurations from ~/.claude.json
505
- * @param {string} cwd - Current working directory for project-specific configs
506
- * @returns {Object|null} MCP servers object or null if none found
507
- */
508
- async function loadMcpConfig(cwd) {
509
- try {
510
- const claudeConfigPath = path.join(os.homedir(), '.claude.json');
511
-
512
- // Check if config file exists
513
- try {
514
- await fs.access(claudeConfigPath);
515
- } catch (error) {
516
- // File doesn't exist, return null
517
- // No config file
518
- return null;
519
- }
520
-
521
- // Read and parse config file
522
- let claudeConfig;
523
- try {
524
- const configContent = await fs.readFile(claudeConfigPath, 'utf8');
525
- claudeConfig = JSON.parse(configContent);
526
- } catch (error) {
527
- console.error('Failed to parse ~/.claude.json:', error.message);
528
- return null;
529
- }
530
-
531
- // Extract MCP servers (merge global and project-specific)
532
- let mcpServers = {};
533
-
534
- // Add global MCP servers
535
- if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
536
- mcpServers = { ...claudeConfig.mcpServers };
537
- // Global MCP servers loaded
538
- }
539
-
540
- // Add/override with project-specific MCP servers
541
- if (claudeConfig.claudeProjects && cwd) {
542
- const projectConfig = claudeConfig.claudeProjects[cwd];
543
- if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
544
- mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
545
- // Project MCP servers merged
546
- }
547
- }
548
-
549
- // Return null if no servers found
550
- if (Object.keys(mcpServers).length === 0) {
551
- return null;
552
- }
553
- return mcpServers;
554
- } catch (error) {
555
- console.error('Error loading MCP config:', error.message);
556
- return null;
557
- }
558
- }
559
-
560
- /**
561
- * Executes a Claude query using the SDK
562
- * @param {string} command - User prompt/command
563
- * @param {Object} options - Query options
564
- * @param {Object} ws - WebSocket connection
565
- * @param {Object} context - Provider-scoped model, session, and auth lookups
566
- * @returns {Promise<void>}
567
- */
568
- async function queryClaudeSDK(command, options = {}, ws, context) {
569
- const { sessionId, sessionSummary } = options;
570
- // Callers pass the stable app session id; the SDK only understands the
571
- // provider-native id recorded on the session row.
572
- const providerSessionId = context.resolveProviderSessionId(sessionId);
573
- // Provider-native id as the SDK reports it (starts as the resume id, or is
574
- // captured from the stream for brand-new sessions).
575
- let capturedSessionId = providerSessionId;
576
- let sessionCreatedSent = false;
577
- // Process-map key: the app session id when the caller supplied one, else
578
- // the provider-native id once captured (legacy/direct API callers).
579
- const sessionKey = () => sessionId || capturedSessionId || null;
580
-
581
- const emitNotification = (event) => {
582
- notifyUserIfEnabled({
583
- userId: ws?.userId || null,
584
- writer: ws,
585
- event
586
- });
587
- };
588
-
589
- // Closes the held stdin stream so the CLI can wind down. Replaced once the
590
- // stream exists; the finally block calls it no matter how the run ends.
591
- let releasePromptStream = () => {};
592
- let idleReleaseTimer = null;
593
- // The client is told the turn is over as soon as `result` lands, even though
594
- // the process lingers, so the UI never waits out the idle hold.
595
- let turnCompleteSent = false;
596
- // Set when a turn starts background work, cleared when the next `result`
597
- // arrives — only turns with work still outstanding hold their process open.
598
- let backgroundWorkPending = false;
599
- // True while the process is being held open for background work, so a later
600
- // `result` can be recognised as that work reporting back.
601
- let heldForBackgroundWork = false;
602
-
603
- // A new turn supersedes any earlier one still holding this session's process
604
- // open, so held runs cannot stack up across a conversation.
605
- if (sessionKey()) {
606
- getSession(sessionKey())?.releaseInput?.();
607
- }
608
-
609
- // Arms (or re-arms) the idle countdown that eventually closes stdin.
610
- const scheduleRelease = () => {
611
- if (idleReleaseTimer) {
612
- clearTimeout(idleReleaseTimer);
613
- idleReleaseTimer = null;
614
- }
615
- idleReleaseTimer = setTimeout(() => {
616
- idleReleaseTimer = null;
617
- releasePromptStream();
618
- }, BG_WAIT_CEILING_MS);
619
- // Never let the hold keep the server process alive on its own.
620
- idleReleaseTimer.unref?.();
621
- };
622
-
623
- // Hoisted above the try so the catch's cleanup can tell whether this run
624
- // still owns the activeSessions entry (or was superseded by a newer run).
625
- let queryInstance = null;
626
-
627
- try {
628
- const resolvedModel = await context.resolveResumeModel(sessionId, options.model);
629
- let effortModels = CLAUDE_PREDEFINED_MODELS;
630
- try {
631
- effortModels = await context.getProviderModels();
632
- } catch (error) {
633
- console.warn('[Claude SDK] Unable to load provider models for effort validation:', error);
634
- }
635
-
636
- const sdkOptions = mapCliOptionsToSDK({
637
- ...options,
638
- providerSessionId,
639
- model: resolvedModel || options.model,
640
- effortModels,
641
- });
642
-
643
- const mcpServers = await loadMcpConfig(options.cwd);
644
- if (mcpServers) {
645
- sdkOptions.mcpServers = mcpServers;
646
- }
647
-
648
- // Every turn uses streaming input so stdin stays open past the turn's
649
- // `result`. The message list is reusable, but each query attempt needs its
650
- // own stream because an async generator cannot be replayed once consumed.
651
- const promptMessages = await buildPromptMessages(command, options.images, options.files, options.cwd);
652
-
653
- sdkOptions.hooks = {
654
- Notification: [{
655
- matcher: '',
656
- hooks: [async (input) => {
657
- const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
658
- // Notifications are app-facing, so they carry the app session id.
659
- emitNotification(createNotificationEvent({
660
- provider: 'claude',
661
- sessionId: sessionId || capturedSessionId || null,
662
- kind: 'action_required',
663
- code: 'agent.notification',
664
- meta: { message, sessionName: sessionSummary },
665
- severity: 'warning',
666
- requiresUserAction: true,
667
- dedupeKey: `claude:hook:notification:${sessionId || capturedSessionId || 'none'}:${message}`
668
- }));
669
- return {};
670
- }]
671
- }]
672
- };
673
-
674
- // Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
675
- // at the permission-mode step and skips this callback, so interactive tools
676
- // (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
677
- // auto-approves them and the model acts on a generated answer. Move these
678
- // tools to a PreToolUse hook (runs before the mode check) if we need them
679
- // to work in those modes.
680
- sdkOptions.canUseTool = async (toolName, input, context) => {
681
- const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
682
-
683
- if (!requiresInteraction) {
684
- if (sdkOptions.permissionMode === 'bypassPermissions') {
685
- return { behavior: 'allow', updatedInput: input };
686
- }
687
-
688
- const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
689
- matchesToolPermission(entry, toolName, input)
690
- );
691
- if (isDisallowed) {
692
- return { behavior: 'deny', message: 'Tool disallowed by settings' };
693
- }
694
-
695
- const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
696
- matchesToolPermission(entry, toolName, input)
697
- );
698
- if (isAllowed) {
699
- return { behavior: 'allow', updatedInput: input };
700
- }
701
- }
702
-
703
- const requestId = createRequestId();
704
- ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
705
- emitNotification(createNotificationEvent({
706
- provider: 'claude',
707
- sessionId: sessionId || capturedSessionId || null,
708
- kind: 'action_required',
709
- code: 'permission.required',
710
- meta: { toolName, sessionName: sessionSummary },
711
- severity: 'warning',
712
- requiresUserAction: true,
713
- dedupeKey: `claude:permission:${sessionId || capturedSessionId || 'none'}:${requestId}`
714
- }));
715
-
716
- const decision = await waitForToolApproval(requestId, {
717
- timeoutMs: requiresInteraction ? 0 : undefined,
718
- signal: context?.signal,
719
- metadata: {
720
- // Keyed by the app session id so `chat.subscribe` can look pending
721
- // approvals up directly; provider id only for legacy callers.
722
- _sessionId: sessionId || capturedSessionId || null,
723
- _toolName: toolName,
724
- _input: input,
725
- _receivedAt: new Date(),
726
- },
727
- onCancel: (reason) => {
728
- ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
729
- }
730
- });
731
- if (!decision) {
732
- return { behavior: 'deny', message: 'Permission request timed out' };
733
- }
734
-
735
- if (decision.cancelled) {
736
- return { behavior: 'deny', message: 'Permission request cancelled' };
737
- }
738
-
739
- if (decision.allow) {
740
- if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
741
- if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
742
- sdkOptions.allowedTools.push(decision.rememberEntry);
743
- }
744
- if (Array.isArray(sdkOptions.disallowedTools)) {
745
- sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
746
- }
747
- }
748
- return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
749
- }
750
-
751
- return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
752
- };
753
-
754
- let heldPrompt = createHeldPromptStream(promptMessages);
755
- releasePromptStream = heldPrompt.release;
756
- try {
757
- queryInstance = query({
758
- prompt: heldPrompt.stream,
759
- options: sdkOptions
760
- });
761
- } catch (hookError) {
762
- // Older/newer SDK versions may not accept hook shapes yet.
763
- // Keep notification behavior operational via runtime events even if hook registration fails.
764
- console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
765
- delete sdkOptions.hooks;
766
- // Discard the abandoned stream and build a fresh one for the retry.
767
- heldPrompt.release();
768
- heldPrompt = createHeldPromptStream(promptMessages);
769
- releasePromptStream = heldPrompt.release;
770
- queryInstance = query({
771
- prompt: heldPrompt.stream,
772
- options: sdkOptions
773
- });
774
- }
775
-
776
- // Track the query instance for abort capability
777
- if (sessionKey()) {
778
- addSession(sessionKey(), queryInstance, ws, releasePromptStream);
779
- }
780
-
781
- // Process streaming messages
782
- console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
783
- for await (const message of queryInstance) {
784
- // Capture session ID from first message
785
- if (message.session_id && !capturedSessionId) {
786
-
787
- capturedSessionId = message.session_id;
788
- addSession(sessionKey(), queryInstance, ws, releasePromptStream);
789
-
790
- // Set session ID on writer
791
- if (ws.setSessionId && typeof ws.setSessionId === 'function') {
792
- ws.setSessionId(capturedSessionId);
793
- }
794
-
795
- // Send session-created event only once for sessions with nothing to resume
796
- if (!providerSessionId && !sessionCreatedSent) {
797
- sessionCreatedSent = true;
798
- ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
799
- }
800
- } else {
801
- // session_id already captured
802
- }
803
-
804
- // Transform and normalize message via adapter
805
- const transformedMessage = transformMessage(message);
806
- const sid = capturedSessionId || sessionId || null;
807
-
808
- // Use adapter to normalize SDK events into NormalizedMessage[]
809
- const normalized = context.normalizeMessage(transformedMessage, sid);
810
- for (const msg of normalized) {
811
- // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
812
- if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
813
- msg.parentToolUseId = transformedMessage.parentToolUseId;
814
- }
815
- ws.send(msg);
816
- }
817
-
818
- // Extract and send token budget updates from assistant/result usage payloads
819
- const tokenBudgetData = extractTokenBudget(message);
820
- if (tokenBudgetData) {
821
- ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
822
- }
823
-
824
- if (startsBackgroundWork(message)) {
825
- backgroundWorkPending = true;
826
- }
827
-
828
- if (message.type === 'result') {
829
- // The turn is done as far as the client is concerned.
830
- const abortPending = sessionKey() ? abortedSessionIds.has(sessionKey()) : false;
831
- if (!turnCompleteSent && !abortPending) {
832
- turnCompleteSent = true;
833
- ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
834
- notifyRunStopped({
835
- userId: ws?.userId || null,
836
- provider: 'claude',
837
- sessionId: sessionId || capturedSessionId || null,
838
- sessionName: sessionSummary,
839
- stopReason: 'completed'
840
- });
841
- } else if (heldForBackgroundWork && !abortPending) {
842
- // A result after the turn already reported complete means the work we
843
- // held the process open for has finished and pushed a follow-up turn.
844
- notifyBackgroundWorkCompleted({
845
- userId: ws?.userId || null,
846
- provider: 'claude',
847
- sessionId: sessionId || capturedSessionId || null,
848
- sessionName: sessionSummary
849
- });
850
- }
851
- if (backgroundWorkPending) {
852
- // Work started during this turn is still running. Hold the process
853
- // open so it can finish and report back in a follow-up turn; the
854
- // ceiling is only a backstop for work that never reports.
855
- backgroundWorkPending = false;
856
- heldForBackgroundWork = true;
857
- scheduleRelease();
858
- } else {
859
- // Either nothing was backgrounded, or the background work just
860
- // reported in — let the CLI exit now, as it always has.
861
- heldForBackgroundWork = false;
862
- releasePromptStream();
863
- }
864
- } else if (idleReleaseTimer) {
865
- // Background activity after the turn — push the countdown back out.
866
- scheduleRelease();
867
- }
868
- }
869
-
870
- // Clean up session on completion — only while this run still owns the map
871
- // entry. A superseding run may have replaced it, and deleting here would
872
- // strand that run.
873
- if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
874
- removeSession(sessionKey());
875
- }
876
-
877
- // A superseded run winds down silently: the map entry, the abort flag,
878
- // and all client-facing events belong to the run that replaced it.
879
- const superseded = supersededInstances.has(queryInstance);
880
-
881
- // Send the terminal completion event — skipped for aborted runs, whose
882
- // terminal `complete` (aborted: true) was already sent by abort-session, and
883
- // for runs that already reported completion when their `result` arrived.
884
- const wasAborted = !superseded && sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
885
- if (!turnCompleteSent && !superseded) {
886
- turnCompleteSent = true;
887
- if (!wasAborted) {
888
- ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
889
- }
890
- notifyRunStopped({
891
- userId: ws?.userId || null,
892
- provider: 'claude',
893
- sessionId: sessionId || capturedSessionId || null,
894
- sessionName: sessionSummary,
895
- stopReason: wasAborted ? 'aborted' : 'completed'
896
- });
897
- }
898
- // Complete
899
-
900
- } catch (error) {
901
- console.error('SDK query error:', error);
902
-
903
- // Clean up session on error — only while this run still owns the map entry
904
- // (a superseding run may have replaced it).
905
- if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
906
- removeSession(sessionKey());
907
- }
908
-
909
- if (supersededInstances.has(queryInstance)) {
910
- // Interrupted because a newer run took over this session id; that run
911
- // owns the abort flag and all further client-facing events.
912
- return;
913
- }
914
-
915
- const wasAborted = sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
916
- if (wasAborted) {
917
- // The abort already produced the terminal complete; a generator throw
918
- // caused by interrupt() is expected noise, not a user-facing error.
919
- return;
920
- }
921
-
922
- // Check if Claude CLI is installed for a clearer error message
923
- const installed = await context.isProviderInstalled();
924
- const errorContent = !installed
925
- ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
926
- : error.message;
927
-
928
- // Send error to WebSocket, then the terminal complete. A run that already
929
- // reported completion and then failed during its post-turn hold still
930
- // surfaces the error, but must not emit a second terminal complete.
931
- ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
932
- if (!turnCompleteSent) {
933
- ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
934
- }
935
- notifyRunFailed({
936
- userId: ws?.userId || null,
937
- provider: 'claude',
938
- sessionId: sessionId || capturedSessionId || null,
939
- sessionName: sessionSummary,
940
- error
941
- });
942
- } finally {
943
- // Always close stdin — otherwise an aborted or failed run leaves the CLI
944
- // process (and its MCP servers) alive until the server exits.
945
- if (idleReleaseTimer) {
946
- clearTimeout(idleReleaseTimer);
947
- idleReleaseTimer = null;
948
- }
949
- releasePromptStream();
950
- }
951
- }
952
-
953
- /**
954
- * Aborts an active SDK session
955
- * @param {string} sessionId - Session identifier
956
- * @returns {boolean} True if session was aborted, false if not found
957
- */
958
- async function abortClaudeSDKSession(sessionId) {
959
- const session = getSession(sessionId);
960
-
961
- if (!session) {
962
- console.log(`Session ${sessionId} not found`);
963
- return false;
964
- }
965
-
966
- try {
967
- console.log(`Aborting SDK session: ${sessionId}`);
968
-
969
- // Mark before interrupting so the run loop knows not to emit its own
970
- // terminal complete (the abort handler sends the aborted one).
971
- abortedSessionIds.add(sessionId);
972
-
973
- // Call interrupt() on the query instance
974
- await session.instance.interrupt();
975
-
976
- // Release the held stdin stream; without this the CLI stays up for the rest
977
- // of the post-turn hold even though the user cancelled.
978
- session.releaseInput?.();
979
-
980
- // Update session status
981
- session.status = 'aborted';
982
-
983
- // Clean up session
984
- removeSession(sessionId);
985
-
986
- return true;
987
- } catch (error) {
988
- console.error(`Error aborting session ${sessionId}:`, error);
989
- // The run keeps going; let it emit its own terminal complete.
990
- abortedSessionIds.delete(sessionId);
991
- return false;
992
- }
993
- }
994
-
995
- /**
996
- * Checks if an SDK session is currently active
997
- * @param {string} sessionId - Session identifier
998
- * @returns {boolean} True if session is active
999
- */
1000
- function isClaudeSDKSessionActive(sessionId) {
1001
- const session = getSession(sessionId);
1002
- return session && session.status === 'active';
1003
- }
1004
-
1005
- /**
1006
- * Gets all active SDK session IDs
1007
- * @returns {Array<string>} Array of active session IDs
1008
- */
1009
- function getActiveClaudeSDKSessions() {
1010
- return getAllSessions();
1011
- }
1012
-
1013
- /**
1014
- * Get pending tool approvals for a specific session.
1015
- * @param {string} sessionId - The session ID
1016
- * @returns {Array} Array of pending permission request objects
1017
- */
1018
- function getPendingApprovalsForSession(sessionId) {
1019
- const pending = [];
1020
- for (const [requestId, resolver] of pendingToolApprovals.entries()) {
1021
- if (resolver._sessionId === sessionId) {
1022
- pending.push({
1023
- requestId,
1024
- toolName: resolver._toolName || 'UnknownTool',
1025
- input: resolver._input,
1026
- context: resolver._context,
1027
- sessionId,
1028
- receivedAt: resolver._receivedAt || new Date(),
1029
- });
1030
- }
1031
- }
1032
- return pending;
1033
- }
1034
-
1035
- /**
1036
- * Reconnect a session's WebSocketWriter to a new raw WebSocket.
1037
- * Called when client reconnects (e.g. page refresh) while SDK is still running.
1038
- * @param {string} sessionId - The session ID
1039
- * @param {Object} newRawWs - The new raw WebSocket connection
1040
- * @returns {boolean} True if writer was successfully reconnected
1041
- */
1042
- function reconnectSessionWriter(sessionId, newRawWs) {
1043
- const session = getSession(sessionId);
1044
- if (!session?.writer?.updateWebSocket) return false;
1045
- session.writer.updateWebSocket(newRawWs);
1046
- console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
1047
- return true;
1048
- }
1049
-
1050
- export const claudeRuntime = {
1051
- run: queryClaudeSDK,
1052
- abort: abortClaudeSDKSession,
1053
- permissions: {
1054
- resolve: resolveToolApproval,
1055
- listPending: getPendingApprovalsForSession,
1056
- },
1057
- };
1058
-
1059
- // Export public API
1060
- export {
1061
- queryClaudeSDK,
1062
- abortClaudeSDKSession,
1063
- isClaudeSDKSessionActive,
1064
- getActiveClaudeSDKSessions,
1065
- resolveToolApproval,
1066
- getPendingApprovalsForSession,
1067
- reconnectSessionWriter
1068
- };
1
+ /**
2
+ * Claude SDK Integration
3
+ *
4
+ * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
+ * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
+ * and maintainability.
7
+ *
8
+ * Key features:
9
+ * - Direct SDK integration without child processes
10
+ * - Session management with abort capability
11
+ * - Options mapping between CLI and SDK formats
12
+ * - WebSocket message streaming
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+ import { promises as fs } from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+
20
+ import { query } from '@anthropic-ai/claude-agent-sdk';
21
+
22
+ import {
23
+ appendFilesInputTag,
24
+ buildClaudeUserContent,
25
+ normalizeImageDescriptors
26
+ } from '@/shared/image-attachments.js';
27
+ import { CLAUDE_PREDEFINED_MODELS } from '@/modules/providers/list/claude/claude-models.provider.js';
28
+ import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
29
+ import {
30
+ createNotificationEvent,
31
+ notifyBackgroundWorkCompleted,
32
+ notifyRunFailed,
33
+ notifyRunStopped,
34
+ notifyUserIfEnabled
35
+ } from '@/modules/notifications/index.js';
36
+ import { createCompleteMessage, createNormalizedMessage } from '@/shared/utils.js';
37
+
38
+ const activeSessions = new Map();
39
+ const pendingToolApprovals = new Map();
40
+ // Sessions cancelled via abort-session. The abort handler already sent the
41
+ // terminal `complete` (aborted: true) to the client, so the run loop must not
42
+ // emit a second one when its generator winds down.
43
+ const abortedSessionIds = new Set();
44
+ // Query instances interrupted because a newer run took over their session id
45
+ // (see addSession). Their run loops must stay silent on wind-down: the map
46
+ // entry, the abort flag, and all client-facing events belong to the new run.
47
+ const supersededInstances = new WeakSet();
48
+
49
+ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
50
+
51
+ // How long background work is allowed to keep running after a turn ends. This drives
52
+ // two halves of the same behaviour:
53
+ //
54
+ // 1. Passed to the spawned CLI as CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, which is how
55
+ // long it waits for still-running background *agents* before killing them.
56
+ // 2. A backstop on how long we hold the SDK's stdin open after a turn's `result`.
57
+ // The SDK closes stdin as soon as a turn ends, and the CLI reads that EOF as
58
+ // "print wind-down" — killing background *shells* after a short grace period,
59
+ // which the ceiling above does not cover. Holding stdin open also lets the CLI
60
+ // push follow-up turns (background-task completions, Monitor notifications,
61
+ // scheduled wake-ups).
62
+ //
63
+ // The hold normally ends long before this: a turn with nothing outstanding closes
64
+ // stdin immediately, background work releases it as soon as it reports back, and a
65
+ // new turn supersedes the previous hold. This ceiling only catches background work
66
+ // that never reports at all, so an abandoned session cannot leak a CLI process
67
+ // forever. The timer resets on every message, so it measures silence, not total time.
68
+ const BG_WAIT_CEILING_MS = 30 * 60 * 1000;
69
+
70
+ const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
71
+
72
+ function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_PREDEFINED_MODELS) {
73
+ const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
74
+ const allowedEfforts = selectedModel?.effort?.values
75
+ ?.map((value) => value.value) || [];
76
+ return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
77
+ ? effort
78
+ : undefined;
79
+ }
80
+
81
+ function createRequestId() {
82
+ if (typeof crypto.randomUUID === 'function') {
83
+ return crypto.randomUUID();
84
+ }
85
+ return crypto.randomBytes(16).toString('hex');
86
+ }
87
+
88
+ function waitForToolApproval(requestId, options = {}) {
89
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
90
+
91
+ return new Promise(resolve => {
92
+ let settled = false;
93
+
94
+ const finalize = (decision) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ cleanup();
98
+ resolve(decision);
99
+ };
100
+
101
+ let timeout;
102
+
103
+ const cleanup = () => {
104
+ pendingToolApprovals.delete(requestId);
105
+ if (timeout) clearTimeout(timeout);
106
+ if (signal && abortHandler) {
107
+ signal.removeEventListener('abort', abortHandler);
108
+ }
109
+ };
110
+
111
+ // timeoutMs 0 = wait indefinitely (interactive tools)
112
+ if (timeoutMs > 0) {
113
+ timeout = setTimeout(() => {
114
+ onCancel?.('timeout');
115
+ finalize(null);
116
+ }, timeoutMs);
117
+ }
118
+
119
+ const abortHandler = () => {
120
+ onCancel?.('cancelled');
121
+ finalize({ cancelled: true });
122
+ };
123
+
124
+ if (signal) {
125
+ if (signal.aborted) {
126
+ onCancel?.('cancelled');
127
+ finalize({ cancelled: true });
128
+ return;
129
+ }
130
+ signal.addEventListener('abort', abortHandler, { once: true });
131
+ }
132
+
133
+ const resolver = (decision) => {
134
+ finalize(decision);
135
+ };
136
+ // Attach metadata for getPendingApprovalsForSession lookup
137
+ if (metadata) {
138
+ Object.assign(resolver, metadata);
139
+ }
140
+ pendingToolApprovals.set(requestId, resolver);
141
+ });
142
+ }
143
+
144
+ function resolveToolApproval(requestId, decision) {
145
+ const resolver = pendingToolApprovals.get(requestId);
146
+ if (resolver) {
147
+ resolver(decision);
148
+ }
149
+ }
150
+
151
+ // Match stored permission entries against a tool + input combo.
152
+ // This only supports exact tool names and the Bash(command:*) shorthand
153
+ // used by the UI; it intentionally does not implement full glob semantics,
154
+ // introduced to stay consistent with the UI's "Allow rule" format.
155
+ function matchesToolPermission(entry, toolName, input) {
156
+ if (!entry || !toolName) {
157
+ return false;
158
+ }
159
+
160
+ if (entry === toolName) {
161
+ return true;
162
+ }
163
+
164
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
165
+ if (toolName === 'Bash' && bashMatch) {
166
+ const allowedPrefix = bashMatch[1];
167
+ let command = '';
168
+
169
+ if (typeof input === 'string') {
170
+ command = input.trim();
171
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
172
+ command = input.command.trim();
173
+ }
174
+
175
+ if (!command) {
176
+ return false;
177
+ }
178
+
179
+ return command.startsWith(allowedPrefix);
180
+ }
181
+
182
+ return false;
183
+ }
184
+
185
+ function mapCliOptionsToSDK(options = {}) {
186
+ const { providerSessionId, cwd, toolsSettings, permissionMode, effort } = options;
187
+
188
+ const sdkOptions = {};
189
+
190
+ // Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
191
+ // Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
192
+ sdkOptions.env = { ...process.env, CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: String(BG_WAIT_CEILING_MS) };
193
+
194
+ // Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
195
+ // which does not reliably follow npm's shell wrappers like cross-spawn does.
196
+ sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
197
+
198
+ if (cwd) {
199
+ sdkOptions.cwd = cwd;
200
+ }
201
+
202
+ if (permissionMode && permissionMode !== 'default') {
203
+ sdkOptions.permissionMode = permissionMode;
204
+ }
205
+
206
+ const settings = toolsSettings || {
207
+ allowedTools: [],
208
+ disallowedTools: [],
209
+ skipPermissions: false
210
+ };
211
+
212
+ if (settings.skipPermissions && permissionMode !== 'plan') {
213
+ sdkOptions.permissionMode = 'bypassPermissions';
214
+ }
215
+
216
+ let allowedTools = [...(settings.allowedTools || [])];
217
+
218
+ if (permissionMode === 'plan') {
219
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
220
+ for (const tool of planModeTools) {
221
+ if (!allowedTools.includes(tool)) {
222
+ allowedTools.push(tool);
223
+ }
224
+ }
225
+ }
226
+
227
+ sdkOptions.allowedTools = allowedTools;
228
+
229
+ // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
230
+ // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
231
+ // but being explicit ensures forward compatibility and clarity.
232
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
233
+
234
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
235
+
236
+ sdkOptions.model = options.model || CLAUDE_PREDEFINED_MODELS.DEFAULT;
237
+
238
+ const resolvedEffort = resolveClaudeEffort(
239
+ sdkOptions.model,
240
+ effort,
241
+ options.effortModels || CLAUDE_PREDEFINED_MODELS,
242
+ );
243
+ if (resolvedEffort) {
244
+ sdkOptions.effort = resolvedEffort;
245
+ }
246
+
247
+ sdkOptions.systemPrompt = {
248
+ type: 'preset',
249
+ preset: 'claude_code'
250
+ };
251
+
252
+ sdkOptions.settingSources = ['project', 'user', 'local'];
253
+
254
+ // The SDK resumes with the provider-native session id, never the app id.
255
+ if (providerSessionId) {
256
+ sdkOptions.resume = providerSessionId;
257
+ }
258
+
259
+ return sdkOptions;
260
+ }
261
+
262
+ /**
263
+ * Adds a session to the active sessions map
264
+ * @param {string} sessionId - Session identifier
265
+ * @param {Object} queryInstance - SDK query instance
266
+ * @param {Object} writer - WebSocket writer for reconnect support
267
+ * @param {Function} releaseInput - Closes the held stdin stream so the CLI can exit
268
+ */
269
+ function addSession(sessionId, queryInstance, writer = null, releaseInput = null) {
270
+ const existing = activeSessions.get(sessionId);
271
+ // A different live instance under the same key means an earlier run was
272
+ // superseded without being stopped (e.g. an abort that raced run setup and
273
+ // found nothing to interrupt). Overwriting it here would strand its
274
+ // generator forever — this map entry is the only handle for interrupting
275
+ // it. Stop it directly rather than via abortClaudeSDKSession, whose
276
+ // session-keyed abortedSessionIds flag would be consumed by the new run
277
+ // and suppress its terminal `complete`.
278
+ const superseding = Boolean(
279
+ existing && existing.status === 'active' && existing.instance && existing.instance !== queryInstance
280
+ );
281
+ if (superseding) {
282
+ supersededInstances.add(existing.instance);
283
+ Promise.resolve()
284
+ .then(() => existing.instance.interrupt())
285
+ .catch((error) => {
286
+ console.error(`Error interrupting superseded run for session ${sessionId}:`, error?.message || error);
287
+ });
288
+ existing.releaseInput?.();
289
+ }
290
+ const carried = superseding ? null : existing;
291
+ activeSessions.set(sessionId, {
292
+ instance: queryInstance,
293
+ startTime: carried?.startTime || Date.now(),
294
+ status: 'active',
295
+ writer,
296
+ // Re-registered mid-run once the provider session id lands; keep the closer.
297
+ releaseInput: releaseInput || carried?.releaseInput || null
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Removes a session from the active sessions map
303
+ * @param {string} sessionId - Session identifier
304
+ */
305
+ function removeSession(sessionId) {
306
+ activeSessions.delete(sessionId);
307
+ }
308
+
309
+ /**
310
+ * Gets a session from the active sessions map
311
+ * @param {string} sessionId - Session identifier
312
+ * @returns {Object|undefined} Session data or undefined
313
+ */
314
+ function getSession(sessionId) {
315
+ return activeSessions.get(sessionId);
316
+ }
317
+
318
+ /**
319
+ * Gets all active session IDs
320
+ * @returns {Array<string>} Array of active session IDs
321
+ */
322
+ function getAllSessions() {
323
+ return Array.from(activeSessions.keys());
324
+ }
325
+
326
+ /**
327
+ * Transforms SDK messages to WebSocket format expected by frontend
328
+ * @param {Object} sdkMessage - SDK message object
329
+ * @returns {Object} Transformed message ready for WebSocket
330
+ */
331
+ function transformMessage(sdkMessage) {
332
+ // Extract parent_tool_use_id for subagent tool grouping
333
+ if (sdkMessage.parent_tool_use_id) {
334
+ return {
335
+ ...sdkMessage,
336
+ parentToolUseId: sdkMessage.parent_tool_use_id
337
+ };
338
+ }
339
+ return sdkMessage;
340
+ }
341
+
342
+ function readNumber(value) {
343
+ const parsed = Number(value);
344
+ return Number.isFinite(parsed) ? parsed : 0;
345
+ }
346
+
347
+ /**
348
+ * Extracts token usage from SDK messages.
349
+ * Prefers per-step `message.usage` (Claude message payload), then falls back
350
+ * to result-level usage/modelUsage for compatibility across SDK versions.
351
+ * @param {Object} sdkMessage - SDK stream message
352
+ * @returns {Object|null} Token budget object or null
353
+ */
354
+ function extractTokenBudget(sdkMessage) {
355
+ if (!sdkMessage || typeof sdkMessage !== 'object') {
356
+ return null;
357
+ }
358
+
359
+ const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
360
+ if (messageUsage && typeof messageUsage === 'object') {
361
+ const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
362
+ const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
363
+ const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
364
+ const cacheTokens = cacheCreationTokens + cacheReadTokens;
365
+ const inputTokens = directInputTokens + cacheTokens;
366
+ const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
367
+ const totalUsed = inputTokens + outputTokens;
368
+ const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
369
+
370
+ return {
371
+ used: totalUsed,
372
+ total: contextWindow,
373
+ inputTokens,
374
+ outputTokens,
375
+ cacheReadTokens,
376
+ cacheCreationTokens,
377
+ cacheTokens,
378
+ breakdown: {
379
+ input: inputTokens,
380
+ output: outputTokens,
381
+ },
382
+ };
383
+ }
384
+
385
+ if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
386
+ return null;
387
+ }
388
+
389
+ // Fallback for older SDK messages with only modelUsage
390
+ const modelKey = Object.keys(sdkMessage.modelUsage)[0];
391
+ const modelData = sdkMessage.modelUsage[modelKey];
392
+
393
+ if (!modelData || typeof modelData !== 'object') {
394
+ return null;
395
+ }
396
+
397
+ const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
398
+ const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
399
+ const totalUsed = inputTokens + outputTokens;
400
+ const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
401
+
402
+ return {
403
+ used: totalUsed,
404
+ total: contextWindow,
405
+ inputTokens,
406
+ outputTokens,
407
+ breakdown: {
408
+ input: inputTokens,
409
+ output: outputTokens,
410
+ },
411
+ };
412
+ }
413
+
414
+ // Tool calls that leave work running past the end of a turn. Bash only counts
415
+ // when it is explicitly backgrounded; the rest defer or watch work by nature.
416
+ const DEFERRED_WORK_TOOLS = new Set(['Monitor', 'ScheduleWakeup', 'CronCreate', 'TaskCreate']);
417
+
418
+ /**
419
+ * Detects tool calls that keep working after the turn's `result` arrives.
420
+ *
421
+ * Only turns that start background work need their CLI process held open; every
422
+ * other turn can let it exit immediately, as it did before the hold existed.
423
+ *
424
+ * @param {Object} sdkMessage - SDK stream message
425
+ * @returns {boolean} True when the message launches work that outlives the turn
426
+ */
427
+ function startsBackgroundWork(sdkMessage) {
428
+ const content = sdkMessage?.message?.content;
429
+ if (!Array.isArray(content)) {
430
+ return false;
431
+ }
432
+
433
+ return content.some((block) => {
434
+ if (block?.type !== 'tool_use') {
435
+ return false;
436
+ }
437
+ if (block.name === 'Bash') {
438
+ return block.input?.run_in_background === true;
439
+ }
440
+ return DEFERRED_WORK_TOOLS.has(block.name);
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Builds the SDK user messages for one turn.
446
+ *
447
+ * Always returns SDKUserMessage records rather than a bare string: a string
448
+ * prompt makes the SDK flag the query as single-turn and close stdin the moment
449
+ * the turn's `result` arrives, which kills the CLI's background tasks. Plain
450
+ * text turns carry string content; turns with image attachments carry the
451
+ * prompt text plus one base64 `image` block per attachment (read from the
452
+ * global `~/.cloudcli/assets` folder).
453
+ *
454
+ * @param {string} command - User prompt
455
+ * @param {Array} images - Image descriptors ({ path, name?, mimeType? })
456
+ * @param {Array} files - Non-image attachment descriptors
457
+ * @param {string} cwd - Project working directory attachment paths resolve against
458
+ * @returns {Promise<Array<Object>>} SDKUserMessage records for the turn
459
+ */
460
+ async function buildPromptMessages(command, images, files, cwd) {
461
+ const promptWithFiles = appendFilesInputTag(command, files);
462
+ const content = normalizeImageDescriptors(images).length === 0
463
+ ? promptWithFiles
464
+ : await buildClaudeUserContent(promptWithFiles, images, cwd);
465
+
466
+ return [{
467
+ type: 'user',
468
+ message: {
469
+ role: 'user',
470
+ content
471
+ },
472
+ parent_tool_use_id: null,
473
+ timestamp: new Date().toISOString()
474
+ }];
475
+ }
476
+
477
+ /**
478
+ * Wraps prompt messages in an async iterable that yields them and then parks.
479
+ *
480
+ * The SDK closes the CLI's stdin as soon as its input iterable is exhausted (and
481
+ * immediately on `result` for string prompts). The CLI reads that EOF as the end
482
+ * of the run and kills anything still going in the background, so the iterable
483
+ * has to stay pending until we actually want the process gone.
484
+ *
485
+ * @param {Array<Object>} messages - SDKUserMessage records to send
486
+ * @returns {{ stream: AsyncIterable, release: () => void }} Stream plus its closer
487
+ */
488
+ function createHeldPromptStream(messages) {
489
+ let release;
490
+ const held = new Promise((resolve) => { release = resolve; });
491
+
492
+ const stream = (async function* () {
493
+ for (const message of messages) {
494
+ yield message;
495
+ }
496
+ // Keeps stdin open — the CLI stays alive until release() is called.
497
+ await held;
498
+ })();
499
+
500
+ return { stream, release };
501
+ }
502
+
503
+ /**
504
+ * Loads MCP server configurations from ~/.claude.json
505
+ * @param {string} cwd - Current working directory for project-specific configs
506
+ * @returns {Object|null} MCP servers object or null if none found
507
+ */
508
+ async function loadMcpConfig(cwd) {
509
+ try {
510
+ const claudeConfigPath = path.join(os.homedir(), '.claude.json');
511
+
512
+ // Check if config file exists
513
+ try {
514
+ await fs.access(claudeConfigPath);
515
+ } catch (error) {
516
+ // File doesn't exist, return null
517
+ // No config file
518
+ return null;
519
+ }
520
+
521
+ // Read and parse config file
522
+ let claudeConfig;
523
+ try {
524
+ const configContent = await fs.readFile(claudeConfigPath, 'utf8');
525
+ claudeConfig = JSON.parse(configContent);
526
+ } catch (error) {
527
+ console.error('Failed to parse ~/.claude.json:', error.message);
528
+ return null;
529
+ }
530
+
531
+ // Extract MCP servers (merge global and project-specific)
532
+ let mcpServers = {};
533
+
534
+ // Add global MCP servers
535
+ if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
536
+ mcpServers = { ...claudeConfig.mcpServers };
537
+ // Global MCP servers loaded
538
+ }
539
+
540
+ // Add/override with project-specific MCP servers
541
+ if (claudeConfig.claudeProjects && cwd) {
542
+ const projectConfig = claudeConfig.claudeProjects[cwd];
543
+ if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
544
+ mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
545
+ // Project MCP servers merged
546
+ }
547
+ }
548
+
549
+ // Return null if no servers found
550
+ if (Object.keys(mcpServers).length === 0) {
551
+ return null;
552
+ }
553
+ return mcpServers;
554
+ } catch (error) {
555
+ console.error('Error loading MCP config:', error.message);
556
+ return null;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Executes a Claude query using the SDK
562
+ * @param {string} command - User prompt/command
563
+ * @param {Object} options - Query options
564
+ * @param {Object} ws - WebSocket connection
565
+ * @param {Object} context - Provider-scoped model, session, and auth lookups
566
+ * @returns {Promise<void>}
567
+ */
568
+ async function queryClaudeSDK(command, options = {}, ws, context) {
569
+ const { sessionId, sessionSummary } = options;
570
+ // Callers pass the stable app session id; the SDK only understands the
571
+ // provider-native id recorded on the session row.
572
+ const providerSessionId = context.resolveProviderSessionId(sessionId);
573
+ // Provider-native id as the SDK reports it (starts as the resume id, or is
574
+ // captured from the stream for brand-new sessions).
575
+ let capturedSessionId = providerSessionId;
576
+ let sessionCreatedSent = false;
577
+ // Process-map key: the app session id when the caller supplied one, else
578
+ // the provider-native id once captured (legacy/direct API callers).
579
+ const sessionKey = () => sessionId || capturedSessionId || null;
580
+
581
+ const emitNotification = (event) => {
582
+ notifyUserIfEnabled({
583
+ userId: ws?.userId || null,
584
+ writer: ws,
585
+ event
586
+ });
587
+ };
588
+
589
+ // Closes the held stdin stream so the CLI can wind down. Replaced once the
590
+ // stream exists; the finally block calls it no matter how the run ends.
591
+ let releasePromptStream = () => {};
592
+ let idleReleaseTimer = null;
593
+ // The client is told the turn is over as soon as `result` lands, even though
594
+ // the process lingers, so the UI never waits out the idle hold.
595
+ let turnCompleteSent = false;
596
+ // Set when a turn starts background work, cleared when the next `result`
597
+ // arrives — only turns with work still outstanding hold their process open.
598
+ let backgroundWorkPending = false;
599
+ // True while the process is being held open for background work, so a later
600
+ // `result` can be recognised as that work reporting back.
601
+ let heldForBackgroundWork = false;
602
+
603
+ // A new turn supersedes any earlier one still holding this session's process
604
+ // open, so held runs cannot stack up across a conversation.
605
+ if (sessionKey()) {
606
+ getSession(sessionKey())?.releaseInput?.();
607
+ }
608
+
609
+ // Arms (or re-arms) the idle countdown that eventually closes stdin.
610
+ const scheduleRelease = () => {
611
+ if (idleReleaseTimer) {
612
+ clearTimeout(idleReleaseTimer);
613
+ idleReleaseTimer = null;
614
+ }
615
+ idleReleaseTimer = setTimeout(() => {
616
+ idleReleaseTimer = null;
617
+ releasePromptStream();
618
+ }, BG_WAIT_CEILING_MS);
619
+ // Never let the hold keep the server process alive on its own.
620
+ idleReleaseTimer.unref?.();
621
+ };
622
+
623
+ // Hoisted above the try so the catch's cleanup can tell whether this run
624
+ // still owns the activeSessions entry (or was superseded by a newer run).
625
+ let queryInstance = null;
626
+
627
+ try {
628
+ const resolvedModel = await context.resolveResumeModel(sessionId, options.model);
629
+ let effortModels = CLAUDE_PREDEFINED_MODELS;
630
+ try {
631
+ effortModels = await context.getProviderModels();
632
+ } catch (error) {
633
+ console.warn('[Claude SDK] Unable to load provider models for effort validation:', error);
634
+ }
635
+
636
+ const sdkOptions = mapCliOptionsToSDK({
637
+ ...options,
638
+ providerSessionId,
639
+ model: resolvedModel || options.model,
640
+ effortModels,
641
+ });
642
+
643
+ const mcpServers = await loadMcpConfig(options.cwd);
644
+ if (mcpServers) {
645
+ sdkOptions.mcpServers = mcpServers;
646
+ }
647
+
648
+ // Every turn uses streaming input so stdin stays open past the turn's
649
+ // `result`. The message list is reusable, but each query attempt needs its
650
+ // own stream because an async generator cannot be replayed once consumed.
651
+ const promptMessages = await buildPromptMessages(command, options.images, options.files, options.cwd);
652
+
653
+ sdkOptions.hooks = {
654
+ Notification: [{
655
+ matcher: '',
656
+ hooks: [async (input) => {
657
+ const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
658
+ // Notifications are app-facing, so they carry the app session id.
659
+ emitNotification(createNotificationEvent({
660
+ provider: 'claude',
661
+ sessionId: sessionId || capturedSessionId || null,
662
+ kind: 'action_required',
663
+ code: 'agent.notification',
664
+ meta: { message, sessionName: sessionSummary },
665
+ severity: 'warning',
666
+ requiresUserAction: true,
667
+ dedupeKey: `claude:hook:notification:${sessionId || capturedSessionId || 'none'}:${message}`
668
+ }));
669
+ return {};
670
+ }]
671
+ }]
672
+ };
673
+
674
+ // Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
675
+ // at the permission-mode step and skips this callback, so interactive tools
676
+ // (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
677
+ // auto-approves them and the model acts on a generated answer. Move these
678
+ // tools to a PreToolUse hook (runs before the mode check) if we need them
679
+ // to work in those modes.
680
+ sdkOptions.canUseTool = async (toolName, input, context) => {
681
+ const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
682
+
683
+ if (!requiresInteraction) {
684
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
685
+ return { behavior: 'allow', updatedInput: input };
686
+ }
687
+
688
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
689
+ matchesToolPermission(entry, toolName, input)
690
+ );
691
+ if (isDisallowed) {
692
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
693
+ }
694
+
695
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
696
+ matchesToolPermission(entry, toolName, input)
697
+ );
698
+ if (isAllowed) {
699
+ return { behavior: 'allow', updatedInput: input };
700
+ }
701
+ }
702
+
703
+ const requestId = createRequestId();
704
+ ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
705
+ emitNotification(createNotificationEvent({
706
+ provider: 'claude',
707
+ sessionId: sessionId || capturedSessionId || null,
708
+ kind: 'action_required',
709
+ code: 'permission.required',
710
+ meta: { toolName, sessionName: sessionSummary },
711
+ severity: 'warning',
712
+ requiresUserAction: true,
713
+ dedupeKey: `claude:permission:${sessionId || capturedSessionId || 'none'}:${requestId}`
714
+ }));
715
+
716
+ const decision = await waitForToolApproval(requestId, {
717
+ timeoutMs: requiresInteraction ? 0 : undefined,
718
+ signal: context?.signal,
719
+ metadata: {
720
+ // Keyed by the app session id so `chat.subscribe` can look pending
721
+ // approvals up directly; provider id only for legacy callers.
722
+ _sessionId: sessionId || capturedSessionId || null,
723
+ _toolName: toolName,
724
+ _input: input,
725
+ _receivedAt: new Date(),
726
+ },
727
+ onCancel: (reason) => {
728
+ ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
729
+ }
730
+ });
731
+ if (!decision) {
732
+ return { behavior: 'deny', message: 'Permission request timed out' };
733
+ }
734
+
735
+ if (decision.cancelled) {
736
+ return { behavior: 'deny', message: 'Permission request cancelled' };
737
+ }
738
+
739
+ if (decision.allow) {
740
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
741
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
742
+ sdkOptions.allowedTools.push(decision.rememberEntry);
743
+ }
744
+ if (Array.isArray(sdkOptions.disallowedTools)) {
745
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
746
+ }
747
+ }
748
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
749
+ }
750
+
751
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
752
+ };
753
+
754
+ let heldPrompt = createHeldPromptStream(promptMessages);
755
+ releasePromptStream = heldPrompt.release;
756
+ try {
757
+ queryInstance = query({
758
+ prompt: heldPrompt.stream,
759
+ options: sdkOptions
760
+ });
761
+ } catch (hookError) {
762
+ // Older/newer SDK versions may not accept hook shapes yet.
763
+ // Keep notification behavior operational via runtime events even if hook registration fails.
764
+ console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
765
+ delete sdkOptions.hooks;
766
+ // Discard the abandoned stream and build a fresh one for the retry.
767
+ heldPrompt.release();
768
+ heldPrompt = createHeldPromptStream(promptMessages);
769
+ releasePromptStream = heldPrompt.release;
770
+ queryInstance = query({
771
+ prompt: heldPrompt.stream,
772
+ options: sdkOptions
773
+ });
774
+ }
775
+
776
+ // Track the query instance for abort capability
777
+ if (sessionKey()) {
778
+ addSession(sessionKey(), queryInstance, ws, releasePromptStream);
779
+ }
780
+
781
+ // Process streaming messages
782
+ console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
783
+ for await (const message of queryInstance) {
784
+ // Capture session ID from first message
785
+ if (message.session_id && !capturedSessionId) {
786
+
787
+ capturedSessionId = message.session_id;
788
+ addSession(sessionKey(), queryInstance, ws, releasePromptStream);
789
+
790
+ // Set session ID on writer
791
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
792
+ ws.setSessionId(capturedSessionId);
793
+ }
794
+
795
+ // Send session-created event only once for sessions with nothing to resume
796
+ if (!providerSessionId && !sessionCreatedSent) {
797
+ sessionCreatedSent = true;
798
+ ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
799
+ }
800
+ } else {
801
+ // session_id already captured
802
+ }
803
+
804
+ // Transform and normalize message via adapter
805
+ const transformedMessage = transformMessage(message);
806
+ const sid = capturedSessionId || sessionId || null;
807
+
808
+ // Use adapter to normalize SDK events into NormalizedMessage[]
809
+ const normalized = context.normalizeMessage(transformedMessage, sid);
810
+ for (const msg of normalized) {
811
+ // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
812
+ if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
813
+ msg.parentToolUseId = transformedMessage.parentToolUseId;
814
+ }
815
+ ws.send(msg);
816
+ }
817
+
818
+ // Extract and send token budget updates from assistant/result usage payloads
819
+ const tokenBudgetData = extractTokenBudget(message);
820
+ if (tokenBudgetData) {
821
+ ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
822
+ }
823
+
824
+ if (startsBackgroundWork(message)) {
825
+ backgroundWorkPending = true;
826
+ }
827
+
828
+ if (message.type === 'result') {
829
+ // The turn is done as far as the client is concerned.
830
+ const abortPending = sessionKey() ? abortedSessionIds.has(sessionKey()) : false;
831
+ if (!turnCompleteSent && !abortPending) {
832
+ turnCompleteSent = true;
833
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
834
+ notifyRunStopped({
835
+ userId: ws?.userId || null,
836
+ provider: 'claude',
837
+ sessionId: sessionId || capturedSessionId || null,
838
+ sessionName: sessionSummary,
839
+ stopReason: 'completed'
840
+ });
841
+ } else if (heldForBackgroundWork && !abortPending) {
842
+ // A result after the turn already reported complete means the work we
843
+ // held the process open for has finished and pushed a follow-up turn.
844
+ notifyBackgroundWorkCompleted({
845
+ userId: ws?.userId || null,
846
+ provider: 'claude',
847
+ sessionId: sessionId || capturedSessionId || null,
848
+ sessionName: sessionSummary
849
+ });
850
+ }
851
+ if (backgroundWorkPending) {
852
+ // Work started during this turn is still running. Hold the process
853
+ // open so it can finish and report back in a follow-up turn; the
854
+ // ceiling is only a backstop for work that never reports.
855
+ backgroundWorkPending = false;
856
+ heldForBackgroundWork = true;
857
+ scheduleRelease();
858
+ } else {
859
+ // Either nothing was backgrounded, or the background work just
860
+ // reported in — let the CLI exit now, as it always has.
861
+ heldForBackgroundWork = false;
862
+ releasePromptStream();
863
+ }
864
+ } else if (idleReleaseTimer) {
865
+ // Background activity after the turn — push the countdown back out.
866
+ scheduleRelease();
867
+ }
868
+ }
869
+
870
+ // Clean up session on completion — only while this run still owns the map
871
+ // entry. A superseding run may have replaced it, and deleting here would
872
+ // strand that run.
873
+ if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
874
+ removeSession(sessionKey());
875
+ }
876
+
877
+ // A superseded run winds down silently: the map entry, the abort flag,
878
+ // and all client-facing events belong to the run that replaced it.
879
+ const superseded = supersededInstances.has(queryInstance);
880
+
881
+ // Send the terminal completion event — skipped for aborted runs, whose
882
+ // terminal `complete` (aborted: true) was already sent by abort-session, and
883
+ // for runs that already reported completion when their `result` arrived.
884
+ const wasAborted = !superseded && sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
885
+ if (!turnCompleteSent && !superseded) {
886
+ turnCompleteSent = true;
887
+ if (!wasAborted) {
888
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
889
+ }
890
+ notifyRunStopped({
891
+ userId: ws?.userId || null,
892
+ provider: 'claude',
893
+ sessionId: sessionId || capturedSessionId || null,
894
+ sessionName: sessionSummary,
895
+ stopReason: wasAborted ? 'aborted' : 'completed'
896
+ });
897
+ }
898
+ // Complete
899
+
900
+ } catch (error) {
901
+ console.error('SDK query error:', error);
902
+
903
+ // Clean up session on error — only while this run still owns the map entry
904
+ // (a superseding run may have replaced it).
905
+ if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
906
+ removeSession(sessionKey());
907
+ }
908
+
909
+ if (supersededInstances.has(queryInstance)) {
910
+ // Interrupted because a newer run took over this session id; that run
911
+ // owns the abort flag and all further client-facing events.
912
+ return;
913
+ }
914
+
915
+ const wasAborted = sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
916
+ if (wasAborted) {
917
+ // The abort already produced the terminal complete; a generator throw
918
+ // caused by interrupt() is expected noise, not a user-facing error.
919
+ return;
920
+ }
921
+
922
+ // Check if Claude CLI is installed for a clearer error message
923
+ const installed = await context.isProviderInstalled();
924
+ const errorContent = !installed
925
+ ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
926
+ : error.message;
927
+
928
+ // Send error to WebSocket, then the terminal complete. A run that already
929
+ // reported completion and then failed during its post-turn hold still
930
+ // surfaces the error, but must not emit a second terminal complete.
931
+ ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
932
+ if (!turnCompleteSent) {
933
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
934
+ }
935
+ notifyRunFailed({
936
+ userId: ws?.userId || null,
937
+ provider: 'claude',
938
+ sessionId: sessionId || capturedSessionId || null,
939
+ sessionName: sessionSummary,
940
+ error
941
+ });
942
+ } finally {
943
+ // Always close stdin — otherwise an aborted or failed run leaves the CLI
944
+ // process (and its MCP servers) alive until the server exits.
945
+ if (idleReleaseTimer) {
946
+ clearTimeout(idleReleaseTimer);
947
+ idleReleaseTimer = null;
948
+ }
949
+ releasePromptStream();
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Aborts an active SDK session
955
+ * @param {string} sessionId - Session identifier
956
+ * @returns {boolean} True if session was aborted, false if not found
957
+ */
958
+ async function abortClaudeSDKSession(sessionId) {
959
+ const session = getSession(sessionId);
960
+
961
+ if (!session) {
962
+ console.log(`Session ${sessionId} not found`);
963
+ return false;
964
+ }
965
+
966
+ try {
967
+ console.log(`Aborting SDK session: ${sessionId}`);
968
+
969
+ // Mark before interrupting so the run loop knows not to emit its own
970
+ // terminal complete (the abort handler sends the aborted one).
971
+ abortedSessionIds.add(sessionId);
972
+
973
+ // Call interrupt() on the query instance
974
+ await session.instance.interrupt();
975
+
976
+ // Release the held stdin stream; without this the CLI stays up for the rest
977
+ // of the post-turn hold even though the user cancelled.
978
+ session.releaseInput?.();
979
+
980
+ // Update session status
981
+ session.status = 'aborted';
982
+
983
+ // Clean up session
984
+ removeSession(sessionId);
985
+
986
+ return true;
987
+ } catch (error) {
988
+ console.error(`Error aborting session ${sessionId}:`, error);
989
+ // The run keeps going; let it emit its own terminal complete.
990
+ abortedSessionIds.delete(sessionId);
991
+ return false;
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Checks if an SDK session is currently active
997
+ * @param {string} sessionId - Session identifier
998
+ * @returns {boolean} True if session is active
999
+ */
1000
+ function isClaudeSDKSessionActive(sessionId) {
1001
+ const session = getSession(sessionId);
1002
+ return session && session.status === 'active';
1003
+ }
1004
+
1005
+ /**
1006
+ * Gets all active SDK session IDs
1007
+ * @returns {Array<string>} Array of active session IDs
1008
+ */
1009
+ function getActiveClaudeSDKSessions() {
1010
+ return getAllSessions();
1011
+ }
1012
+
1013
+ /**
1014
+ * Get pending tool approvals for a specific session.
1015
+ * @param {string} sessionId - The session ID
1016
+ * @returns {Array} Array of pending permission request objects
1017
+ */
1018
+ function getPendingApprovalsForSession(sessionId) {
1019
+ const pending = [];
1020
+ for (const [requestId, resolver] of pendingToolApprovals.entries()) {
1021
+ if (resolver._sessionId === sessionId) {
1022
+ pending.push({
1023
+ requestId,
1024
+ toolName: resolver._toolName || 'UnknownTool',
1025
+ input: resolver._input,
1026
+ context: resolver._context,
1027
+ sessionId,
1028
+ receivedAt: resolver._receivedAt || new Date(),
1029
+ });
1030
+ }
1031
+ }
1032
+ return pending;
1033
+ }
1034
+
1035
+ /**
1036
+ * Reconnect a session's WebSocketWriter to a new raw WebSocket.
1037
+ * Called when client reconnects (e.g. page refresh) while SDK is still running.
1038
+ * @param {string} sessionId - The session ID
1039
+ * @param {Object} newRawWs - The new raw WebSocket connection
1040
+ * @returns {boolean} True if writer was successfully reconnected
1041
+ */
1042
+ function reconnectSessionWriter(sessionId, newRawWs) {
1043
+ const session = getSession(sessionId);
1044
+ if (!session?.writer?.updateWebSocket) return false;
1045
+ session.writer.updateWebSocket(newRawWs);
1046
+ console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
1047
+ return true;
1048
+ }
1049
+
1050
+ export const claudeRuntime = {
1051
+ run: queryClaudeSDK,
1052
+ abort: abortClaudeSDKSession,
1053
+ permissions: {
1054
+ resolve: resolveToolApproval,
1055
+ listPending: getPendingApprovalsForSession,
1056
+ },
1057
+ };
1058
+
1059
+ // Export public API
1060
+ export {
1061
+ queryClaudeSDK,
1062
+ abortClaudeSDKSession,
1063
+ isClaudeSDKSessionActive,
1064
+ getActiveClaudeSDKSessions,
1065
+ resolveToolApproval,
1066
+ getPendingApprovalsForSession,
1067
+ reconnectSessionWriter
1068
+ };