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,1287 +1,1287 @@
1
- // @ts-nocheck -- dynamic provider event payloads are normalized at the injected writer boundary.
2
- import path from 'path';
3
-
4
- import express from 'express';
5
-
6
- import type { ProviderRunFunction } from '@/shared/types.js';
7
-
8
- import { normalizeProjectPath } from '../../shared/utils.js';
9
-
10
- type AgentRouterDependencies = {
11
- fileSystem: typeof import('node:fs/promises');
12
- crypto: typeof import('node:crypto');
13
- homeDirectory(): string;
14
- spawnProcess: typeof import('cross-spawn').default;
15
- platformMode: boolean;
16
- users: { getFirstUser(): unknown };
17
- apiKeys: { validateApiKey(apiKey: string): unknown };
18
- githubTokens: { getActiveGithubToken(userId: number): string | null };
19
- projects: { createProjectPath(projectPath: string, customName: string | null): unknown };
20
- models: typeof import('../providers/index.js').providerModelsService;
21
- queryClaude: ProviderRunFunction;
22
- queryCursor: ProviderRunFunction;
23
- queryCodex: ProviderRunFunction;
24
- queryOpenCode: ProviderRunFunction;
25
- GithubClient: typeof import('@octokit/rest').Octokit;
26
- };
27
-
28
- /**
29
- * Creates Agent routes around explicit authentication, repository, provider,
30
- * filesystem, subprocess, runtime, and GitHub dependencies.
31
- */
32
- export function createAgentRouter(dependencies: AgentRouterDependencies): express.Router {
33
- const fs = dependencies.fileSystem;
34
- const crypto = dependencies.crypto;
35
- const os = { homedir: dependencies.homeDirectory };
36
- const spawn = dependencies.spawnProcess;
37
- const IS_PLATFORM = dependencies.platformMode;
38
- const userDb = dependencies.users;
39
- const apiKeysDb = dependencies.apiKeys;
40
- const githubTokensDb = dependencies.githubTokens;
41
- const projectsDb = dependencies.projects;
42
- const providerModelsService = dependencies.models;
43
- const queryClaudeSDK = dependencies.queryClaude;
44
- const spawnCursor = dependencies.queryCursor;
45
- const queryCodex = dependencies.queryCodex;
46
- const spawnOpenCode = dependencies.queryOpenCode;
47
- const Octokit = dependencies.GithubClient;
48
- const router = express.Router();
49
-
50
- /**
51
- * Middleware to authenticate agent API requests.
52
- *
53
- * Supports two authentication modes:
54
- * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
55
- * authentication is handled by an external proxy. Requests are trusted and
56
- * the default user context is used.
57
- *
58
- * 2. API key mode (default): For self-hosted deployments where users authenticate
59
- * via API keys created in the UI. Keys are validated against the local database.
60
- */
61
- const validateExternalApiKey = (req, res, next) => {
62
- // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
63
- // Trust the request and use the default user context.
64
- if (IS_PLATFORM) {
65
- try {
66
- const user = userDb.getFirstUser();
67
- if (!user) {
68
- return res.status(500).json({ error: 'Platform mode: No user found in database' });
69
- }
70
- req.user = user;
71
- return next();
72
- } catch (error) {
73
- console.error('Platform mode error:', error);
74
- return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
75
- }
76
- }
77
-
78
- // Self-hosted mode: Validate API key from header or query parameter
79
- const apiKey = req.headers['x-api-key'] || req.query.apiKey;
80
-
81
- if (!apiKey) {
82
- return res.status(401).json({ error: 'API key required' });
83
- }
84
-
85
- const user = apiKeysDb.validateApiKey(apiKey);
86
-
87
- if (!user) {
88
- return res.status(401).json({ error: 'Invalid or inactive API key' });
89
- }
90
-
91
- req.user = user;
92
- next();
93
- };
94
-
95
- /**
96
- * Get the remote URL of a git repository
97
- * @param {string} repoPath - Path to the git repository
98
- * @returns {Promise<string>} - Remote URL of the repository
99
- */
100
- async function getGitRemoteUrl(repoPath) {
101
- return new Promise((resolve, reject) => {
102
- const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
103
- cwd: repoPath,
104
- stdio: ['pipe', 'pipe', 'pipe']
105
- });
106
-
107
- let stdout = '';
108
- let stderr = '';
109
-
110
- gitProcess.stdout.on('data', (data) => {
111
- stdout += data.toString();
112
- });
113
-
114
- gitProcess.stderr.on('data', (data) => {
115
- stderr += data.toString();
116
- });
117
-
118
- gitProcess.on('close', (code) => {
119
- if (code === 0) {
120
- resolve(stdout.trim());
121
- } else {
122
- reject(new Error(`Failed to get git remote: ${stderr}`));
123
- }
124
- });
125
-
126
- gitProcess.on('error', (error) => {
127
- reject(new Error(`Failed to execute git: ${error.message}`));
128
- });
129
- });
130
- }
131
-
132
- /**
133
- * Normalize GitHub URLs for comparison
134
- * @param {string} url - GitHub URL
135
- * @returns {string} - Normalized URL
136
- */
137
- function normalizeGitHubUrl(url) {
138
- // Remove .git suffix
139
- let normalized = url.replace(/\.git$/, '');
140
- // Convert SSH to HTTPS format for comparison
141
- normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
142
- // Remove trailing slash
143
- normalized = normalized.replace(/\/$/, '');
144
- return normalized.toLowerCase();
145
- }
146
-
147
- /**
148
- * Parse GitHub URL to extract owner and repo
149
- * @param {string} url - GitHub URL (HTTPS or SSH)
150
- * @returns {{owner: string, repo: string}} - Parsed owner and repo
151
- */
152
- function parseGitHubUrl(url) {
153
- // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
154
- // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
155
- const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
156
- if (!match) {
157
- throw new Error('Invalid GitHub URL format');
158
- }
159
- return {
160
- owner: match[1],
161
- repo: match[2].replace(/\.git$/, '')
162
- };
163
- }
164
-
165
- /**
166
- * Auto-generate a branch name from a message
167
- * @param {string} message - The agent message
168
- * @returns {string} - Generated branch name
169
- */
170
- function autogenerateBranchName(message) {
171
- // Convert to lowercase, replace spaces/special chars with hyphens
172
- let branchName = message
173
- .toLowerCase()
174
- .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
175
- .replace(/\s+/g, '-') // Replace spaces with hyphens
176
- .replace(/-+/g, '-') // Replace multiple hyphens with single
177
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
178
-
179
- // Ensure non-empty fallback
180
- if (!branchName) {
181
- branchName = 'task';
182
- }
183
-
184
- // Generate timestamp suffix (last 6 chars of base36 timestamp)
185
- const timestamp = Date.now().toString(36).slice(-6);
186
- const suffix = `-${timestamp}`;
187
-
188
- // Limit length to ensure total length including suffix fits within 50 characters
189
- const maxBaseLength = 50 - suffix.length;
190
- if (branchName.length > maxBaseLength) {
191
- branchName = branchName.substring(0, maxBaseLength);
192
- }
193
-
194
- // Remove any trailing hyphen after truncation and ensure no leading hyphen
195
- branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
196
-
197
- // If still empty or starts with hyphen after cleanup, use fallback
198
- if (!branchName || branchName.startsWith('-')) {
199
- branchName = 'task';
200
- }
201
-
202
- // Combine base name with timestamp suffix
203
- branchName = `${branchName}${suffix}`;
204
-
205
- // Final validation: ensure it matches safe pattern
206
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
207
- // Fallback to deterministic safe name
208
- return `branch-${timestamp}`;
209
- }
210
-
211
- return branchName;
212
- }
213
-
214
- /**
215
- * Validate a Git branch name
216
- * @param {string} branchName - Branch name to validate
217
- * @returns {{valid: boolean, error?: string}} - Validation result
218
- */
219
- function validateBranchName(branchName) {
220
- if (!branchName || branchName.trim() === '') {
221
- return { valid: false, error: 'Branch name cannot be empty' };
222
- }
223
-
224
- // Git branch name rules
225
- const invalidPatterns = [
226
- { pattern: /^\./, message: 'Branch name cannot start with a dot' },
227
- { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
228
- { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
229
- { pattern: /\s/, message: 'Branch name cannot contain spaces' },
230
- { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
231
- { pattern: /@{/, message: 'Branch name cannot contain @{' },
232
- { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
233
- { pattern: /^\//, message: 'Branch name cannot start with a slash' },
234
- { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
235
- { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
236
- ];
237
-
238
- for (const { pattern, message } of invalidPatterns) {
239
- if (pattern.test(branchName)) {
240
- return { valid: false, error: message };
241
- }
242
- }
243
-
244
- // Check for ASCII control characters
245
- if (/[\x00-\x1F\x7F]/.test(branchName)) {
246
- return { valid: false, error: 'Branch name cannot contain control characters' };
247
- }
248
-
249
- return { valid: true };
250
- }
251
-
252
- /**
253
- * Get recent commit messages from a repository
254
- * @param {string} projectPath - Path to the git repository
255
- * @param {number} limit - Number of commits to retrieve (default: 5)
256
- * @returns {Promise<string[]>} - Array of commit messages
257
- */
258
- async function getCommitMessages(projectPath, limit = 5) {
259
- return new Promise((resolve, reject) => {
260
- const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
261
- cwd: projectPath,
262
- stdio: ['pipe', 'pipe', 'pipe']
263
- });
264
-
265
- let stdout = '';
266
- let stderr = '';
267
-
268
- gitProcess.stdout.on('data', (data) => {
269
- stdout += data.toString();
270
- });
271
-
272
- gitProcess.stderr.on('data', (data) => {
273
- stderr += data.toString();
274
- });
275
-
276
- gitProcess.on('close', (code) => {
277
- if (code === 0) {
278
- const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
279
- resolve(messages);
280
- } else {
281
- reject(new Error(`Failed to get commit messages: ${stderr}`));
282
- }
283
- });
284
-
285
- gitProcess.on('error', (error) => {
286
- reject(new Error(`Failed to execute git: ${error.message}`));
287
- });
288
- });
289
- }
290
-
291
- /**
292
- * Create a new branch on GitHub using the API
293
- * @param {Octokit} octokit - Octokit instance
294
- * @param {string} owner - Repository owner
295
- * @param {string} repo - Repository name
296
- * @param {string} branchName - Name of the new branch
297
- * @param {string} baseBranch - Base branch to branch from (default: 'main')
298
- * @returns {Promise<void>}
299
- */
300
-
301
- /**
302
- * Create a pull request on GitHub
303
- * @param {Octokit} octokit - Octokit instance
304
- * @param {string} owner - Repository owner
305
- * @param {string} repo - Repository name
306
- * @param {string} branchName - Head branch name
307
- * @param {string} title - PR title
308
- * @param {string} body - PR body/description
309
- * @param {string} baseBranch - Base branch (default: 'main')
310
- * @returns {Promise<{number: number, url: string}>} - PR number and URL
311
- */
312
- async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
313
- const { data: pr } = await octokit.pulls.create({
314
- owner,
315
- repo,
316
- title,
317
- head: branchName,
318
- base: baseBranch,
319
- body
320
- });
321
-
322
- console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
323
-
324
- return {
325
- number: pr.number,
326
- url: pr.html_url
327
- };
328
- }
329
-
330
- /**
331
- * Clone a GitHub repository to a directory
332
- * @param {string} githubUrl - GitHub repository URL
333
- * @param {string} githubToken - Optional GitHub token for private repos
334
- * @param {string} projectPath - Path for cloning the repository
335
- * @returns {Promise<{path: string, created: boolean}>} - Checkout path and ownership flag
336
- */
337
- async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
338
- return new Promise(async (resolve, reject) => {
339
- try {
340
- // Validate the host before using credentials or invoking Git.
341
- let parsedGithubUrl;
342
- try {
343
- parsedGithubUrl = new URL(githubUrl);
344
- } catch {
345
- throw new Error('Invalid GitHub URL');
346
- }
347
- if (
348
- parsedGithubUrl.protocol !== 'https:'
349
- || parsedGithubUrl.hostname !== 'github.com'
350
- || parsedGithubUrl.username
351
- || parsedGithubUrl.password
352
- ) {
353
- throw new Error('Invalid GitHub URL');
354
- }
355
- const cloneUrl = parsedGithubUrl.toString();
356
-
357
- const cloneDir = path.resolve(projectPath);
358
-
359
- // Check if directory already exists
360
- try {
361
- await fs.access(cloneDir);
362
- // Directory exists - check if it's a git repo with the same URL
363
- try {
364
- const existingUrl = await getGitRemoteUrl(cloneDir);
365
- const normalizedExisting = normalizeGitHubUrl(existingUrl);
366
- const normalizedRequested = normalizeGitHubUrl(cloneUrl);
367
-
368
- if (normalizedExisting === normalizedRequested) {
369
- console.log('✅ Repository already exists at path with correct URL');
370
- return resolve({ path: cloneDir, created: false });
371
- } else {
372
- throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
373
- }
374
- } catch (gitError) {
375
- throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
376
- }
377
- } catch (accessError) {
378
- // Directory doesn't exist - proceed with clone
379
- }
380
-
381
- // Ensure parent directory exists
382
- await fs.mkdir(path.dirname(cloneDir), { recursive: true });
383
-
384
- console.log('🔄 Cloning repository:', githubUrl);
385
- console.log('📁 Destination:', cloneDir);
386
-
387
- // Execute git clone
388
- const gitEnvironment = githubToken ? {
389
- ...process.env,
390
- GIT_CONFIG_COUNT: '2',
391
- GIT_CONFIG_KEY_0: 'credential.helper',
392
- GIT_CONFIG_VALUE_0: '',
393
- GIT_CONFIG_KEY_1: 'credential.helper',
394
- GIT_CONFIG_VALUE_1: '!f() { echo username=x-access-token; echo "password=$CLOUDCLI_GITHUB_TOKEN"; }; f',
395
- CLOUDCLI_GITHUB_TOKEN: githubToken,
396
- GIT_TERMINAL_PROMPT: '0'
397
- } : process.env;
398
- const gitProcess = spawn('git', ['clone', '--depth', '1', '--', cloneUrl, cloneDir], {
399
- stdio: ['pipe', 'pipe', 'pipe'],
400
- env: gitEnvironment
401
- });
402
-
403
- let stdout = '';
404
- let stderr = '';
405
-
406
- gitProcess.stdout.on('data', (data) => {
407
- stdout += data.toString();
408
- });
409
-
410
- gitProcess.stderr.on('data', (data) => {
411
- stderr += data.toString();
412
- console.log('Git stderr:', data.toString());
413
- });
414
-
415
- gitProcess.on('close', (code) => {
416
- if (code === 0) {
417
- console.log('✅ Repository cloned successfully');
418
- resolve({ path: cloneDir, created: true });
419
- } else {
420
- console.error('❌ Git clone failed:', stderr);
421
- reject(new Error(`Git clone failed: ${stderr}`));
422
- }
423
- });
424
-
425
- gitProcess.on('error', (error) => {
426
- reject(new Error(`Failed to execute git: ${error.message}`));
427
- });
428
- } catch (error) {
429
- reject(error);
430
- }
431
- });
432
- }
433
-
434
- /**
435
- * Clean up a temporary project directory and its Claude session
436
- * @param {string} projectPath - Path to the project directory
437
- * @param {string} sessionId - Session ID to clean up
438
- */
439
- async function cleanupProject(projectPath, sessionId = null) {
440
- try {
441
- const externalProjectsRoot = await fs.realpath(
442
- path.join(os.homedir(), '.claude', 'external-projects')
443
- );
444
- const canonicalProjectPath = await fs.realpath(projectPath);
445
- const relativeProjectPath = path.relative(externalProjectsRoot, canonicalProjectPath);
446
- const isContained = relativeProjectPath !== ''
447
- && relativeProjectPath !== '..'
448
- && !relativeProjectPath.startsWith(`..${path.sep}`)
449
- && !path.isAbsolute(relativeProjectPath);
450
-
451
- if (!isContained) {
452
- console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
453
- return;
454
- }
455
-
456
- console.log('🧹 Cleaning up project:', projectPath);
457
- await fs.rm(canonicalProjectPath, { recursive: true, force: true });
458
- console.log('✅ Project cleaned up');
459
-
460
- // Also clean up the Claude session directory if sessionId provided
461
- if (sessionId) {
462
- try {
463
- const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
464
- console.log('🧹 Cleaning up session directory:', sessionPath);
465
- await fs.rm(sessionPath, { recursive: true, force: true });
466
- console.log('✅ Session directory cleaned up');
467
- } catch (error) {
468
- console.error('⚠️ Failed to clean up session directory:', error.message);
469
- }
470
- }
471
- } catch (error) {
472
- console.error('❌ Failed to clean up project:', error);
473
- }
474
- }
475
-
476
- /**
477
- * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
478
- */
479
- class SSEStreamWriter {
480
- constructor(res, userId = null) {
481
- this.res = res;
482
- this.sessionId = null;
483
- this.userId = userId;
484
- this.isSSEStreamWriter = true; // Marker for transport detection
485
- }
486
-
487
- send(data) {
488
- if (this.res.writableEnded) {
489
- return;
490
- }
491
-
492
- // Format as SSE - providers send raw objects, we stringify
493
- this.res.write(`data: ${JSON.stringify(data)}\n\n`);
494
- }
495
-
496
- end() {
497
- if (!this.res.writableEnded) {
498
- this.res.write('data: {"type":"done"}\n\n');
499
- this.res.end();
500
- }
501
- }
502
-
503
- setSessionId(sessionId) {
504
- this.sessionId = sessionId;
505
- this.send({ type: 'session-id', sessionId });
506
- }
507
-
508
- getSessionId() {
509
- return this.sessionId;
510
- }
511
- }
512
-
513
- /**
514
- * Non-streaming response collector
515
- */
516
- class ResponseCollector {
517
- constructor(userId = null) {
518
- this.messages = [];
519
- this.sessionId = null;
520
- this.userId = userId;
521
- }
522
-
523
- send(data) {
524
- // Store ALL messages for now - we'll filter when returning
525
- this.messages.push(data);
526
-
527
- // Extract sessionId if present
528
- if (typeof data === 'string') {
529
- try {
530
- const parsed = JSON.parse(data);
531
- if (parsed.sessionId) {
532
- this.sessionId = parsed.sessionId;
533
- }
534
- } catch (e) {
535
- // Not JSON, ignore
536
- }
537
- } else if (data && data.sessionId) {
538
- this.sessionId = data.sessionId;
539
- }
540
- }
541
-
542
- end() {
543
- // Do nothing - we'll collect all messages
544
- }
545
-
546
- setSessionId(sessionId) {
547
- this.sessionId = sessionId;
548
- }
549
-
550
- getSessionId() {
551
- return this.sessionId;
552
- }
553
-
554
- getMessages() {
555
- return this.messages;
556
- }
557
-
558
- /**
559
- * Get filtered assistant messages only
560
- */
561
- getAssistantMessages() {
562
- const assistantMessages = [];
563
-
564
- for (const msg of this.messages) {
565
- // Skip initial status message
566
- if (msg && msg.type === 'status') {
567
- continue;
568
- }
569
-
570
- // Handle JSON strings
571
- if (typeof msg === 'string') {
572
- try {
573
- const parsed = JSON.parse(msg);
574
- // Only include claude-response messages with assistant type
575
- if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
576
- assistantMessages.push(parsed.data);
577
- }
578
- } catch (e) {
579
- // Not JSON, skip
580
- }
581
- }
582
- }
583
-
584
- return assistantMessages;
585
- }
586
-
587
- /**
588
- * Calculate total tokens from all messages
589
- */
590
- getTotalTokens() {
591
- let totalInput = 0;
592
- let totalOutput = 0;
593
- let totalCacheRead = 0;
594
- let totalCacheCreation = 0;
595
-
596
- for (const msg of this.messages) {
597
- let data = msg;
598
-
599
- // Parse if string
600
- if (typeof msg === 'string') {
601
- try {
602
- data = JSON.parse(msg);
603
- } catch (e) {
604
- continue;
605
- }
606
- }
607
-
608
- // Extract usage from claude-response messages
609
- if (data && data.type === 'claude-response' && data.data) {
610
- const msgData = data.data;
611
- if (msgData.message && msgData.message.usage) {
612
- const usage = msgData.message.usage;
613
- totalInput += usage.input_tokens || 0;
614
- totalOutput += usage.output_tokens || 0;
615
- totalCacheRead += usage.cache_read_input_tokens || 0;
616
- totalCacheCreation += usage.cache_creation_input_tokens || 0;
617
- }
618
- }
619
- }
620
-
621
- const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
622
-
623
- return {
624
- inputTokens,
625
- outputTokens: totalOutput,
626
- cacheReadTokens: totalCacheRead,
627
- cacheCreationTokens: totalCacheCreation,
628
- totalTokens: inputTokens + totalOutput
629
- };
630
- }
631
- }
632
-
633
- // ===============================
634
- // External API Endpoint
635
- // ===============================
636
-
637
- /**
638
- * POST /api/agent
639
- *
640
- * Trigger an AI agent to work on a project.
641
- * Supports automatic GitHub branch and pull request creation after successful completion.
642
- *
643
- * ================================================================================================
644
- * REQUEST BODY PARAMETERS
645
- * ================================================================================================
646
- *
647
- * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
648
- * Supported formats:
649
- * - HTTPS: https://github.com/owner/repo
650
- * - HTTPS with .git: https://github.com/owner/repo.git
651
- * - SSH: git@github.com:owner/repo
652
- * - SSH with .git: git@github.com:owner/repo.git
653
- *
654
- * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
655
- * Behavior depends on usage:
656
- * - If used alone: Must point to existing project directory
657
- * - If used with githubUrl: Target location for cloning
658
- * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
659
- *
660
- * @param {string} message - (Required) Task description for the AI agent. Used as:
661
- * - Instructions for the agent
662
- * - Source for auto-generated branch names (if createBranch=true and no branchName)
663
- * - Fallback for PR title if no commits are made
664
- *
665
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode'
666
- * Default: 'claude'
667
- *
668
- * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
669
- * Default: true
670
- * - true: Returns text/event-stream with incremental updates
671
- * - false: Returns complete JSON response after completion
672
- *
673
- * @param {string} model - (Optional) Model identifier for providers.
674
- *
675
- * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable'
676
- * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
677
- * 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
678
- * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
679
- * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
680
- * Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini'
681
- *
682
- * @param {string} effort - (Optional) Reasoning effort for providers/models that support it.
683
- * Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model.
684
- * Codex supports: 'low', 'medium', 'high', 'xhigh'.
685
- * 'default' or omission lets the provider decide.
686
- *
687
- * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
688
- * Default: true
689
- * Behavior:
690
- * - Only applies when cloning via githubUrl (not for existing projectPath)
691
- * - Deletes cloned repository after 5 seconds
692
- * - Also deletes associated Claude session directory
693
- * - Remote branch and PR remain on GitHub if created
694
- *
695
- * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
696
- * Overrides stored token from user settings.
697
- * Required for:
698
- * - Private repositories
699
- * - Branch/PR creation features
700
- * Token must have 'repo' scope for full functionality.
701
- *
702
- * @param {string} branchName - (Optional) Custom name for the Git branch.
703
- * If provided, createBranch is automatically set to true.
704
- * Validation rules (errors returned if violated):
705
- * - Cannot be empty or whitespace only
706
- * - Cannot start or end with dot (.)
707
- * - Cannot contain consecutive dots (..)
708
- * - Cannot contain spaces
709
- * - Cannot contain special characters: ~ ^ : ? * [ \
710
- * - Cannot contain @{
711
- * - Cannot start or end with forward slash (/)
712
- * - Cannot contain consecutive slashes (//)
713
- * - Cannot end with .lock
714
- * - Cannot contain ASCII control characters
715
- * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
716
- *
717
- * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
718
- * Default: false (or true if branchName is provided)
719
- * Behavior:
720
- * - Creates branch locally and pushes to remote
721
- * - If branch exists locally: Checks out existing branch (no error)
722
- * - If branch exists on remote: Uses existing branch (no error)
723
- * - Branch name: Custom (if branchName provided) or auto-generated from message
724
- * - Requires either githubUrl OR projectPath with GitHub remote
725
- *
726
- * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
727
- * Default: false
728
- * Behavior:
729
- * - PR title: First commit message (or fallback to message parameter)
730
- * - PR description: Auto-generated from all commit messages
731
- * - Base branch: Always 'main' (currently hardcoded)
732
- * - If PR already exists: GitHub returns error with details
733
- * - Requires either githubUrl OR projectPath with GitHub remote
734
- *
735
- * ================================================================================================
736
- * PATH HANDLING BEHAVIOR
737
- * ================================================================================================
738
- *
739
- * Scenario 1: Only githubUrl provided
740
- * Input: { githubUrl: "https://github.com/owner/repo" }
741
- * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
742
- * Cleanup: Yes (if cleanup=true)
743
- *
744
- * Scenario 2: Only projectPath provided
745
- * Input: { projectPath: "/home/user/my-project" }
746
- * Action: Uses existing project at specified path
747
- * Validation: Path must exist and be accessible
748
- * Cleanup: No (never cleanup existing projects)
749
- *
750
- * Scenario 3: Both githubUrl and projectPath provided
751
- * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
752
- * Action: Clones githubUrl to projectPath location
753
- * Validation:
754
- * - If projectPath exists with git repo:
755
- * - Compares remote URL with githubUrl
756
- * - If URLs match: Reuses existing repo
757
- * - If URLs differ: Returns error
758
- * Cleanup: Yes (if cleanup=true)
759
- *
760
- * ================================================================================================
761
- * GITHUB BRANCH/PR CREATION REQUIREMENTS
762
- * ================================================================================================
763
- *
764
- * For createBranch or createPR to work, one of the following must be true:
765
- *
766
- * Option A: githubUrl provided
767
- * - Repository URL directly specified
768
- * - Works with both cloning and existing paths
769
- *
770
- * Option B: projectPath with GitHub remote
771
- * - Project must be a Git repository
772
- * - Must have 'origin' remote configured
773
- * - Remote URL must point to github.com
774
- * - System auto-detects GitHub URL via: git remote get-url origin
775
- *
776
- * Additional Requirements:
777
- * - Valid GitHub token (from settings or githubToken parameter)
778
- * - Token must have 'repo' scope for private repos
779
- * - Project must have commits (for PR creation)
780
- *
781
- * ================================================================================================
782
- * VALIDATION & ERROR HANDLING
783
- * ================================================================================================
784
- *
785
- * Input Validations (400 Bad Request):
786
- * - Either githubUrl OR projectPath must be provided (not neither)
787
- * - message must be non-empty string
788
- * - provider must be 'claude', 'cursor', 'codex', or 'opencode'
789
- * - createBranch/createPR requires githubUrl OR projectPath (not neither)
790
- * - branchName must pass Git naming rules (if provided)
791
- *
792
- * Runtime Validations (500 Internal Server Error or specific error in response):
793
- * - projectPath must exist (if used alone)
794
- * - GitHub URL format must be valid
795
- * - Git remote URL must include github.com (for projectPath + branch/PR)
796
- * - GitHub token must be available (for private repos and branch/PR)
797
- * - Directory conflicts handled (existing path with different repo)
798
- *
799
- * Branch Name Validation Errors (returned in response, not HTTP error):
800
- * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
801
- * Examples:
802
- * - "my branch" → "Branch name cannot contain spaces"
803
- * - ".feature" → "Branch name cannot start with a dot"
804
- * - "feature.lock" → "Branch name cannot end with .lock"
805
- *
806
- * ================================================================================================
807
- * RESPONSE FORMATS
808
- * ================================================================================================
809
- *
810
- * Streaming Response (stream=true):
811
- * Content-Type: text/event-stream
812
- * Events:
813
- * - { type: "status", message: "...", projectPath: "..." }
814
- * - { type: "claude-response", data: {...} }
815
- * - { type: "github-branch", branch: { name: "...", url: "..." } }
816
- * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
817
- * - { type: "github-error", error: "..." }
818
- * - { type: "done" }
819
- *
820
- * Non-Streaming Response (stream=false):
821
- * Content-Type: application/json
822
- * {
823
- * success: true,
824
- * sessionId: "session-123",
825
- * messages: [...], // Assistant messages only (filtered)
826
- * tokens: {
827
- * inputTokens: 150,
828
- * outputTokens: 50,
829
- * cacheReadTokens: 0,
830
- * cacheCreationTokens: 0,
831
- * totalTokens: 200
832
- * },
833
- * projectPath: "/path/to/project",
834
- * branch: { // Only if createBranch=true
835
- * name: "feature/xyz",
836
- * url: "https://github.com/owner/repo/tree/feature/xyz"
837
- * } | { error: "..." },
838
- * pullRequest: { // Only if createPR=true
839
- * number: 42,
840
- * url: "https://github.com/owner/repo/pull/42"
841
- * } | { error: "..." }
842
- * }
843
- *
844
- * Error Response:
845
- * HTTP Status: 400, 401, 500
846
- * Content-Type: application/json
847
- * { success: false, error: "Error description" }
848
- *
849
- * ================================================================================================
850
- * EXAMPLES
851
- * ================================================================================================
852
- *
853
- * Example 1: Clone and process with auto-cleanup
854
- * POST /api/agent
855
- * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
856
- *
857
- * Example 2: Use existing project with custom branch and PR
858
- * POST /api/agent
859
- * {
860
- * "projectPath": "/home/user/project",
861
- * "message": "Add feature",
862
- * "branchName": "feature/new-feature",
863
- * "createPR": true
864
- * }
865
- *
866
- * Example 3: Clone to specific path with auto-generated branch
867
- * POST /api/agent
868
- * {
869
- * "githubUrl": "https://github.com/user/repo",
870
- * "projectPath": "/tmp/work",
871
- * "message": "Refactor code",
872
- * "createBranch": true,
873
- * "cleanup": false
874
- * }
875
- */
876
- router.post('/', validateExternalApiKey, async (req, res) => {
877
- const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
878
- const effort = typeof req.body.effort === 'string' && req.body.effort.trim()
879
- ? req.body.effort.trim()
880
- : undefined;
881
-
882
- // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
883
- const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
884
- const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
885
-
886
- // If branchName is provided, automatically enable createBranch
887
- const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
888
- const createPR = req.body.createPR === true || req.body.createPR === 'true';
889
-
890
- // Validate inputs
891
- if (!githubUrl && !projectPath) {
892
- return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
893
- }
894
-
895
- if (!message || !message.trim()) {
896
- return res.status(400).json({ error: 'message is required' });
897
- }
898
-
899
- if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) {
900
- return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' });
901
- }
902
-
903
- // Validate GitHub branch/PR creation requirements
904
- // Allow branch/PR creation with projectPath as long as it has a GitHub remote
905
- if ((createBranch || createPR) && !githubUrl && !projectPath) {
906
- return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
907
- }
908
-
909
- let finalProjectPath = null;
910
- let clonedProjectCreated = false;
911
- let writer = null;
912
-
913
- try {
914
- // Determine the final project path
915
- if (githubUrl) {
916
- // Clone repository (to projectPath if provided, otherwise generate path)
917
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
918
-
919
- let targetPath;
920
- if (projectPath) {
921
- targetPath = projectPath;
922
- } else {
923
- // Generate a unique path for cloning
924
- const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
925
- targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
926
- }
927
-
928
- const clonedProject = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
929
- finalProjectPath = clonedProject.path;
930
- clonedProjectCreated = clonedProject.created;
931
- } else {
932
- // Use existing project path
933
- finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
934
-
935
- // Verify the path exists
936
- try {
937
- await fs.access(finalProjectPath);
938
- } catch (error) {
939
- throw new Error(`Project path does not exist: ${finalProjectPath}`);
940
- }
941
- }
942
-
943
- finalProjectPath = normalizeProjectPath(finalProjectPath);
944
-
945
- // Register project path in DB (or reuse existing active registration)
946
- const registrationResult = projectsDb.createProjectPath(finalProjectPath, null);
947
- if (registrationResult.outcome === 'active_conflict') {
948
- console.log('Project registration already exists for:', finalProjectPath);
949
- } else {
950
- console.log('Project registered:', registrationResult.project);
951
- }
952
-
953
- // Set up writer based on streaming mode
954
- if (stream) {
955
- // Set up SSE headers for streaming
956
- res.setHeader('Content-Type', 'text/event-stream');
957
- res.setHeader('Cache-Control', 'no-cache');
958
- res.setHeader('Connection', 'keep-alive');
959
- res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
960
-
961
- writer = new SSEStreamWriter(res, req.user.id);
962
-
963
- // Send initial status
964
- writer.send({
965
- type: 'status',
966
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
967
- projectPath: finalProjectPath
968
- });
969
- } else {
970
- // Non-streaming mode: collect messages
971
- writer = new ResponseCollector(req.user.id);
972
-
973
- // Collect initial status message
974
- writer.send({
975
- type: 'status',
976
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
977
- projectPath: finalProjectPath
978
- });
979
- }
980
-
981
- const codexModels = (await providerModelsService.getProviderModels('codex')).models;
982
- const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
983
-
984
- // Start the appropriate session
985
- if (provider === 'claude') {
986
- console.log('🤖 Starting Claude SDK session');
987
-
988
- await queryClaudeSDK(message.trim(), {
989
- projectPath: finalProjectPath,
990
- cwd: finalProjectPath,
991
- sessionId: sessionId || null,
992
- model: model,
993
- effort,
994
- permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
995
- }, writer);
996
-
997
- } else if (provider === 'cursor') {
998
- console.log('🖱️ Starting Cursor CLI session');
999
-
1000
- await spawnCursor(message.trim(), {
1001
- projectPath: finalProjectPath,
1002
- cwd: finalProjectPath,
1003
- sessionId: sessionId || null,
1004
- model: model || undefined,
1005
- skipPermissions: true // Bypass permissions for Cursor
1006
- }, writer);
1007
- } else if (provider === 'codex') {
1008
- console.log('🤖 Starting Codex SDK session');
1009
-
1010
- await queryCodex(message.trim(), {
1011
- projectPath: finalProjectPath,
1012
- cwd: finalProjectPath,
1013
- sessionId: sessionId || null,
1014
- model: model || codexModels.DEFAULT,
1015
- effort,
1016
- permissionMode: 'bypassPermissions'
1017
- }, writer);
1018
- } else if (provider === 'opencode') {
1019
- console.log('Starting OpenCode CLI session');
1020
-
1021
- await spawnOpenCode(message.trim(), {
1022
- projectPath: finalProjectPath,
1023
- cwd: finalProjectPath,
1024
- sessionId: sessionId || null,
1025
- model: model || opencodeModels.DEFAULT,
1026
- effort,
1027
- permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above
1028
- }, writer);
1029
- }
1030
-
1031
- // Handle GitHub branch and PR creation after successful agent completion
1032
- let branchInfo = null;
1033
- let prInfo = null;
1034
-
1035
- if (createBranch || createPR) {
1036
- try {
1037
- console.log('🔄 Starting GitHub branch/PR creation workflow...');
1038
-
1039
- // Get GitHub token
1040
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1041
-
1042
- if (!tokenToUse) {
1043
- throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1044
- }
1045
-
1046
- // Initialize Octokit
1047
- const octokit = new Octokit({ auth: tokenToUse });
1048
-
1049
- // Get GitHub URL - either from parameter or from git remote
1050
- let repoUrl = githubUrl;
1051
- if (!repoUrl) {
1052
- console.log('🔍 Getting GitHub URL from git remote...');
1053
- try {
1054
- repoUrl = await getGitRemoteUrl(finalProjectPath);
1055
- if (!repoUrl.includes('github.com')) {
1056
- throw new Error('Project does not have a GitHub remote configured');
1057
- }
1058
- console.log(`✅ Found GitHub remote: ${repoUrl}`);
1059
- } catch (error) {
1060
- throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1061
- }
1062
- }
1063
-
1064
- // Parse GitHub URL to get owner and repo
1065
- const { owner, repo } = parseGitHubUrl(repoUrl);
1066
- console.log(`📦 Repository: ${owner}/${repo}`);
1067
-
1068
- // Use provided branch name or auto-generate from message
1069
- const finalBranchName = branchName || autogenerateBranchName(message);
1070
- if (branchName) {
1071
- console.log(`🌿 Using provided branch name: ${finalBranchName}`);
1072
-
1073
- // Validate custom branch name
1074
- const validation = validateBranchName(finalBranchName);
1075
- if (!validation.valid) {
1076
- throw new Error(`Invalid branch name: ${validation.error}`);
1077
- }
1078
- } else {
1079
- console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
1080
- }
1081
-
1082
- if (createBranch) {
1083
- // Create and checkout the new branch locally
1084
- console.log('🔄 Creating local branch...');
1085
- const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1086
- cwd: finalProjectPath,
1087
- stdio: 'pipe'
1088
- });
1089
-
1090
- await new Promise((resolve, reject) => {
1091
- let stderr = '';
1092
- checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1093
- checkoutProcess.on('close', (code) => {
1094
- if (code === 0) {
1095
- console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
1096
- resolve();
1097
- } else {
1098
- // Branch might already exist locally, try to checkout
1099
- if (stderr.includes('already exists')) {
1100
- console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);
1101
- const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1102
- cwd: finalProjectPath,
1103
- stdio: 'pipe'
1104
- });
1105
- checkoutExisting.on('close', (checkoutCode) => {
1106
- if (checkoutCode === 0) {
1107
- console.log(`✅ Checked out existing branch '${finalBranchName}'`);
1108
- resolve();
1109
- } else {
1110
- reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1111
- }
1112
- });
1113
- } else {
1114
- reject(new Error(`Failed to create branch: ${stderr}`));
1115
- }
1116
- }
1117
- });
1118
- });
1119
-
1120
- // Push the branch to remote
1121
- console.log('🔄 Pushing branch to remote...');
1122
- const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1123
- cwd: finalProjectPath,
1124
- stdio: 'pipe'
1125
- });
1126
-
1127
- await new Promise((resolve, reject) => {
1128
- let stderr = '';
1129
- let stdout = '';
1130
- pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1131
- pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1132
- pushProcess.on('close', (code) => {
1133
- if (code === 0) {
1134
- console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
1135
- resolve();
1136
- } else {
1137
- // Check if branch exists on remote but has different commits
1138
- if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1139
- console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);
1140
- resolve();
1141
- } else {
1142
- reject(new Error(`Failed to push branch: ${stderr}`));
1143
- }
1144
- }
1145
- });
1146
- });
1147
-
1148
- branchInfo = {
1149
- name: finalBranchName,
1150
- url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1151
- };
1152
- }
1153
-
1154
- if (createPR) {
1155
- // Get commit messages to generate PR description
1156
- console.log('🔄 Generating PR title and description...');
1157
- const commitMessages = await getCommitMessages(finalProjectPath, 5);
1158
-
1159
- // Use the first commit message as the PR title, or fallback to the agent message
1160
- const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1161
-
1162
- // Generate PR body from commit messages
1163
- let prBody = '## Changes\n\n';
1164
- if (commitMessages.length > 0) {
1165
- prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1166
- } else {
1167
- prBody += `Agent task: ${message}`;
1168
- }
1169
- prBody += '\n\n---\n*This pull request was automatically created by CloudCLI.ai Agent.*';
1170
-
1171
- console.log(`📝 PR Title: ${prTitle}`);
1172
-
1173
- // Create the pull request
1174
- console.log('🔄 Creating pull request...');
1175
- prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1176
- }
1177
-
1178
- // Send branch/PR info in response
1179
- if (stream) {
1180
- if (branchInfo) {
1181
- writer.send({
1182
- type: 'github-branch',
1183
- branch: branchInfo
1184
- });
1185
- }
1186
- if (prInfo) {
1187
- writer.send({
1188
- type: 'github-pr',
1189
- pullRequest: prInfo
1190
- });
1191
- }
1192
- }
1193
-
1194
- } catch (error) {
1195
- console.error('❌ GitHub branch/PR creation error:', error);
1196
-
1197
- // Send error but don't fail the entire request
1198
- if (stream) {
1199
- writer.send({
1200
- type: 'github-error',
1201
- error: error.message
1202
- });
1203
- }
1204
- // Store error info for non-streaming response
1205
- if (!stream) {
1206
- branchInfo = { error: error.message };
1207
- prInfo = { error: error.message };
1208
- }
1209
- }
1210
- }
1211
-
1212
- // Handle response based on streaming mode
1213
- if (stream) {
1214
- // Streaming mode: end the SSE stream
1215
- writer.end();
1216
- } else {
1217
- // Non-streaming mode: send filtered messages and token summary as JSON
1218
- const assistantMessages = writer.getAssistantMessages();
1219
- const tokenSummary = writer.getTotalTokens();
1220
-
1221
- const response = {
1222
- success: true,
1223
- sessionId: writer.getSessionId(),
1224
- messages: assistantMessages,
1225
- tokens: tokenSummary,
1226
- projectPath: finalProjectPath
1227
- };
1228
-
1229
- // Add branch/PR info if created
1230
- if (branchInfo) {
1231
- response.branch = branchInfo;
1232
- }
1233
- if (prInfo) {
1234
- response.pullRequest = prInfo;
1235
- }
1236
-
1237
- res.json(response);
1238
- }
1239
-
1240
- // Clean up if requested
1241
- if (cleanup && githubUrl && clonedProjectCreated) {
1242
- // Only cleanup if we cloned a repo (not for existing project paths)
1243
- const sessionIdForCleanup = writer.getSessionId();
1244
- setTimeout(() => {
1245
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1246
- }, 5000);
1247
- }
1248
-
1249
- } catch (error) {
1250
- console.error('❌ External session error:', error);
1251
-
1252
- // Clean up on error
1253
- if (finalProjectPath && cleanup && githubUrl && clonedProjectCreated) {
1254
- const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1255
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1256
- }
1257
-
1258
- if (stream) {
1259
- // For streaming, send error event and stop
1260
- if (!writer) {
1261
- // Set up SSE headers if not already done
1262
- res.setHeader('Content-Type', 'text/event-stream');
1263
- res.setHeader('Cache-Control', 'no-cache');
1264
- res.setHeader('Connection', 'keep-alive');
1265
- res.setHeader('X-Accel-Buffering', 'no');
1266
- writer = new SSEStreamWriter(res, req.user.id);
1267
- }
1268
-
1269
- if (!res.writableEnded) {
1270
- writer.send({
1271
- type: 'error',
1272
- error: error.message,
1273
- message: `Failed: ${error.message}`
1274
- });
1275
- writer.end();
1276
- }
1277
- } else if (!res.headersSent) {
1278
- res.status(500).json({
1279
- success: false,
1280
- error: error.message
1281
- });
1282
- }
1283
- }
1284
- });
1285
-
1286
- return router;
1287
- }
1
+ // @ts-nocheck -- dynamic provider event payloads are normalized at the injected writer boundary.
2
+ import path from 'path';
3
+
4
+ import express from 'express';
5
+
6
+ import type { ProviderRunFunction } from '@/shared/types.js';
7
+
8
+ import { normalizeProjectPath } from '../../shared/utils.js';
9
+
10
+ type AgentRouterDependencies = {
11
+ fileSystem: typeof import('node:fs/promises');
12
+ crypto: typeof import('node:crypto');
13
+ homeDirectory(): string;
14
+ spawnProcess: typeof import('cross-spawn').default;
15
+ platformMode: boolean;
16
+ users: { getFirstUser(): unknown };
17
+ apiKeys: { validateApiKey(apiKey: string): unknown };
18
+ githubTokens: { getActiveGithubToken(userId: number): string | null };
19
+ projects: { createProjectPath(projectPath: string, customName: string | null): unknown };
20
+ models: typeof import('../providers/index.js').providerModelsService;
21
+ queryClaude: ProviderRunFunction;
22
+ queryCursor: ProviderRunFunction;
23
+ queryCodex: ProviderRunFunction;
24
+ queryOpenCode: ProviderRunFunction;
25
+ GithubClient: typeof import('@octokit/rest').Octokit;
26
+ };
27
+
28
+ /**
29
+ * Creates Agent routes around explicit authentication, repository, provider,
30
+ * filesystem, subprocess, runtime, and GitHub dependencies.
31
+ */
32
+ export function createAgentRouter(dependencies: AgentRouterDependencies): express.Router {
33
+ const fs = dependencies.fileSystem;
34
+ const crypto = dependencies.crypto;
35
+ const os = { homedir: dependencies.homeDirectory };
36
+ const spawn = dependencies.spawnProcess;
37
+ const IS_PLATFORM = dependencies.platformMode;
38
+ const userDb = dependencies.users;
39
+ const apiKeysDb = dependencies.apiKeys;
40
+ const githubTokensDb = dependencies.githubTokens;
41
+ const projectsDb = dependencies.projects;
42
+ const providerModelsService = dependencies.models;
43
+ const queryClaudeSDK = dependencies.queryClaude;
44
+ const spawnCursor = dependencies.queryCursor;
45
+ const queryCodex = dependencies.queryCodex;
46
+ const spawnOpenCode = dependencies.queryOpenCode;
47
+ const Octokit = dependencies.GithubClient;
48
+ const router = express.Router();
49
+
50
+ /**
51
+ * Middleware to authenticate agent API requests.
52
+ *
53
+ * Supports two authentication modes:
54
+ * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
55
+ * authentication is handled by an external proxy. Requests are trusted and
56
+ * the default user context is used.
57
+ *
58
+ * 2. API key mode (default): For self-hosted deployments where users authenticate
59
+ * via API keys created in the UI. Keys are validated against the local database.
60
+ */
61
+ const validateExternalApiKey = (req, res, next) => {
62
+ // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
63
+ // Trust the request and use the default user context.
64
+ if (IS_PLATFORM) {
65
+ try {
66
+ const user = userDb.getFirstUser();
67
+ if (!user) {
68
+ return res.status(500).json({ error: 'Platform mode: No user found in database' });
69
+ }
70
+ req.user = user;
71
+ return next();
72
+ } catch (error) {
73
+ console.error('Platform mode error:', error);
74
+ return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
75
+ }
76
+ }
77
+
78
+ // Self-hosted mode: Validate API key from header or query parameter
79
+ const apiKey = req.headers['x-api-key'] || req.query.apiKey;
80
+
81
+ if (!apiKey) {
82
+ return res.status(401).json({ error: 'API key required' });
83
+ }
84
+
85
+ const user = apiKeysDb.validateApiKey(apiKey);
86
+
87
+ if (!user) {
88
+ return res.status(401).json({ error: 'Invalid or inactive API key' });
89
+ }
90
+
91
+ req.user = user;
92
+ next();
93
+ };
94
+
95
+ /**
96
+ * Get the remote URL of a git repository
97
+ * @param {string} repoPath - Path to the git repository
98
+ * @returns {Promise<string>} - Remote URL of the repository
99
+ */
100
+ async function getGitRemoteUrl(repoPath) {
101
+ return new Promise((resolve, reject) => {
102
+ const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
103
+ cwd: repoPath,
104
+ stdio: ['pipe', 'pipe', 'pipe']
105
+ });
106
+
107
+ let stdout = '';
108
+ let stderr = '';
109
+
110
+ gitProcess.stdout.on('data', (data) => {
111
+ stdout += data.toString();
112
+ });
113
+
114
+ gitProcess.stderr.on('data', (data) => {
115
+ stderr += data.toString();
116
+ });
117
+
118
+ gitProcess.on('close', (code) => {
119
+ if (code === 0) {
120
+ resolve(stdout.trim());
121
+ } else {
122
+ reject(new Error(`Failed to get git remote: ${stderr}`));
123
+ }
124
+ });
125
+
126
+ gitProcess.on('error', (error) => {
127
+ reject(new Error(`Failed to execute git: ${error.message}`));
128
+ });
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Normalize GitHub URLs for comparison
134
+ * @param {string} url - GitHub URL
135
+ * @returns {string} - Normalized URL
136
+ */
137
+ function normalizeGitHubUrl(url) {
138
+ // Remove .git suffix
139
+ let normalized = url.replace(/\.git$/, '');
140
+ // Convert SSH to HTTPS format for comparison
141
+ normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
142
+ // Remove trailing slash
143
+ normalized = normalized.replace(/\/$/, '');
144
+ return normalized.toLowerCase();
145
+ }
146
+
147
+ /**
148
+ * Parse GitHub URL to extract owner and repo
149
+ * @param {string} url - GitHub URL (HTTPS or SSH)
150
+ * @returns {{owner: string, repo: string}} - Parsed owner and repo
151
+ */
152
+ function parseGitHubUrl(url) {
153
+ // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
154
+ // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
155
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
156
+ if (!match) {
157
+ throw new Error('Invalid GitHub URL format');
158
+ }
159
+ return {
160
+ owner: match[1],
161
+ repo: match[2].replace(/\.git$/, '')
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Auto-generate a branch name from a message
167
+ * @param {string} message - The agent message
168
+ * @returns {string} - Generated branch name
169
+ */
170
+ function autogenerateBranchName(message) {
171
+ // Convert to lowercase, replace spaces/special chars with hyphens
172
+ let branchName = message
173
+ .toLowerCase()
174
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
175
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
176
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
177
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
178
+
179
+ // Ensure non-empty fallback
180
+ if (!branchName) {
181
+ branchName = 'task';
182
+ }
183
+
184
+ // Generate timestamp suffix (last 6 chars of base36 timestamp)
185
+ const timestamp = Date.now().toString(36).slice(-6);
186
+ const suffix = `-${timestamp}`;
187
+
188
+ // Limit length to ensure total length including suffix fits within 50 characters
189
+ const maxBaseLength = 50 - suffix.length;
190
+ if (branchName.length > maxBaseLength) {
191
+ branchName = branchName.substring(0, maxBaseLength);
192
+ }
193
+
194
+ // Remove any trailing hyphen after truncation and ensure no leading hyphen
195
+ branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
196
+
197
+ // If still empty or starts with hyphen after cleanup, use fallback
198
+ if (!branchName || branchName.startsWith('-')) {
199
+ branchName = 'task';
200
+ }
201
+
202
+ // Combine base name with timestamp suffix
203
+ branchName = `${branchName}${suffix}`;
204
+
205
+ // Final validation: ensure it matches safe pattern
206
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
207
+ // Fallback to deterministic safe name
208
+ return `branch-${timestamp}`;
209
+ }
210
+
211
+ return branchName;
212
+ }
213
+
214
+ /**
215
+ * Validate a Git branch name
216
+ * @param {string} branchName - Branch name to validate
217
+ * @returns {{valid: boolean, error?: string}} - Validation result
218
+ */
219
+ function validateBranchName(branchName) {
220
+ if (!branchName || branchName.trim() === '') {
221
+ return { valid: false, error: 'Branch name cannot be empty' };
222
+ }
223
+
224
+ // Git branch name rules
225
+ const invalidPatterns = [
226
+ { pattern: /^\./, message: 'Branch name cannot start with a dot' },
227
+ { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
228
+ { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
229
+ { pattern: /\s/, message: 'Branch name cannot contain spaces' },
230
+ { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
231
+ { pattern: /@{/, message: 'Branch name cannot contain @{' },
232
+ { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
233
+ { pattern: /^\//, message: 'Branch name cannot start with a slash' },
234
+ { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
235
+ { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
236
+ ];
237
+
238
+ for (const { pattern, message } of invalidPatterns) {
239
+ if (pattern.test(branchName)) {
240
+ return { valid: false, error: message };
241
+ }
242
+ }
243
+
244
+ // Check for ASCII control characters
245
+ if (/[\x00-\x1F\x7F]/.test(branchName)) {
246
+ return { valid: false, error: 'Branch name cannot contain control characters' };
247
+ }
248
+
249
+ return { valid: true };
250
+ }
251
+
252
+ /**
253
+ * Get recent commit messages from a repository
254
+ * @param {string} projectPath - Path to the git repository
255
+ * @param {number} limit - Number of commits to retrieve (default: 5)
256
+ * @returns {Promise<string[]>} - Array of commit messages
257
+ */
258
+ async function getCommitMessages(projectPath, limit = 5) {
259
+ return new Promise((resolve, reject) => {
260
+ const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
261
+ cwd: projectPath,
262
+ stdio: ['pipe', 'pipe', 'pipe']
263
+ });
264
+
265
+ let stdout = '';
266
+ let stderr = '';
267
+
268
+ gitProcess.stdout.on('data', (data) => {
269
+ stdout += data.toString();
270
+ });
271
+
272
+ gitProcess.stderr.on('data', (data) => {
273
+ stderr += data.toString();
274
+ });
275
+
276
+ gitProcess.on('close', (code) => {
277
+ if (code === 0) {
278
+ const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
279
+ resolve(messages);
280
+ } else {
281
+ reject(new Error(`Failed to get commit messages: ${stderr}`));
282
+ }
283
+ });
284
+
285
+ gitProcess.on('error', (error) => {
286
+ reject(new Error(`Failed to execute git: ${error.message}`));
287
+ });
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Create a new branch on GitHub using the API
293
+ * @param {Octokit} octokit - Octokit instance
294
+ * @param {string} owner - Repository owner
295
+ * @param {string} repo - Repository name
296
+ * @param {string} branchName - Name of the new branch
297
+ * @param {string} baseBranch - Base branch to branch from (default: 'main')
298
+ * @returns {Promise<void>}
299
+ */
300
+
301
+ /**
302
+ * Create a pull request on GitHub
303
+ * @param {Octokit} octokit - Octokit instance
304
+ * @param {string} owner - Repository owner
305
+ * @param {string} repo - Repository name
306
+ * @param {string} branchName - Head branch name
307
+ * @param {string} title - PR title
308
+ * @param {string} body - PR body/description
309
+ * @param {string} baseBranch - Base branch (default: 'main')
310
+ * @returns {Promise<{number: number, url: string}>} - PR number and URL
311
+ */
312
+ async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
313
+ const { data: pr } = await octokit.pulls.create({
314
+ owner,
315
+ repo,
316
+ title,
317
+ head: branchName,
318
+ base: baseBranch,
319
+ body
320
+ });
321
+
322
+ console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
323
+
324
+ return {
325
+ number: pr.number,
326
+ url: pr.html_url
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Clone a GitHub repository to a directory
332
+ * @param {string} githubUrl - GitHub repository URL
333
+ * @param {string} githubToken - Optional GitHub token for private repos
334
+ * @param {string} projectPath - Path for cloning the repository
335
+ * @returns {Promise<{path: string, created: boolean}>} - Checkout path and ownership flag
336
+ */
337
+ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
338
+ return new Promise(async (resolve, reject) => {
339
+ try {
340
+ // Validate the host before using credentials or invoking Git.
341
+ let parsedGithubUrl;
342
+ try {
343
+ parsedGithubUrl = new URL(githubUrl);
344
+ } catch {
345
+ throw new Error('Invalid GitHub URL');
346
+ }
347
+ if (
348
+ parsedGithubUrl.protocol !== 'https:'
349
+ || parsedGithubUrl.hostname !== 'github.com'
350
+ || parsedGithubUrl.username
351
+ || parsedGithubUrl.password
352
+ ) {
353
+ throw new Error('Invalid GitHub URL');
354
+ }
355
+ const cloneUrl = parsedGithubUrl.toString();
356
+
357
+ const cloneDir = path.resolve(projectPath);
358
+
359
+ // Check if directory already exists
360
+ try {
361
+ await fs.access(cloneDir);
362
+ // Directory exists - check if it's a git repo with the same URL
363
+ try {
364
+ const existingUrl = await getGitRemoteUrl(cloneDir);
365
+ const normalizedExisting = normalizeGitHubUrl(existingUrl);
366
+ const normalizedRequested = normalizeGitHubUrl(cloneUrl);
367
+
368
+ if (normalizedExisting === normalizedRequested) {
369
+ console.log('✅ Repository already exists at path with correct URL');
370
+ return resolve({ path: cloneDir, created: false });
371
+ } else {
372
+ throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
373
+ }
374
+ } catch (gitError) {
375
+ throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
376
+ }
377
+ } catch (accessError) {
378
+ // Directory doesn't exist - proceed with clone
379
+ }
380
+
381
+ // Ensure parent directory exists
382
+ await fs.mkdir(path.dirname(cloneDir), { recursive: true });
383
+
384
+ console.log('🔄 Cloning repository:', githubUrl);
385
+ console.log('📁 Destination:', cloneDir);
386
+
387
+ // Execute git clone
388
+ const gitEnvironment = githubToken ? {
389
+ ...process.env,
390
+ GIT_CONFIG_COUNT: '2',
391
+ GIT_CONFIG_KEY_0: 'credential.helper',
392
+ GIT_CONFIG_VALUE_0: '',
393
+ GIT_CONFIG_KEY_1: 'credential.helper',
394
+ GIT_CONFIG_VALUE_1: '!f() { echo username=x-access-token; echo "password=$CLOUDCLI_GITHUB_TOKEN"; }; f',
395
+ CLOUDCLI_GITHUB_TOKEN: githubToken,
396
+ GIT_TERMINAL_PROMPT: '0'
397
+ } : process.env;
398
+ const gitProcess = spawn('git', ['clone', '--depth', '1', '--', cloneUrl, cloneDir], {
399
+ stdio: ['pipe', 'pipe', 'pipe'],
400
+ env: gitEnvironment
401
+ });
402
+
403
+ let stdout = '';
404
+ let stderr = '';
405
+
406
+ gitProcess.stdout.on('data', (data) => {
407
+ stdout += data.toString();
408
+ });
409
+
410
+ gitProcess.stderr.on('data', (data) => {
411
+ stderr += data.toString();
412
+ console.log('Git stderr:', data.toString());
413
+ });
414
+
415
+ gitProcess.on('close', (code) => {
416
+ if (code === 0) {
417
+ console.log('✅ Repository cloned successfully');
418
+ resolve({ path: cloneDir, created: true });
419
+ } else {
420
+ console.error('❌ Git clone failed:', stderr);
421
+ reject(new Error(`Git clone failed: ${stderr}`));
422
+ }
423
+ });
424
+
425
+ gitProcess.on('error', (error) => {
426
+ reject(new Error(`Failed to execute git: ${error.message}`));
427
+ });
428
+ } catch (error) {
429
+ reject(error);
430
+ }
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Clean up a temporary project directory and its Claude session
436
+ * @param {string} projectPath - Path to the project directory
437
+ * @param {string} sessionId - Session ID to clean up
438
+ */
439
+ async function cleanupProject(projectPath, sessionId = null) {
440
+ try {
441
+ const externalProjectsRoot = await fs.realpath(
442
+ path.join(os.homedir(), '.claude', 'external-projects')
443
+ );
444
+ const canonicalProjectPath = await fs.realpath(projectPath);
445
+ const relativeProjectPath = path.relative(externalProjectsRoot, canonicalProjectPath);
446
+ const isContained = relativeProjectPath !== ''
447
+ && relativeProjectPath !== '..'
448
+ && !relativeProjectPath.startsWith(`..${path.sep}`)
449
+ && !path.isAbsolute(relativeProjectPath);
450
+
451
+ if (!isContained) {
452
+ console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
453
+ return;
454
+ }
455
+
456
+ console.log('🧹 Cleaning up project:', projectPath);
457
+ await fs.rm(canonicalProjectPath, { recursive: true, force: true });
458
+ console.log('✅ Project cleaned up');
459
+
460
+ // Also clean up the Claude session directory if sessionId provided
461
+ if (sessionId) {
462
+ try {
463
+ const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
464
+ console.log('🧹 Cleaning up session directory:', sessionPath);
465
+ await fs.rm(sessionPath, { recursive: true, force: true });
466
+ console.log('✅ Session directory cleaned up');
467
+ } catch (error) {
468
+ console.error('⚠️ Failed to clean up session directory:', error.message);
469
+ }
470
+ }
471
+ } catch (error) {
472
+ console.error('❌ Failed to clean up project:', error);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
478
+ */
479
+ class SSEStreamWriter {
480
+ constructor(res, userId = null) {
481
+ this.res = res;
482
+ this.sessionId = null;
483
+ this.userId = userId;
484
+ this.isSSEStreamWriter = true; // Marker for transport detection
485
+ }
486
+
487
+ send(data) {
488
+ if (this.res.writableEnded) {
489
+ return;
490
+ }
491
+
492
+ // Format as SSE - providers send raw objects, we stringify
493
+ this.res.write(`data: ${JSON.stringify(data)}\n\n`);
494
+ }
495
+
496
+ end() {
497
+ if (!this.res.writableEnded) {
498
+ this.res.write('data: {"type":"done"}\n\n');
499
+ this.res.end();
500
+ }
501
+ }
502
+
503
+ setSessionId(sessionId) {
504
+ this.sessionId = sessionId;
505
+ this.send({ type: 'session-id', sessionId });
506
+ }
507
+
508
+ getSessionId() {
509
+ return this.sessionId;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Non-streaming response collector
515
+ */
516
+ class ResponseCollector {
517
+ constructor(userId = null) {
518
+ this.messages = [];
519
+ this.sessionId = null;
520
+ this.userId = userId;
521
+ }
522
+
523
+ send(data) {
524
+ // Store ALL messages for now - we'll filter when returning
525
+ this.messages.push(data);
526
+
527
+ // Extract sessionId if present
528
+ if (typeof data === 'string') {
529
+ try {
530
+ const parsed = JSON.parse(data);
531
+ if (parsed.sessionId) {
532
+ this.sessionId = parsed.sessionId;
533
+ }
534
+ } catch (e) {
535
+ // Not JSON, ignore
536
+ }
537
+ } else if (data && data.sessionId) {
538
+ this.sessionId = data.sessionId;
539
+ }
540
+ }
541
+
542
+ end() {
543
+ // Do nothing - we'll collect all messages
544
+ }
545
+
546
+ setSessionId(sessionId) {
547
+ this.sessionId = sessionId;
548
+ }
549
+
550
+ getSessionId() {
551
+ return this.sessionId;
552
+ }
553
+
554
+ getMessages() {
555
+ return this.messages;
556
+ }
557
+
558
+ /**
559
+ * Get filtered assistant messages only
560
+ */
561
+ getAssistantMessages() {
562
+ const assistantMessages = [];
563
+
564
+ for (const msg of this.messages) {
565
+ // Skip initial status message
566
+ if (msg && msg.type === 'status') {
567
+ continue;
568
+ }
569
+
570
+ // Handle JSON strings
571
+ if (typeof msg === 'string') {
572
+ try {
573
+ const parsed = JSON.parse(msg);
574
+ // Only include claude-response messages with assistant type
575
+ if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
576
+ assistantMessages.push(parsed.data);
577
+ }
578
+ } catch (e) {
579
+ // Not JSON, skip
580
+ }
581
+ }
582
+ }
583
+
584
+ return assistantMessages;
585
+ }
586
+
587
+ /**
588
+ * Calculate total tokens from all messages
589
+ */
590
+ getTotalTokens() {
591
+ let totalInput = 0;
592
+ let totalOutput = 0;
593
+ let totalCacheRead = 0;
594
+ let totalCacheCreation = 0;
595
+
596
+ for (const msg of this.messages) {
597
+ let data = msg;
598
+
599
+ // Parse if string
600
+ if (typeof msg === 'string') {
601
+ try {
602
+ data = JSON.parse(msg);
603
+ } catch (e) {
604
+ continue;
605
+ }
606
+ }
607
+
608
+ // Extract usage from claude-response messages
609
+ if (data && data.type === 'claude-response' && data.data) {
610
+ const msgData = data.data;
611
+ if (msgData.message && msgData.message.usage) {
612
+ const usage = msgData.message.usage;
613
+ totalInput += usage.input_tokens || 0;
614
+ totalOutput += usage.output_tokens || 0;
615
+ totalCacheRead += usage.cache_read_input_tokens || 0;
616
+ totalCacheCreation += usage.cache_creation_input_tokens || 0;
617
+ }
618
+ }
619
+ }
620
+
621
+ const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
622
+
623
+ return {
624
+ inputTokens,
625
+ outputTokens: totalOutput,
626
+ cacheReadTokens: totalCacheRead,
627
+ cacheCreationTokens: totalCacheCreation,
628
+ totalTokens: inputTokens + totalOutput
629
+ };
630
+ }
631
+ }
632
+
633
+ // ===============================
634
+ // External API Endpoint
635
+ // ===============================
636
+
637
+ /**
638
+ * POST /api/agent
639
+ *
640
+ * Trigger an AI agent to work on a project.
641
+ * Supports automatic GitHub branch and pull request creation after successful completion.
642
+ *
643
+ * ================================================================================================
644
+ * REQUEST BODY PARAMETERS
645
+ * ================================================================================================
646
+ *
647
+ * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
648
+ * Supported formats:
649
+ * - HTTPS: https://github.com/owner/repo
650
+ * - HTTPS with .git: https://github.com/owner/repo.git
651
+ * - SSH: git@github.com:owner/repo
652
+ * - SSH with .git: git@github.com:owner/repo.git
653
+ *
654
+ * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
655
+ * Behavior depends on usage:
656
+ * - If used alone: Must point to existing project directory
657
+ * - If used with githubUrl: Target location for cloning
658
+ * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
659
+ *
660
+ * @param {string} message - (Required) Task description for the AI agent. Used as:
661
+ * - Instructions for the agent
662
+ * - Source for auto-generated branch names (if createBranch=true and no branchName)
663
+ * - Fallback for PR title if no commits are made
664
+ *
665
+ * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode'
666
+ * Default: 'claude'
667
+ *
668
+ * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
669
+ * Default: true
670
+ * - true: Returns text/event-stream with incremental updates
671
+ * - false: Returns complete JSON response after completion
672
+ *
673
+ * @param {string} model - (Optional) Model identifier for providers.
674
+ *
675
+ * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable'
676
+ * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
677
+ * 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
678
+ * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
679
+ * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
680
+ * Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini'
681
+ *
682
+ * @param {string} effort - (Optional) Reasoning effort for providers/models that support it.
683
+ * Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model.
684
+ * Codex supports: 'low', 'medium', 'high', 'xhigh'.
685
+ * 'default' or omission lets the provider decide.
686
+ *
687
+ * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
688
+ * Default: true
689
+ * Behavior:
690
+ * - Only applies when cloning via githubUrl (not for existing projectPath)
691
+ * - Deletes cloned repository after 5 seconds
692
+ * - Also deletes associated Claude session directory
693
+ * - Remote branch and PR remain on GitHub if created
694
+ *
695
+ * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
696
+ * Overrides stored token from user settings.
697
+ * Required for:
698
+ * - Private repositories
699
+ * - Branch/PR creation features
700
+ * Token must have 'repo' scope for full functionality.
701
+ *
702
+ * @param {string} branchName - (Optional) Custom name for the Git branch.
703
+ * If provided, createBranch is automatically set to true.
704
+ * Validation rules (errors returned if violated):
705
+ * - Cannot be empty or whitespace only
706
+ * - Cannot start or end with dot (.)
707
+ * - Cannot contain consecutive dots (..)
708
+ * - Cannot contain spaces
709
+ * - Cannot contain special characters: ~ ^ : ? * [ \
710
+ * - Cannot contain @{
711
+ * - Cannot start or end with forward slash (/)
712
+ * - Cannot contain consecutive slashes (//)
713
+ * - Cannot end with .lock
714
+ * - Cannot contain ASCII control characters
715
+ * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
716
+ *
717
+ * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
718
+ * Default: false (or true if branchName is provided)
719
+ * Behavior:
720
+ * - Creates branch locally and pushes to remote
721
+ * - If branch exists locally: Checks out existing branch (no error)
722
+ * - If branch exists on remote: Uses existing branch (no error)
723
+ * - Branch name: Custom (if branchName provided) or auto-generated from message
724
+ * - Requires either githubUrl OR projectPath with GitHub remote
725
+ *
726
+ * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
727
+ * Default: false
728
+ * Behavior:
729
+ * - PR title: First commit message (or fallback to message parameter)
730
+ * - PR description: Auto-generated from all commit messages
731
+ * - Base branch: Always 'main' (currently hardcoded)
732
+ * - If PR already exists: GitHub returns error with details
733
+ * - Requires either githubUrl OR projectPath with GitHub remote
734
+ *
735
+ * ================================================================================================
736
+ * PATH HANDLING BEHAVIOR
737
+ * ================================================================================================
738
+ *
739
+ * Scenario 1: Only githubUrl provided
740
+ * Input: { githubUrl: "https://github.com/owner/repo" }
741
+ * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
742
+ * Cleanup: Yes (if cleanup=true)
743
+ *
744
+ * Scenario 2: Only projectPath provided
745
+ * Input: { projectPath: "/home/user/my-project" }
746
+ * Action: Uses existing project at specified path
747
+ * Validation: Path must exist and be accessible
748
+ * Cleanup: No (never cleanup existing projects)
749
+ *
750
+ * Scenario 3: Both githubUrl and projectPath provided
751
+ * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
752
+ * Action: Clones githubUrl to projectPath location
753
+ * Validation:
754
+ * - If projectPath exists with git repo:
755
+ * - Compares remote URL with githubUrl
756
+ * - If URLs match: Reuses existing repo
757
+ * - If URLs differ: Returns error
758
+ * Cleanup: Yes (if cleanup=true)
759
+ *
760
+ * ================================================================================================
761
+ * GITHUB BRANCH/PR CREATION REQUIREMENTS
762
+ * ================================================================================================
763
+ *
764
+ * For createBranch or createPR to work, one of the following must be true:
765
+ *
766
+ * Option A: githubUrl provided
767
+ * - Repository URL directly specified
768
+ * - Works with both cloning and existing paths
769
+ *
770
+ * Option B: projectPath with GitHub remote
771
+ * - Project must be a Git repository
772
+ * - Must have 'origin' remote configured
773
+ * - Remote URL must point to github.com
774
+ * - System auto-detects GitHub URL via: git remote get-url origin
775
+ *
776
+ * Additional Requirements:
777
+ * - Valid GitHub token (from settings or githubToken parameter)
778
+ * - Token must have 'repo' scope for private repos
779
+ * - Project must have commits (for PR creation)
780
+ *
781
+ * ================================================================================================
782
+ * VALIDATION & ERROR HANDLING
783
+ * ================================================================================================
784
+ *
785
+ * Input Validations (400 Bad Request):
786
+ * - Either githubUrl OR projectPath must be provided (not neither)
787
+ * - message must be non-empty string
788
+ * - provider must be 'claude', 'cursor', 'codex', or 'opencode'
789
+ * - createBranch/createPR requires githubUrl OR projectPath (not neither)
790
+ * - branchName must pass Git naming rules (if provided)
791
+ *
792
+ * Runtime Validations (500 Internal Server Error or specific error in response):
793
+ * - projectPath must exist (if used alone)
794
+ * - GitHub URL format must be valid
795
+ * - Git remote URL must include github.com (for projectPath + branch/PR)
796
+ * - GitHub token must be available (for private repos and branch/PR)
797
+ * - Directory conflicts handled (existing path with different repo)
798
+ *
799
+ * Branch Name Validation Errors (returned in response, not HTTP error):
800
+ * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
801
+ * Examples:
802
+ * - "my branch" → "Branch name cannot contain spaces"
803
+ * - ".feature" → "Branch name cannot start with a dot"
804
+ * - "feature.lock" → "Branch name cannot end with .lock"
805
+ *
806
+ * ================================================================================================
807
+ * RESPONSE FORMATS
808
+ * ================================================================================================
809
+ *
810
+ * Streaming Response (stream=true):
811
+ * Content-Type: text/event-stream
812
+ * Events:
813
+ * - { type: "status", message: "...", projectPath: "..." }
814
+ * - { type: "claude-response", data: {...} }
815
+ * - { type: "github-branch", branch: { name: "...", url: "..." } }
816
+ * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
817
+ * - { type: "github-error", error: "..." }
818
+ * - { type: "done" }
819
+ *
820
+ * Non-Streaming Response (stream=false):
821
+ * Content-Type: application/json
822
+ * {
823
+ * success: true,
824
+ * sessionId: "session-123",
825
+ * messages: [...], // Assistant messages only (filtered)
826
+ * tokens: {
827
+ * inputTokens: 150,
828
+ * outputTokens: 50,
829
+ * cacheReadTokens: 0,
830
+ * cacheCreationTokens: 0,
831
+ * totalTokens: 200
832
+ * },
833
+ * projectPath: "/path/to/project",
834
+ * branch: { // Only if createBranch=true
835
+ * name: "feature/xyz",
836
+ * url: "https://github.com/owner/repo/tree/feature/xyz"
837
+ * } | { error: "..." },
838
+ * pullRequest: { // Only if createPR=true
839
+ * number: 42,
840
+ * url: "https://github.com/owner/repo/pull/42"
841
+ * } | { error: "..." }
842
+ * }
843
+ *
844
+ * Error Response:
845
+ * HTTP Status: 400, 401, 500
846
+ * Content-Type: application/json
847
+ * { success: false, error: "Error description" }
848
+ *
849
+ * ================================================================================================
850
+ * EXAMPLES
851
+ * ================================================================================================
852
+ *
853
+ * Example 1: Clone and process with auto-cleanup
854
+ * POST /api/agent
855
+ * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
856
+ *
857
+ * Example 2: Use existing project with custom branch and PR
858
+ * POST /api/agent
859
+ * {
860
+ * "projectPath": "/home/user/project",
861
+ * "message": "Add feature",
862
+ * "branchName": "feature/new-feature",
863
+ * "createPR": true
864
+ * }
865
+ *
866
+ * Example 3: Clone to specific path with auto-generated branch
867
+ * POST /api/agent
868
+ * {
869
+ * "githubUrl": "https://github.com/user/repo",
870
+ * "projectPath": "/tmp/work",
871
+ * "message": "Refactor code",
872
+ * "createBranch": true,
873
+ * "cleanup": false
874
+ * }
875
+ */
876
+ router.post('/', validateExternalApiKey, async (req, res) => {
877
+ const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
878
+ const effort = typeof req.body.effort === 'string' && req.body.effort.trim()
879
+ ? req.body.effort.trim()
880
+ : undefined;
881
+
882
+ // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
883
+ const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
884
+ const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
885
+
886
+ // If branchName is provided, automatically enable createBranch
887
+ const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
888
+ const createPR = req.body.createPR === true || req.body.createPR === 'true';
889
+
890
+ // Validate inputs
891
+ if (!githubUrl && !projectPath) {
892
+ return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
893
+ }
894
+
895
+ if (!message || !message.trim()) {
896
+ return res.status(400).json({ error: 'message is required' });
897
+ }
898
+
899
+ if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) {
900
+ return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' });
901
+ }
902
+
903
+ // Validate GitHub branch/PR creation requirements
904
+ // Allow branch/PR creation with projectPath as long as it has a GitHub remote
905
+ if ((createBranch || createPR) && !githubUrl && !projectPath) {
906
+ return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
907
+ }
908
+
909
+ let finalProjectPath = null;
910
+ let clonedProjectCreated = false;
911
+ let writer = null;
912
+
913
+ try {
914
+ // Determine the final project path
915
+ if (githubUrl) {
916
+ // Clone repository (to projectPath if provided, otherwise generate path)
917
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
918
+
919
+ let targetPath;
920
+ if (projectPath) {
921
+ targetPath = projectPath;
922
+ } else {
923
+ // Generate a unique path for cloning
924
+ const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
925
+ targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
926
+ }
927
+
928
+ const clonedProject = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
929
+ finalProjectPath = clonedProject.path;
930
+ clonedProjectCreated = clonedProject.created;
931
+ } else {
932
+ // Use existing project path
933
+ finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
934
+
935
+ // Verify the path exists
936
+ try {
937
+ await fs.access(finalProjectPath);
938
+ } catch (error) {
939
+ throw new Error(`Project path does not exist: ${finalProjectPath}`);
940
+ }
941
+ }
942
+
943
+ finalProjectPath = normalizeProjectPath(finalProjectPath);
944
+
945
+ // Register project path in DB (or reuse existing active registration)
946
+ const registrationResult = projectsDb.createProjectPath(finalProjectPath, null);
947
+ if (registrationResult.outcome === 'active_conflict') {
948
+ console.log('Project registration already exists for:', finalProjectPath);
949
+ } else {
950
+ console.log('Project registered:', registrationResult.project);
951
+ }
952
+
953
+ // Set up writer based on streaming mode
954
+ if (stream) {
955
+ // Set up SSE headers for streaming
956
+ res.setHeader('Content-Type', 'text/event-stream');
957
+ res.setHeader('Cache-Control', 'no-cache');
958
+ res.setHeader('Connection', 'keep-alive');
959
+ res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
960
+
961
+ writer = new SSEStreamWriter(res, req.user.id);
962
+
963
+ // Send initial status
964
+ writer.send({
965
+ type: 'status',
966
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
967
+ projectPath: finalProjectPath
968
+ });
969
+ } else {
970
+ // Non-streaming mode: collect messages
971
+ writer = new ResponseCollector(req.user.id);
972
+
973
+ // Collect initial status message
974
+ writer.send({
975
+ type: 'status',
976
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
977
+ projectPath: finalProjectPath
978
+ });
979
+ }
980
+
981
+ const codexModels = (await providerModelsService.getProviderModels('codex')).models;
982
+ const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
983
+
984
+ // Start the appropriate session
985
+ if (provider === 'claude') {
986
+ console.log('🤖 Starting Claude SDK session');
987
+
988
+ await queryClaudeSDK(message.trim(), {
989
+ projectPath: finalProjectPath,
990
+ cwd: finalProjectPath,
991
+ sessionId: sessionId || null,
992
+ model: model,
993
+ effort,
994
+ permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
995
+ }, writer);
996
+
997
+ } else if (provider === 'cursor') {
998
+ console.log('🖱️ Starting Cursor CLI session');
999
+
1000
+ await spawnCursor(message.trim(), {
1001
+ projectPath: finalProjectPath,
1002
+ cwd: finalProjectPath,
1003
+ sessionId: sessionId || null,
1004
+ model: model || undefined,
1005
+ skipPermissions: true // Bypass permissions for Cursor
1006
+ }, writer);
1007
+ } else if (provider === 'codex') {
1008
+ console.log('🤖 Starting Codex SDK session');
1009
+
1010
+ await queryCodex(message.trim(), {
1011
+ projectPath: finalProjectPath,
1012
+ cwd: finalProjectPath,
1013
+ sessionId: sessionId || null,
1014
+ model: model || codexModels.DEFAULT,
1015
+ effort,
1016
+ permissionMode: 'bypassPermissions'
1017
+ }, writer);
1018
+ } else if (provider === 'opencode') {
1019
+ console.log('Starting OpenCode CLI session');
1020
+
1021
+ await spawnOpenCode(message.trim(), {
1022
+ projectPath: finalProjectPath,
1023
+ cwd: finalProjectPath,
1024
+ sessionId: sessionId || null,
1025
+ model: model || opencodeModels.DEFAULT,
1026
+ effort,
1027
+ permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above
1028
+ }, writer);
1029
+ }
1030
+
1031
+ // Handle GitHub branch and PR creation after successful agent completion
1032
+ let branchInfo = null;
1033
+ let prInfo = null;
1034
+
1035
+ if (createBranch || createPR) {
1036
+ try {
1037
+ console.log('🔄 Starting GitHub branch/PR creation workflow...');
1038
+
1039
+ // Get GitHub token
1040
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1041
+
1042
+ if (!tokenToUse) {
1043
+ throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1044
+ }
1045
+
1046
+ // Initialize Octokit
1047
+ const octokit = new Octokit({ auth: tokenToUse });
1048
+
1049
+ // Get GitHub URL - either from parameter or from git remote
1050
+ let repoUrl = githubUrl;
1051
+ if (!repoUrl) {
1052
+ console.log('🔍 Getting GitHub URL from git remote...');
1053
+ try {
1054
+ repoUrl = await getGitRemoteUrl(finalProjectPath);
1055
+ if (!repoUrl.includes('github.com')) {
1056
+ throw new Error('Project does not have a GitHub remote configured');
1057
+ }
1058
+ console.log(`✅ Found GitHub remote: ${repoUrl}`);
1059
+ } catch (error) {
1060
+ throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1061
+ }
1062
+ }
1063
+
1064
+ // Parse GitHub URL to get owner and repo
1065
+ const { owner, repo } = parseGitHubUrl(repoUrl);
1066
+ console.log(`📦 Repository: ${owner}/${repo}`);
1067
+
1068
+ // Use provided branch name or auto-generate from message
1069
+ const finalBranchName = branchName || autogenerateBranchName(message);
1070
+ if (branchName) {
1071
+ console.log(`🌿 Using provided branch name: ${finalBranchName}`);
1072
+
1073
+ // Validate custom branch name
1074
+ const validation = validateBranchName(finalBranchName);
1075
+ if (!validation.valid) {
1076
+ throw new Error(`Invalid branch name: ${validation.error}`);
1077
+ }
1078
+ } else {
1079
+ console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
1080
+ }
1081
+
1082
+ if (createBranch) {
1083
+ // Create and checkout the new branch locally
1084
+ console.log('🔄 Creating local branch...');
1085
+ const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1086
+ cwd: finalProjectPath,
1087
+ stdio: 'pipe'
1088
+ });
1089
+
1090
+ await new Promise((resolve, reject) => {
1091
+ let stderr = '';
1092
+ checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1093
+ checkoutProcess.on('close', (code) => {
1094
+ if (code === 0) {
1095
+ console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
1096
+ resolve();
1097
+ } else {
1098
+ // Branch might already exist locally, try to checkout
1099
+ if (stderr.includes('already exists')) {
1100
+ console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);
1101
+ const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1102
+ cwd: finalProjectPath,
1103
+ stdio: 'pipe'
1104
+ });
1105
+ checkoutExisting.on('close', (checkoutCode) => {
1106
+ if (checkoutCode === 0) {
1107
+ console.log(`✅ Checked out existing branch '${finalBranchName}'`);
1108
+ resolve();
1109
+ } else {
1110
+ reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1111
+ }
1112
+ });
1113
+ } else {
1114
+ reject(new Error(`Failed to create branch: ${stderr}`));
1115
+ }
1116
+ }
1117
+ });
1118
+ });
1119
+
1120
+ // Push the branch to remote
1121
+ console.log('🔄 Pushing branch to remote...');
1122
+ const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1123
+ cwd: finalProjectPath,
1124
+ stdio: 'pipe'
1125
+ });
1126
+
1127
+ await new Promise((resolve, reject) => {
1128
+ let stderr = '';
1129
+ let stdout = '';
1130
+ pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1131
+ pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1132
+ pushProcess.on('close', (code) => {
1133
+ if (code === 0) {
1134
+ console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
1135
+ resolve();
1136
+ } else {
1137
+ // Check if branch exists on remote but has different commits
1138
+ if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1139
+ console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);
1140
+ resolve();
1141
+ } else {
1142
+ reject(new Error(`Failed to push branch: ${stderr}`));
1143
+ }
1144
+ }
1145
+ });
1146
+ });
1147
+
1148
+ branchInfo = {
1149
+ name: finalBranchName,
1150
+ url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1151
+ };
1152
+ }
1153
+
1154
+ if (createPR) {
1155
+ // Get commit messages to generate PR description
1156
+ console.log('🔄 Generating PR title and description...');
1157
+ const commitMessages = await getCommitMessages(finalProjectPath, 5);
1158
+
1159
+ // Use the first commit message as the PR title, or fallback to the agent message
1160
+ const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1161
+
1162
+ // Generate PR body from commit messages
1163
+ let prBody = '## Changes\n\n';
1164
+ if (commitMessages.length > 0) {
1165
+ prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1166
+ } else {
1167
+ prBody += `Agent task: ${message}`;
1168
+ }
1169
+ prBody += '\n\n---\n*This pull request was automatically created by WindCli Agent.*';
1170
+
1171
+ console.log(`📝 PR Title: ${prTitle}`);
1172
+
1173
+ // Create the pull request
1174
+ console.log('🔄 Creating pull request...');
1175
+ prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1176
+ }
1177
+
1178
+ // Send branch/PR info in response
1179
+ if (stream) {
1180
+ if (branchInfo) {
1181
+ writer.send({
1182
+ type: 'github-branch',
1183
+ branch: branchInfo
1184
+ });
1185
+ }
1186
+ if (prInfo) {
1187
+ writer.send({
1188
+ type: 'github-pr',
1189
+ pullRequest: prInfo
1190
+ });
1191
+ }
1192
+ }
1193
+
1194
+ } catch (error) {
1195
+ console.error('❌ GitHub branch/PR creation error:', error);
1196
+
1197
+ // Send error but don't fail the entire request
1198
+ if (stream) {
1199
+ writer.send({
1200
+ type: 'github-error',
1201
+ error: error.message
1202
+ });
1203
+ }
1204
+ // Store error info for non-streaming response
1205
+ if (!stream) {
1206
+ branchInfo = { error: error.message };
1207
+ prInfo = { error: error.message };
1208
+ }
1209
+ }
1210
+ }
1211
+
1212
+ // Handle response based on streaming mode
1213
+ if (stream) {
1214
+ // Streaming mode: end the SSE stream
1215
+ writer.end();
1216
+ } else {
1217
+ // Non-streaming mode: send filtered messages and token summary as JSON
1218
+ const assistantMessages = writer.getAssistantMessages();
1219
+ const tokenSummary = writer.getTotalTokens();
1220
+
1221
+ const response = {
1222
+ success: true,
1223
+ sessionId: writer.getSessionId(),
1224
+ messages: assistantMessages,
1225
+ tokens: tokenSummary,
1226
+ projectPath: finalProjectPath
1227
+ };
1228
+
1229
+ // Add branch/PR info if created
1230
+ if (branchInfo) {
1231
+ response.branch = branchInfo;
1232
+ }
1233
+ if (prInfo) {
1234
+ response.pullRequest = prInfo;
1235
+ }
1236
+
1237
+ res.json(response);
1238
+ }
1239
+
1240
+ // Clean up if requested
1241
+ if (cleanup && githubUrl && clonedProjectCreated) {
1242
+ // Only cleanup if we cloned a repo (not for existing project paths)
1243
+ const sessionIdForCleanup = writer.getSessionId();
1244
+ setTimeout(() => {
1245
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1246
+ }, 5000);
1247
+ }
1248
+
1249
+ } catch (error) {
1250
+ console.error('❌ External session error:', error);
1251
+
1252
+ // Clean up on error
1253
+ if (finalProjectPath && cleanup && githubUrl && clonedProjectCreated) {
1254
+ const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1255
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1256
+ }
1257
+
1258
+ if (stream) {
1259
+ // For streaming, send error event and stop
1260
+ if (!writer) {
1261
+ // Set up SSE headers if not already done
1262
+ res.setHeader('Content-Type', 'text/event-stream');
1263
+ res.setHeader('Cache-Control', 'no-cache');
1264
+ res.setHeader('Connection', 'keep-alive');
1265
+ res.setHeader('X-Accel-Buffering', 'no');
1266
+ writer = new SSEStreamWriter(res, req.user.id);
1267
+ }
1268
+
1269
+ if (!res.writableEnded) {
1270
+ writer.send({
1271
+ type: 'error',
1272
+ error: error.message,
1273
+ message: `Failed: ${error.message}`
1274
+ });
1275
+ writer.end();
1276
+ }
1277
+ } else if (!res.headersSent) {
1278
+ res.status(500).json({
1279
+ success: false,
1280
+ error: error.message
1281
+ });
1282
+ }
1283
+ }
1284
+ });
1285
+
1286
+ return router;
1287
+ }