@pixelbyte-software/pixcode 1.54.10 → 1.55.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 (534) hide show
  1. package/CODE_OF_CONDUCT.md +41 -41
  2. package/CONTRIBUTING.md +156 -156
  3. package/LICENSE +21 -718
  4. package/README.de.md +169 -169
  5. package/README.ja.md +167 -167
  6. package/README.ko.md +167 -167
  7. package/README.md +435 -418
  8. package/README.ru.md +169 -169
  9. package/README.tr.md +298 -298
  10. package/README.zh-CN.md +167 -167
  11. package/SECURITY.md +46 -46
  12. package/dist-server/server/claude-sdk.js +1 -1
  13. package/dist-server/server/claude-sdk.js.map +1 -1
  14. package/dist-server/server/cli.js +100 -100
  15. package/dist-server/server/daemon/manager.js +33 -33
  16. package/dist-server/server/daemon-manager.js +64 -64
  17. package/dist-server/server/index.js +6 -11
  18. package/dist-server/server/index.js.map +1 -1
  19. package/dist-server/server/modules/security/permission-policy.js.map +1 -0
  20. package/dist-server/server/modules/tasks/git-worktree.js +98 -0
  21. package/dist-server/server/modules/tasks/git-worktree.js.map +1 -0
  22. package/dist-server/server/modules/tasks/index.js +8 -0
  23. package/dist-server/server/modules/tasks/index.js.map +1 -0
  24. package/dist-server/server/modules/tasks/prompt-builder.js +85 -0
  25. package/dist-server/server/modules/tasks/prompt-builder.js.map +1 -0
  26. package/dist-server/server/modules/tasks/role-presets.js +94 -0
  27. package/dist-server/server/modules/tasks/role-presets.js.map +1 -0
  28. package/dist-server/server/modules/tasks/runners/claude-runner.js +109 -0
  29. package/dist-server/server/modules/tasks/runners/claude-runner.js.map +1 -0
  30. package/dist-server/server/modules/tasks/runners/codex-runner.js +67 -0
  31. package/dist-server/server/modules/tasks/runners/codex-runner.js.map +1 -0
  32. package/dist-server/server/modules/tasks/runners/spawn-runner.js +111 -0
  33. package/dist-server/server/modules/tasks/runners/spawn-runner.js.map +1 -0
  34. package/dist-server/server/modules/tasks/runners/types.js +5 -0
  35. package/dist-server/server/modules/tasks/runners/types.js.map +1 -0
  36. package/dist-server/server/modules/tasks/scheduler.js +69 -0
  37. package/dist-server/server/modules/tasks/scheduler.js.map +1 -0
  38. package/dist-server/server/modules/tasks/task-queue.js +81 -0
  39. package/dist-server/server/modules/tasks/task-queue.js.map +1 -0
  40. package/dist-server/server/modules/tasks/task-routes.js +195 -0
  41. package/dist-server/server/modules/tasks/task-routes.js.map +1 -0
  42. package/dist-server/server/modules/tasks/task-runner.js +187 -0
  43. package/dist-server/server/modules/tasks/task-runner.js.map +1 -0
  44. package/dist-server/server/modules/tasks/task-store.js +196 -0
  45. package/dist-server/server/modules/tasks/task-store.js.map +1 -0
  46. package/dist-server/server/modules/tasks/task-types.js +5 -0
  47. package/dist-server/server/modules/tasks/task-types.js.map +1 -0
  48. package/dist-server/server/modules/tasks/user-interaction.js +73 -0
  49. package/dist-server/server/modules/tasks/user-interaction.js.map +1 -0
  50. package/dist-server/server/routes/commands.js +25 -25
  51. package/dist-server/server/routes/git.js +17 -17
  52. package/dist-server/server/routes/live-view.js +46 -46
  53. package/dist-server/server/services/control-room.js +9 -1
  54. package/dist-server/server/services/control-room.js.map +1 -1
  55. package/dist-server/server/services/public-api-manifest.js +54 -54
  56. package/dist-server/server/services/public-api-manifest.js.map +1 -1
  57. package/dist-server/server/services/telegram/control-center.js +10 -10
  58. package/dist-server/server/services/telegram/control-center.js.map +1 -1
  59. package/dist-server/server/services/telegram/telegram-gateway.js +2 -2
  60. package/dist-server/server/services/telegram/telegram-gateway.js.map +1 -1
  61. package/dist-server/server/services/telegram/translations.js +5 -5
  62. package/dist-server/server/services/telegram/translations.js.map +1 -1
  63. package/package.json +224 -224
  64. package/scripts/fix-node-pty.js +67 -67
  65. package/scripts/github/create-v1.38-issues.mjs +351 -351
  66. package/scripts/github/create-vscode-workbench-issues.mjs +121 -121
  67. package/scripts/smoke/backend-resource-bounds.mjs +152 -152
  68. package/scripts/smoke/changes-panel-layout.mjs +48 -48
  69. package/scripts/smoke/chat-composer-fixed-layout.mjs +55 -55
  70. package/scripts/smoke/chat-message-timeline-order.mjs +41 -41
  71. package/scripts/smoke/chat-realtime-hydration.mjs +44 -44
  72. package/scripts/smoke/chat-session-provider-pools.mjs +35 -35
  73. package/scripts/smoke/chat-session-state.mjs +19 -19
  74. package/scripts/smoke/code-editor-theme.mjs +55 -55
  75. package/scripts/smoke/code-editor-vscode-engine.mjs +91 -91
  76. package/scripts/smoke/command-center-agent-writes.mjs +79 -79
  77. package/scripts/smoke/command-center-non-git.mjs +46 -46
  78. package/scripts/smoke/context-packet.mjs +43 -43
  79. package/scripts/smoke/control-room-ux-redesign.mjs +91 -91
  80. package/scripts/smoke/daemon-entrypoint.mjs +20 -20
  81. package/scripts/smoke/default-landing-routing.mjs +33 -33
  82. package/scripts/smoke/desktop-native-notifications.mjs +30 -30
  83. package/scripts/smoke/desktop-tray-icon.mjs +33 -33
  84. package/scripts/smoke/discord-release-workflow.mjs +24 -24
  85. package/scripts/smoke/git-install-update.mjs +255 -255
  86. package/scripts/smoke/handoff-artifact-protocol.mjs +50 -50
  87. package/scripts/smoke/live-view-diagnostics.mjs +53 -53
  88. package/scripts/smoke/live-view-environment.mjs +92 -92
  89. package/scripts/smoke/live-view-integration.mjs +450 -450
  90. package/scripts/smoke/mac-desktop-runtime.mjs +37 -37
  91. package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -29
  92. package/scripts/smoke/mobile-ux.mjs +76 -76
  93. package/scripts/smoke/model-registry.mjs +36 -36
  94. package/scripts/smoke/multi-project-ui.mjs +45 -45
  95. package/scripts/smoke/multi-worker-slots.mjs +42 -42
  96. package/scripts/smoke/notification-center.mjs +87 -87
  97. package/scripts/smoke/notification-inapp-preference.mjs +23 -23
  98. package/scripts/smoke/notification-taxonomy.mjs +58 -58
  99. package/scripts/smoke/orchestration-api.mjs +172 -172
  100. package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -33
  101. package/scripts/smoke/orchestration-live-run.mjs +176 -176
  102. package/scripts/smoke/orchestration-mobile-scroll.mjs +29 -29
  103. package/scripts/smoke/orchestration-model-sync.mjs +30 -30
  104. package/scripts/smoke/orchestration-permission-fallback.mjs +34 -34
  105. package/scripts/smoke/orchestration-runtime-guards.mjs +48 -48
  106. package/scripts/smoke/orchestration-user-facing-output.mjs +25 -25
  107. package/scripts/smoke/permission-policy.mjs +50 -50
  108. package/scripts/smoke/pixcode-workbench-1-48.mjs +167 -167
  109. package/scripts/smoke/provider-models-opencode-live.mjs +66 -66
  110. package/scripts/smoke/provider-rest-api.mjs +124 -124
  111. package/scripts/smoke/provider-selection-status.mjs +52 -52
  112. package/scripts/smoke/run-state-refresh.mjs +52 -52
  113. package/scripts/smoke/runtime-manager.mjs +99 -99
  114. package/scripts/smoke/shell-manual-disconnect.mjs +30 -30
  115. package/scripts/smoke/side-panel-editor-layout.mjs +34 -34
  116. package/scripts/smoke/static-root-routing.mjs +21 -21
  117. package/scripts/smoke/strict-handoff-compact.mjs +60 -60
  118. package/scripts/smoke/taskmaster-config.mjs +24 -24
  119. package/scripts/smoke/taskmaster-execution-telegram.mjs +3 -3
  120. package/scripts/smoke/taskmaster-onboarding.mjs +3 -3
  121. package/scripts/smoke/taskmaster-run-graph.mjs +3 -3
  122. package/scripts/smoke/telegram-control.mjs +331 -331
  123. package/scripts/smoke/tunnel-persistence.mjs +56 -56
  124. package/scripts/smoke/update-issue-progress.mjs +69 -69
  125. package/scripts/smoke/update-ux.mjs +55 -55
  126. package/scripts/smoke/v138-completion.mjs +132 -132
  127. package/scripts/smoke/v138-desktop-release-hardening.mjs +69 -69
  128. package/scripts/smoke/v138-diagnostics.mjs +63 -63
  129. package/scripts/smoke/v138-issue-planner.mjs +33 -33
  130. package/scripts/smoke/v143-remote-control.mjs +76 -76
  131. package/scripts/smoke/v144-production-loop.mjs +47 -47
  132. package/scripts/smoke/v145-platformization.mjs +46 -46
  133. package/scripts/smoke/v146-control-room-ui.mjs +150 -150
  134. package/scripts/smoke/version-modal-autoshow.mjs +29 -29
  135. package/scripts/smoke/vscode-workbench-layout.mjs +63 -63
  136. package/scripts/smoke/vscode-workbench-polish.mjs +461 -461
  137. package/scripts/smoke/workflow-fallback-replay.mjs +56 -56
  138. package/scripts/smoke/workflow-templates.mjs +43 -43
  139. package/scripts/smoke/workflow-trace-timeline.mjs +46 -46
  140. package/scripts/update-git-install.mjs +298 -298
  141. package/server/claude-sdk.js +927 -927
  142. package/server/cli.js +1106 -1106
  143. package/server/constants/config.js +4 -4
  144. package/server/cursor-cli.js +344 -344
  145. package/server/daemon/manager.js +563 -563
  146. package/server/daemon-manager.js +964 -964
  147. package/server/database/db.js +988 -988
  148. package/server/database/json-store.js +197 -197
  149. package/server/gemini-cli.js +550 -550
  150. package/server/gemini-response-handler.js +79 -79
  151. package/server/index.js +5654 -5671
  152. package/server/load-env.js +35 -35
  153. package/server/middleware/account-lockout.js +112 -112
  154. package/server/middleware/auth.js +277 -277
  155. package/server/middleware/rate-limiter.js +87 -87
  156. package/server/modules/providers/index.ts +2 -2
  157. package/server/modules/providers/list/claude/claude-auth.provider.ts +146 -146
  158. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  159. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  160. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  161. package/server/modules/providers/list/codex/codex-auth.provider.ts +117 -117
  162. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  163. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  164. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  165. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +147 -147
  166. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  167. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  168. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  169. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +173 -173
  170. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  171. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  172. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  173. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +131 -131
  174. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +286 -286
  175. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +146 -146
  176. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  177. package/server/modules/providers/provider.registry.ts +40 -40
  178. package/server/modules/providers/provider.routes.ts +982 -982
  179. package/server/modules/providers/services/mcp.service.ts +86 -86
  180. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  181. package/server/modules/providers/services/sessions.service.ts +45 -45
  182. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  183. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  184. package/server/modules/providers/tests/mcp.test.ts +293 -293
  185. package/server/modules/{orchestration/security → security}/permission-policy.ts +401 -401
  186. package/server/modules/tasks/git-worktree.ts +102 -0
  187. package/server/modules/tasks/index.ts +9 -0
  188. package/server/modules/tasks/prompt-builder.ts +98 -0
  189. package/server/modules/tasks/role-presets.ts +113 -0
  190. package/server/modules/tasks/runners/claude-runner.ts +120 -0
  191. package/server/modules/tasks/runners/codex-runner.ts +80 -0
  192. package/server/modules/tasks/runners/spawn-runner.ts +122 -0
  193. package/server/modules/tasks/runners/types.ts +25 -0
  194. package/server/modules/tasks/scheduler.ts +75 -0
  195. package/server/modules/tasks/task-queue.ts +91 -0
  196. package/server/modules/tasks/task-routes.ts +245 -0
  197. package/server/modules/tasks/task-runner.ts +219 -0
  198. package/server/modules/tasks/task-store.ts +223 -0
  199. package/server/modules/tasks/task-types.ts +100 -0
  200. package/server/modules/tasks/user-interaction.ts +93 -0
  201. package/server/openai-codex.js +468 -468
  202. package/server/opencode-cli.js +491 -491
  203. package/server/opencode-response-handler.js +111 -111
  204. package/server/projects.js +3135 -3135
  205. package/server/qwen-code-cli.js +410 -410
  206. package/server/routes/agent.js +1462 -1462
  207. package/server/routes/auth.js +298 -298
  208. package/server/routes/codex.js +20 -20
  209. package/server/routes/commands.js +581 -581
  210. package/server/routes/cursor.js +61 -61
  211. package/server/routes/diagnostics.js +44 -44
  212. package/server/routes/gemini.js +25 -25
  213. package/server/routes/git.js +1822 -1822
  214. package/server/routes/live-view.js +434 -434
  215. package/server/routes/mcp-utils.js +13 -13
  216. package/server/routes/messages.js +97 -97
  217. package/server/routes/network.js +143 -143
  218. package/server/routes/platformization.js +231 -231
  219. package/server/routes/plugins.js +690 -690
  220. package/server/routes/production-agent-loop.js +90 -90
  221. package/server/routes/projects.js +918 -918
  222. package/server/routes/public-api.js +34 -34
  223. package/server/routes/qwen.js +27 -27
  224. package/server/routes/remote.js +56 -56
  225. package/server/routes/settings.js +321 -321
  226. package/server/routes/telegram.js +163 -163
  227. package/server/routes/user.js +125 -125
  228. package/server/routes/webhooks.js +63 -63
  229. package/server/services/control-room.js +111 -102
  230. package/server/services/diagnostics.js +165 -165
  231. package/server/services/external-access.js +403 -403
  232. package/server/services/install-jobs.js +715 -715
  233. package/server/services/live-view.js +956 -956
  234. package/server/services/managed-runtimes.js +493 -493
  235. package/server/services/model-registry.js +144 -144
  236. package/server/services/notification-orchestrator.js +365 -365
  237. package/server/services/notification-taxonomy.js +204 -204
  238. package/server/services/platformization.js +1052 -1052
  239. package/server/services/production-agent-loop.js +248 -248
  240. package/server/services/provider-cli-versions.js +149 -149
  241. package/server/services/provider-credentials.js +189 -189
  242. package/server/services/provider-models.js +396 -396
  243. package/server/services/public-api-manifest.js +181 -181
  244. package/server/services/qr-login.js +126 -126
  245. package/server/services/remote-connection.js +127 -127
  246. package/server/services/runtime-manager.js +323 -323
  247. package/server/services/startup-update.js +259 -259
  248. package/server/services/telegram/bot.js +393 -393
  249. package/server/services/telegram/control-center.js +2453 -2453
  250. package/server/services/telegram/telegram-gateway.js +314 -314
  251. package/server/services/telegram/telegram-http-client.js +201 -201
  252. package/server/services/telegram/translations.js +470 -470
  253. package/server/services/webhooks.js +216 -216
  254. package/server/sessionManager.js +225 -225
  255. package/server/setup-wizard.js +283 -283
  256. package/server/shared/interfaces.ts +54 -54
  257. package/server/shared/types.ts +172 -172
  258. package/server/shared/utils.ts +193 -193
  259. package/server/tsconfig.json +36 -36
  260. package/server/utils/colors.js +21 -21
  261. package/server/utils/commandParser.js +305 -305
  262. package/server/utils/frontmatter.js +18 -18
  263. package/server/utils/gitConfig.js +34 -34
  264. package/server/utils/plugin-loader.js +461 -461
  265. package/server/utils/plugin-process-manager.js +185 -185
  266. package/server/utils/port-access.js +227 -227
  267. package/server/utils/runtime-paths.js +37 -37
  268. package/server/utils/security-log.js +84 -84
  269. package/server/utils/url-detection.js +71 -71
  270. package/server/vite-daemon.js +79 -79
  271. package/shared/modelConstants.js +161 -161
  272. package/shared/networkHosts.js +22 -22
  273. package/dist/api-automation.html +0 -110
  274. package/dist/api-docs.html +0 -548
  275. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  276. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  277. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  278. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  279. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  280. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  281. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  282. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  283. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  284. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  285. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  286. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  287. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  288. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  289. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  290. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  291. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  292. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  293. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  294. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  295. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  296. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  297. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  298. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  299. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  300. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  301. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  302. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  303. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  304. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  305. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  306. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  307. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  308. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  309. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  310. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  311. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  312. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  313. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  314. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  315. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  316. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  317. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  318. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  319. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  320. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  321. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  322. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  323. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  324. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  325. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  326. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  327. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  328. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  329. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  330. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  331. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  332. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  333. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  334. package/dist/assets/index-DWOW_Jn0.js +0 -877
  335. package/dist/assets/index-Dz1luE0u.css +0 -32
  336. package/dist/assets/index-ak1p_4ew.js +0 -11
  337. package/dist/assets/localMonaco-DFIbtDNp.js +0 -1
  338. package/dist/assets/vendor-codemirror-CIYNS698.js +0 -41
  339. package/dist/assets/vendor-react-DB6V5Fl1.js +0 -59
  340. package/dist/assets/vendor-xterm-C7tpxJl7.js +0 -9
  341. package/dist/clear-cache.html +0 -85
  342. package/dist/convert-icons.md +0 -53
  343. package/dist/docs.html +0 -308
  344. package/dist/favicon.png +0 -0
  345. package/dist/favicon.svg +0 -8
  346. package/dist/features.html +0 -133
  347. package/dist/generate-icons.js +0 -49
  348. package/dist/humans.txt +0 -15
  349. package/dist/icons/claude-ai-icon.svg +0 -1
  350. package/dist/icons/codex-white.svg +0 -3
  351. package/dist/icons/codex.svg +0 -3
  352. package/dist/icons/cursor-white.svg +0 -12
  353. package/dist/icons/cursor.svg +0 -1
  354. package/dist/icons/gemini-ai-icon.svg +0 -1
  355. package/dist/icons/icon-128x128.png +0 -0
  356. package/dist/icons/icon-128x128.svg +0 -9
  357. package/dist/icons/icon-144x144.png +0 -0
  358. package/dist/icons/icon-144x144.svg +0 -9
  359. package/dist/icons/icon-152x152.png +0 -0
  360. package/dist/icons/icon-152x152.svg +0 -9
  361. package/dist/icons/icon-192x192.png +0 -0
  362. package/dist/icons/icon-192x192.svg +0 -9
  363. package/dist/icons/icon-384x384.png +0 -0
  364. package/dist/icons/icon-384x384.svg +0 -9
  365. package/dist/icons/icon-512x512.png +0 -0
  366. package/dist/icons/icon-512x512.svg +0 -9
  367. package/dist/icons/icon-72x72.png +0 -0
  368. package/dist/icons/icon-72x72.svg +0 -9
  369. package/dist/icons/icon-96x96.png +0 -0
  370. package/dist/icons/icon-96x96.svg +0 -9
  371. package/dist/icons/icon-template.svg +0 -9
  372. package/dist/icons/opencode-logo-dark.svg +0 -1
  373. package/dist/icons/opencode-logo-light.svg +0 -1
  374. package/dist/icons/qwen-ai-icon.png +0 -0
  375. package/dist/icons/qwen-logo.svg +0 -15
  376. package/dist/index.html +0 -62
  377. package/dist/landing.html +0 -268
  378. package/dist/llms-full.txt +0 -119
  379. package/dist/llms.txt +0 -53
  380. package/dist/logo-128.png +0 -0
  381. package/dist/logo-256.png +0 -0
  382. package/dist/logo-32.png +0 -0
  383. package/dist/logo-512.png +0 -0
  384. package/dist/logo-64.png +0 -0
  385. package/dist/logo.png +0 -0
  386. package/dist/logo.svg +0 -12
  387. package/dist/manifest.json +0 -61
  388. package/dist/openapi.yaml +0 -1696
  389. package/dist/orchestration.html +0 -125
  390. package/dist/robots.txt +0 -4
  391. package/dist/screenshots/cli-selection.png +0 -0
  392. package/dist/screenshots/desktop-main.png +0 -0
  393. package/dist/screenshots/mobile-chat.png +0 -0
  394. package/dist/screenshots/tools-modal.png +0 -0
  395. package/dist/site.css +0 -692
  396. package/dist/sitemap.xml +0 -51
  397. package/dist/sw.js +0 -132
  398. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +0 -73
  399. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +0 -1
  400. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js +0 -17
  401. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +0 -1
  402. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +0 -236
  403. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +0 -1
  404. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js +0 -202
  405. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js.map +0 -1
  406. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js +0 -205
  407. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js.map +0 -1
  408. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js +0 -205
  409. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js.map +0 -1
  410. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js +0 -84
  411. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js.map +0 -1
  412. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js +0 -43
  413. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js.map +0 -1
  414. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js +0 -205
  415. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js.map +0 -1
  416. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js +0 -205
  417. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js.map +0 -1
  418. package/dist-server/server/modules/orchestration/a2a/agent-card.js +0 -50
  419. package/dist-server/server/modules/orchestration/a2a/agent-card.js.map +0 -1
  420. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js +0 -25
  421. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js.map +0 -1
  422. package/dist-server/server/modules/orchestration/a2a/bus.js +0 -34
  423. package/dist-server/server/modules/orchestration/a2a/bus.js.map +0 -1
  424. package/dist-server/server/modules/orchestration/a2a/routes.js +0 -502
  425. package/dist-server/server/modules/orchestration/a2a/routes.js.map +0 -1
  426. package/dist-server/server/modules/orchestration/a2a/task-dispatcher.js +0 -295
  427. package/dist-server/server/modules/orchestration/a2a/task-dispatcher.js.map +0 -1
  428. package/dist-server/server/modules/orchestration/a2a/task-store.js +0 -144
  429. package/dist-server/server/modules/orchestration/a2a/task-store.js.map +0 -1
  430. package/dist-server/server/modules/orchestration/a2a/types.js +0 -6
  431. package/dist-server/server/modules/orchestration/a2a/types.js.map +0 -1
  432. package/dist-server/server/modules/orchestration/a2a/validator.js +0 -101
  433. package/dist-server/server/modules/orchestration/a2a/validator.js.map +0 -1
  434. package/dist-server/server/modules/orchestration/index.js +0 -27
  435. package/dist-server/server/modules/orchestration/index.js.map +0 -1
  436. package/dist-server/server/modules/orchestration/preview/port-watcher.js +0 -90
  437. package/dist-server/server/modules/orchestration/preview/port-watcher.js.map +0 -1
  438. package/dist-server/server/modules/orchestration/preview/preview-proxy.js +0 -58
  439. package/dist-server/server/modules/orchestration/preview/preview-proxy.js.map +0 -1
  440. package/dist-server/server/modules/orchestration/preview/types.js +0 -2
  441. package/dist-server/server/modules/orchestration/preview/types.js.map +0 -1
  442. package/dist-server/server/modules/orchestration/security/permission-policy.js.map +0 -1
  443. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js +0 -34
  444. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js.map +0 -1
  445. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js +0 -70
  446. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js.map +0 -1
  447. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js +0 -181
  448. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js.map +0 -1
  449. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js +0 -2
  450. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js.map +0 -1
  451. package/dist-server/server/modules/orchestration/tasks/task-run-graph.js +0 -100
  452. package/dist-server/server/modules/orchestration/tasks/task-run-graph.js.map +0 -1
  453. package/dist-server/server/modules/orchestration/workflows/approval-queue.js +0 -72
  454. package/dist-server/server/modules/orchestration/workflows/approval-queue.js.map +0 -1
  455. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js +0 -126
  456. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js.map +0 -1
  457. package/dist-server/server/modules/orchestration/workflows/context-packet.js +0 -89
  458. package/dist-server/server/modules/orchestration/workflows/context-packet.js.map +0 -1
  459. package/dist-server/server/modules/orchestration/workflows/handoff-artifact.js +0 -123
  460. package/dist-server/server/modules/orchestration/workflows/handoff-artifact.js.map +0 -1
  461. package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js +0 -114
  462. package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js.map +0 -1
  463. package/dist-server/server/modules/orchestration/workflows/workflow-replay.js +0 -177
  464. package/dist-server/server/modules/orchestration/workflows/workflow-replay.js.map +0 -1
  465. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +0 -1718
  466. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +0 -1
  467. package/dist-server/server/modules/orchestration/workflows/workflow-store.js +0 -76
  468. package/dist-server/server/modules/orchestration/workflows/workflow-store.js.map +0 -1
  469. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js +0 -242
  470. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js.map +0 -1
  471. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js +0 -388
  472. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js.map +0 -1
  473. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +0 -508
  474. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +0 -1
  475. package/dist-server/server/modules/orchestration/workflows/workflow.types.js +0 -2
  476. package/dist-server/server/modules/orchestration/workflows/workflow.types.js.map +0 -1
  477. package/dist-server/server/modules/orchestration/workflows/workspace-target.js +0 -100
  478. package/dist-server/server/modules/orchestration/workflows/workspace-target.js.map +0 -1
  479. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js +0 -123
  480. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js.map +0 -1
  481. package/dist-server/server/modules/orchestration/workspace/path-safety.js +0 -48
  482. package/dist-server/server/modules/orchestration/workspace/path-safety.js.map +0 -1
  483. package/dist-server/server/modules/orchestration/workspace/types.js +0 -11
  484. package/dist-server/server/modules/orchestration/workspace/types.js.map +0 -1
  485. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js +0 -84
  486. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js.map +0 -1
  487. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js +0 -97
  488. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js.map +0 -1
  489. package/server/modules/orchestration/a2a/adapter-registry.ts +0 -108
  490. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +0 -63
  491. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +0 -286
  492. package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +0 -244
  493. package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +0 -249
  494. package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +0 -248
  495. package/server/modules/orchestration/a2a/adapters/json-event.adapter.test.ts +0 -60
  496. package/server/modules/orchestration/a2a/adapters/json-event.adapter.ts +0 -101
  497. package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +0 -248
  498. package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +0 -248
  499. package/server/modules/orchestration/a2a/agent-card.ts +0 -55
  500. package/server/modules/orchestration/a2a/auth.middleware.ts +0 -29
  501. package/server/modules/orchestration/a2a/bus.ts +0 -46
  502. package/server/modules/orchestration/a2a/routes.ts +0 -586
  503. package/server/modules/orchestration/a2a/task-dispatcher.ts +0 -345
  504. package/server/modules/orchestration/a2a/task-store.ts +0 -178
  505. package/server/modules/orchestration/a2a/types.ts +0 -126
  506. package/server/modules/orchestration/a2a/validator.ts +0 -113
  507. package/server/modules/orchestration/index.ts +0 -99
  508. package/server/modules/orchestration/preview/port-watcher.ts +0 -112
  509. package/server/modules/orchestration/preview/preview-proxy.ts +0 -60
  510. package/server/modules/orchestration/preview/types.ts +0 -19
  511. package/server/modules/orchestration/tasks/orchestration-task-store.ts +0 -41
  512. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +0 -81
  513. package/server/modules/orchestration/tasks/orchestration-task.service.ts +0 -201
  514. package/server/modules/orchestration/tasks/orchestration-task.types.ts +0 -40
  515. package/server/modules/orchestration/tasks/task-run-graph.ts +0 -155
  516. package/server/modules/orchestration/workflows/approval-queue.ts +0 -106
  517. package/server/modules/orchestration/workflows/built-in-workflows.ts +0 -127
  518. package/server/modules/orchestration/workflows/context-packet.ts +0 -186
  519. package/server/modules/orchestration/workflows/handoff-artifact.ts +0 -175
  520. package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +0 -161
  521. package/server/modules/orchestration/workflows/workflow-replay.ts +0 -254
  522. package/server/modules/orchestration/workflows/workflow-runner.ts +0 -2062
  523. package/server/modules/orchestration/workflows/workflow-store.ts +0 -97
  524. package/server/modules/orchestration/workflows/workflow-templates.ts +0 -272
  525. package/server/modules/orchestration/workflows/workflow-trace.ts +0 -424
  526. package/server/modules/orchestration/workflows/workflow.routes.ts +0 -586
  527. package/server/modules/orchestration/workflows/workflow.types.ts +0 -111
  528. package/server/modules/orchestration/workflows/workspace-target.ts +0 -122
  529. package/server/modules/orchestration/workspace/docker-workspace.ts +0 -136
  530. package/server/modules/orchestration/workspace/path-safety.ts +0 -55
  531. package/server/modules/orchestration/workspace/types.ts +0 -52
  532. package/server/modules/orchestration/workspace/workspace-manager.ts +0 -102
  533. package/server/modules/orchestration/workspace/worktree-workspace.ts +0 -126
  534. /package/dist-server/server/modules/{orchestration/security → security}/permission-policy.js +0 -0
@@ -1,1462 +1,1462 @@
1
- import { spawn } from 'child_process';
2
- import path from 'path';
3
- import os from 'os';
4
- import { promises as fs } from 'fs';
5
- import crypto from 'crypto';
6
-
7
- import express from 'express';
8
- import { Octokit } from '@octokit/rest';
9
-
10
- import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
11
- import { addProjectManually } from '../projects.js';
12
- import { queryClaudeSDK } from '../claude-sdk.js';
13
- import { spawnCursor } from '../cursor-cli.js';
14
- import { queryCodex } from '../openai-codex.js';
15
- import { spawnGemini } from '../gemini-cli.js';
16
- import { spawnQwen } from '../qwen-code-cli.js';
17
- import { spawnOpencode } from '../opencode-cli.js';
18
- import { IS_PLATFORM } from '../constants/config.js';
19
- import { getDefaultProviderModel } from '../services/model-registry.js';
20
-
21
- const router = express.Router();
22
- const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
23
- const EXTERNAL_PROJECTS_BASE = path.join(os.homedir(), '.claude', 'external-projects');
24
-
25
- function isPathWithinExternalProjects(resolvedPath) {
26
- const normalized = path.resolve(resolvedPath);
27
- return normalized.startsWith(EXTERNAL_PROJECTS_BASE + path.sep) || normalized === EXTERNAL_PROJECTS_BASE;
28
- }
29
-
30
- /**
31
- * Middleware to authenticate agent API requests.
32
- *
33
- * Supports two authentication modes:
34
- * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
35
- * authentication is handled by an external proxy. Requests are trusted and
36
- * the default user context is used.
37
- *
38
- * 2. API key mode (default): For self-hosted deployments where users authenticate
39
- * via API keys created in the UI. Keys are validated against the local database.
40
- */
41
- const validateExternalApiKey = (req, res, next) => {
42
- // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
43
- // Trust the request and use the default user context.
44
- if (IS_PLATFORM) {
45
- try {
46
- const user = userDb.getFirstUser();
47
- if (!user) {
48
- return res.status(500).json({ error: 'Platform mode: No user found in database' });
49
- }
50
- req.user = user;
51
- return next();
52
- } catch (error) {
53
- console.error('Platform mode error:', error);
54
- return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
55
- }
56
- }
57
-
58
- // Self-hosted mode: validate API key from any of the supported transports.
59
- // - Authorization: Bearer px_... (legacy ck_... still accepted)
60
- // auth shape as the rest of the API, per the auth-unify in this turn)
61
- // - X-API-Key: px_...
62
- // - ?apiKey=px_... (EventSource workaround)
63
- const authHeader = req.headers['authorization'];
64
- const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
65
- const apiKey = (isPixcodeApiKey(bearer) ? bearer : null)
66
- || req.headers['x-api-key']
67
- || (typeof req.query.apiKey === 'string' ? req.query.apiKey : null);
68
-
69
- if (!apiKey) {
70
- return res.status(401).json({ error: 'API key required (Authorization: Bearer px_..., X-API-Key, or ?apiKey=)' });
71
- }
72
-
73
- const user = apiKeysDb.validateApiKey(apiKey);
74
-
75
- if (!user) {
76
- return res.status(401).json({ error: 'Invalid or inactive API key' });
77
- }
78
-
79
- req.user = user;
80
- next();
81
- };
82
-
83
- /**
84
- * Get the remote URL of a git repository
85
- * @param {string} repoPath - Path to the git repository
86
- * @returns {Promise<string>} - Remote URL of the repository
87
- */
88
- async function getGitRemoteUrl(repoPath) {
89
- return new Promise((resolve, reject) => {
90
- const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
91
- cwd: repoPath,
92
- stdio: ['pipe', 'pipe', 'pipe']
93
- });
94
-
95
- let stdout = '';
96
- let stderr = '';
97
-
98
- gitProcess.stdout.on('data', (data) => {
99
- stdout += data.toString();
100
- });
101
-
102
- gitProcess.stderr.on('data', (data) => {
103
- stderr += data.toString();
104
- });
105
-
106
- gitProcess.on('close', (code) => {
107
- if (code === 0) {
108
- resolve(stdout.trim());
109
- } else {
110
- reject(new Error(`Failed to get git remote: ${stderr}`));
111
- }
112
- });
113
-
114
- gitProcess.on('error', (error) => {
115
- reject(new Error(`Failed to execute git: ${error.message}`));
116
- });
117
- });
118
- }
119
-
120
- /**
121
- * Normalize GitHub URLs for comparison
122
- * @param {string} url - GitHub URL
123
- * @returns {string} - Normalized URL
124
- */
125
- function normalizeGitHubUrl(url) {
126
- // Remove .git suffix
127
- let normalized = url.replace(/\.git$/, '');
128
- // Convert SSH to HTTPS format for comparison
129
- normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
130
- // Remove trailing slash
131
- normalized = normalized.replace(/\/$/, '');
132
- return normalized.toLowerCase();
133
- }
134
-
135
- /**
136
- * Parse GitHub URL to extract owner and repo
137
- * @param {string} url - GitHub URL (HTTPS or SSH)
138
- * @returns {{owner: string, repo: string}} - Parsed owner and repo
139
- */
140
- function parseGitHubUrl(url) {
141
- // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
142
- // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
143
- const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
144
- if (!match) {
145
- throw new Error('Invalid GitHub URL format');
146
- }
147
- return {
148
- owner: match[1],
149
- repo: match[2].replace(/\.git$/, '')
150
- };
151
- }
152
-
153
- /**
154
- * Auto-generate a branch name from a message
155
- * @param {string} message - The agent message
156
- * @returns {string} - Generated branch name
157
- */
158
- function autogenerateBranchName(message) {
159
- // Convert to lowercase, replace spaces/special chars with hyphens
160
- let branchName = message
161
- .toLowerCase()
162
- .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
163
- .replace(/\s+/g, '-') // Replace spaces with hyphens
164
- .replace(/-+/g, '-') // Replace multiple hyphens with single
165
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
166
-
167
- // Ensure non-empty fallback
168
- if (!branchName) {
169
- branchName = 'task';
170
- }
171
-
172
- // Generate timestamp suffix (last 6 chars of base36 timestamp)
173
- const timestamp = Date.now().toString(36).slice(-6);
174
- const suffix = `-${timestamp}`;
175
-
176
- // Limit length to ensure total length including suffix fits within 50 characters
177
- const maxBaseLength = 50 - suffix.length;
178
- if (branchName.length > maxBaseLength) {
179
- branchName = branchName.substring(0, maxBaseLength);
180
- }
181
-
182
- // Remove any trailing hyphen after truncation and ensure no leading hyphen
183
- branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
184
-
185
- // If still empty or starts with hyphen after cleanup, use fallback
186
- if (!branchName || branchName.startsWith('-')) {
187
- branchName = 'task';
188
- }
189
-
190
- // Combine base name with timestamp suffix
191
- branchName = `${branchName}${suffix}`;
192
-
193
- // Final validation: ensure it matches safe pattern
194
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
195
- // Fallback to deterministic safe name
196
- return `branch-${timestamp}`;
197
- }
198
-
199
- return branchName;
200
- }
201
-
202
- /**
203
- * Validate a Git branch name
204
- * @param {string} branchName - Branch name to validate
205
- * @returns {{valid: boolean, error?: string}} - Validation result
206
- */
207
- function validateBranchName(branchName) {
208
- if (!branchName || branchName.trim() === '') {
209
- return { valid: false, error: 'Branch name cannot be empty' };
210
- }
211
-
212
- // Git branch name rules
213
- const invalidPatterns = [
214
- { pattern: /^\./, message: 'Branch name cannot start with a dot' },
215
- { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
216
- { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
217
- { pattern: /\s/, message: 'Branch name cannot contain spaces' },
218
- { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
219
- { pattern: /@{/, message: 'Branch name cannot contain @{' },
220
- { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
221
- { pattern: /^\//, message: 'Branch name cannot start with a slash' },
222
- { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
223
- { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
224
- ];
225
-
226
- for (const { pattern, message } of invalidPatterns) {
227
- if (pattern.test(branchName)) {
228
- return { valid: false, error: message };
229
- }
230
- }
231
-
232
- // Check for ASCII control characters
233
- if (/[\x00-\x1F\x7F]/.test(branchName)) {
234
- return { valid: false, error: 'Branch name cannot contain control characters' };
235
- }
236
-
237
- return { valid: true };
238
- }
239
-
240
- function providerDisplayName(provider) {
241
- return ({
242
- claude: 'Claude',
243
- cursor: 'Cursor',
244
- codex: 'Codex',
245
- gemini: 'Gemini',
246
- qwen: 'Qwen',
247
- opencode: 'OpenCode',
248
- })[provider] || 'Provider';
249
- }
250
-
251
- function describeProviderFailure(rawError, provider) {
252
- const rawMessage = String(rawError || '').trim() || 'Provider returned no assistant text.';
253
- const normalized = rawMessage.toLowerCase();
254
- const name = providerDisplayName(provider);
255
-
256
- const details = {
257
- provider,
258
- providerName: name,
259
- category: 'provider_error',
260
- title: `${name} could not answer.`,
261
- action: 'Check the provider output, then retry with a shorter prompt or a different model.',
262
- rawMessage,
263
- };
264
-
265
- if (/(balance|billing|quota|credit|insufficient|payment required|402|usage limit|spend limit)/i.test(rawMessage)) {
266
- details.category = 'quota';
267
- details.title = `${name} could not answer because the account has no available balance or quota.`;
268
- details.action = 'Add credits, increase the provider usage limit, or switch to a free/available model.';
269
- } else if (/(rate limit|too many requests|429|temporarily overloaded|resource exhausted)/i.test(rawMessage)) {
270
- details.category = 'rate_limit';
271
- details.title = `${name} is rate limited right now.`;
272
- details.action = 'Wait a bit, reduce parallel runs, or switch to another provider/model.';
273
- } else if (/(unauthorized|forbidden|permission_denied|permission denied|api key|token|oauth|login|not authenticated|401|403|invalid credentials)/i.test(rawMessage)) {
274
- details.category = 'auth';
275
- details.title = `${name} is not authenticated or the selected model is not allowed.`;
276
- details.action = 'Reconnect this provider in Settings, refresh the CLI login, or choose a model enabled for the account.';
277
- } else if (/(not installed|command not found|enoent|spawn .* enoent|executable file not found|exited with code 127)/i.test(rawMessage)) {
278
- details.category = 'missing_cli';
279
- details.title = `${name} CLI is not installed or not on PATH.`;
280
- details.action = 'Install the CLI from Settings -> Agents or set the matching CLI path environment variable.';
281
- } else if (/(timeout|timed out|aborted|etimedout|deadline)/i.test(rawMessage)) {
282
- details.category = 'timeout';
283
- details.title = `${name} timed out before returning a complete answer.`;
284
- details.action = 'Retry with a shorter request, reduce orchestration parallelism, or inspect the provider session log.';
285
- } else if (normalized.includes('no assistant text') || normalized.includes('empty')) {
286
- details.category = 'no_output';
287
- details.title = `${name} finished without visible assistant text.`;
288
- details.action = 'Retry once; if it repeats, check provider stderr/session logs because the CLI may have exited before streaming text.';
289
- }
290
-
291
- details.message = `${details.title} ${details.action}`;
292
- return details;
293
- }
294
-
295
- /**
296
- * Get recent commit messages from a repository
297
- * @param {string} projectPath - Path to the git repository
298
- * @param {number} limit - Number of commits to retrieve (default: 5)
299
- * @returns {Promise<string[]>} - Array of commit messages
300
- */
301
- async function getCommitMessages(projectPath, limit = 5) {
302
- return new Promise((resolve, reject) => {
303
- const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
304
- cwd: projectPath,
305
- stdio: ['pipe', 'pipe', 'pipe']
306
- });
307
-
308
- let stdout = '';
309
- let stderr = '';
310
-
311
- gitProcess.stdout.on('data', (data) => {
312
- stdout += data.toString();
313
- });
314
-
315
- gitProcess.stderr.on('data', (data) => {
316
- stderr += data.toString();
317
- });
318
-
319
- gitProcess.on('close', (code) => {
320
- if (code === 0) {
321
- const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
322
- resolve(messages);
323
- } else {
324
- reject(new Error(`Failed to get commit messages: ${stderr}`));
325
- }
326
- });
327
-
328
- gitProcess.on('error', (error) => {
329
- reject(new Error(`Failed to execute git: ${error.message}`));
330
- });
331
- });
332
- }
333
-
334
- /**
335
- * Create a new branch on GitHub using the API
336
- * @param {Octokit} octokit - Octokit instance
337
- * @param {string} owner - Repository owner
338
- * @param {string} repo - Repository name
339
- * @param {string} branchName - Name of the new branch
340
- * @param {string} baseBranch - Base branch to branch from (default: 'main')
341
- * @returns {Promise<void>}
342
- */
343
- async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
344
- try {
345
- // Get the SHA of the base branch
346
- const { data: ref } = await octokit.git.getRef({
347
- owner,
348
- repo,
349
- ref: `heads/${baseBranch}`
350
- });
351
-
352
- const baseSha = ref.object.sha;
353
-
354
- // Create the new branch
355
- await octokit.git.createRef({
356
- owner,
357
- repo,
358
- ref: `refs/heads/${branchName}`,
359
- sha: baseSha
360
- });
361
-
362
- console.log(`✅ Created branch '${branchName}' on GitHub`);
363
- } catch (error) {
364
- if (error.status === 422 && error.message.includes('Reference already exists')) {
365
- console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`);
366
- } else {
367
- throw error;
368
- }
369
- }
370
- }
371
-
372
- /**
373
- * Create a pull request on GitHub
374
- * @param {Octokit} octokit - Octokit instance
375
- * @param {string} owner - Repository owner
376
- * @param {string} repo - Repository name
377
- * @param {string} branchName - Head branch name
378
- * @param {string} title - PR title
379
- * @param {string} body - PR body/description
380
- * @param {string} baseBranch - Base branch (default: 'main')
381
- * @returns {Promise<{number: number, url: string}>} - PR number and URL
382
- */
383
- async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
384
- const { data: pr } = await octokit.pulls.create({
385
- owner,
386
- repo,
387
- title,
388
- head: branchName,
389
- base: baseBranch,
390
- body
391
- });
392
-
393
- console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
394
-
395
- return {
396
- number: pr.number,
397
- url: pr.html_url
398
- };
399
- }
400
-
401
- /**
402
- * Clone a GitHub repository to a directory
403
- * @param {string} githubUrl - GitHub repository URL
404
- * @param {string} githubToken - Optional GitHub token for private repos
405
- * @param {string} projectPath - Path for cloning the repository
406
- * @returns {Promise<string>} - Path to the cloned repository
407
- */
408
- async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
409
- return new Promise(async (resolve, reject) => {
410
- try {
411
- // Validate GitHub URL
412
- if (!githubUrl || !githubUrl.includes('github.com')) {
413
- throw new Error('Invalid GitHub URL');
414
- }
415
-
416
- const cloneDir = path.resolve(projectPath);
417
-
418
- if (!isPathWithinExternalProjects(cloneDir)) {
419
- throw new Error('Clone directory must be within ~/.claude/external-projects');
420
- }
421
-
422
- // Check if directory already exists
423
- try {
424
- await fs.access(cloneDir);
425
- // Directory exists - check if it's a git repo with the same URL
426
- try {
427
- const existingUrl = await getGitRemoteUrl(cloneDir);
428
- const normalizedExisting = normalizeGitHubUrl(existingUrl);
429
- const normalizedRequested = normalizeGitHubUrl(githubUrl);
430
-
431
- if (normalizedExisting === normalizedRequested) {
432
- console.log('✅ Repository already exists at path with correct URL');
433
- return resolve(cloneDir);
434
- } else {
435
- throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
436
- }
437
- } catch (gitError) {
438
- throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
439
- }
440
- } catch (accessError) {
441
- // Directory doesn't exist - proceed with clone
442
- }
443
-
444
- // Ensure parent directory exists
445
- await fs.mkdir(path.dirname(cloneDir), { recursive: true });
446
-
447
- // Prepare the git clone URL with authentication if token is provided
448
- let cloneUrl = githubUrl;
449
- if (githubToken) {
450
- // Convert HTTPS URL to authenticated URL
451
- // Example: https://github.com/user/repo -> https://token@github.com/user/repo
452
- cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
453
- }
454
-
455
- console.log('🔄 Cloning repository:', githubUrl);
456
- console.log('📁 Destination:', cloneDir);
457
-
458
- // Execute git clone
459
- const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
460
- stdio: ['pipe', 'pipe', 'pipe']
461
- });
462
-
463
- let stdout = '';
464
- let stderr = '';
465
-
466
- gitProcess.stdout.on('data', (data) => {
467
- stdout += data.toString();
468
- });
469
-
470
- gitProcess.stderr.on('data', (data) => {
471
- stderr += data.toString();
472
- console.log('Git stderr:', data.toString());
473
- });
474
-
475
- gitProcess.on('close', (code) => {
476
- if (code === 0) {
477
- console.log('✅ Repository cloned successfully');
478
- resolve(cloneDir);
479
- } else {
480
- console.error('❌ Git clone failed:', stderr);
481
- reject(new Error(`Git clone failed: ${stderr}`));
482
- }
483
- });
484
-
485
- gitProcess.on('error', (error) => {
486
- reject(new Error(`Failed to execute git: ${error.message}`));
487
- });
488
- } catch (error) {
489
- reject(error);
490
- }
491
- });
492
- }
493
-
494
- /**
495
- * Clean up a temporary project directory and its Claude session
496
- * @param {string} projectPath - Path to the project directory
497
- * @param {string} sessionId - Session ID to clean up
498
- */
499
- async function cleanupProject(projectPath, sessionId = null) {
500
- try {
501
- // Only clean up projects in the external-projects directory
502
- const resolvedPath = path.resolve(projectPath);
503
- if (!isPathWithinExternalProjects(resolvedPath)) {
504
- console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
505
- return;
506
- }
507
-
508
- console.log('🧹 Cleaning up project:', projectPath);
509
- await fs.rm(projectPath, { recursive: true, force: true });
510
- console.log('✅ Project cleaned up');
511
-
512
- // Also clean up the Claude session directory if sessionId provided
513
- if (sessionId) {
514
- try {
515
- const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
516
- console.log('🧹 Cleaning up session directory:', sessionPath);
517
- await fs.rm(sessionPath, { recursive: true, force: true });
518
- console.log('✅ Session directory cleaned up');
519
- } catch (error) {
520
- console.error('⚠️ Failed to clean up session directory:', error.message);
521
- }
522
- }
523
- } catch (error) {
524
- console.error('❌ Failed to clean up project:', error);
525
- }
526
- }
527
-
528
- /**
529
- * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
530
- */
531
- class SSEStreamWriter {
532
- constructor(res, userId = null) {
533
- this.res = res;
534
- this.sessionId = null;
535
- this.userId = userId;
536
- this.isSSEStreamWriter = true; // Marker for transport detection
537
- }
538
-
539
- send(data) {
540
- if (this.res.writableEnded) {
541
- return;
542
- }
543
-
544
- // Format as SSE - providers send raw objects, we stringify
545
- this.res.write(`data: ${JSON.stringify(data)}\n\n`);
546
- }
547
-
548
- end() {
549
- if (!this.res.writableEnded) {
550
- this.res.write('data: {"type":"done"}\n\n');
551
- this.res.end();
552
- }
553
- }
554
-
555
- setSessionId(sessionId) {
556
- this.sessionId = sessionId;
557
- this.send({ type: 'session-id', sessionId });
558
- }
559
-
560
- getSessionId() {
561
- return this.sessionId;
562
- }
563
- }
564
-
565
- /**
566
- * Non-streaming response collector
567
- */
568
- class ResponseCollector {
569
- constructor(userId = null) {
570
- this.messages = [];
571
- this.sessionId = null;
572
- this.userId = userId;
573
- }
574
-
575
- send(data) {
576
- // Store ALL messages for now - we'll filter when returning
577
- this.messages.push(data);
578
-
579
- // Extract sessionId if present
580
- if (typeof data === 'string') {
581
- try {
582
- const parsed = JSON.parse(data);
583
- if (parsed.sessionId) {
584
- this.sessionId = parsed.sessionId;
585
- }
586
- } catch (e) {
587
- // Not JSON, ignore
588
- }
589
- } else if (data && data.sessionId) {
590
- this.sessionId = data.sessionId;
591
- }
592
- }
593
-
594
- end() {
595
- // Do nothing - we'll collect all messages
596
- }
597
-
598
- setSessionId(sessionId) {
599
- this.sessionId = sessionId;
600
- }
601
-
602
- getSessionId() {
603
- return this.sessionId;
604
- }
605
-
606
- getMessages() {
607
- return this.messages;
608
- }
609
-
610
- /**
611
- * Get filtered assistant messages.
612
- *
613
- * Two message shapes are observed in the wild:
614
- * 1. Legacy Claude-only: { type:'claude-response', data:{ type:'assistant', message:{...} } }
615
- * 2. Unified normalized: { kind:'stream_delta'|'tool_use'|... , provider, content, ... }
616
- * (every provider after the v1.30+ unified-message migration emits this)
617
- *
618
- * Pre-fix this method only matched (1), so qwen / gemini / opencode / codex
619
- * runs all returned an empty array even when the provider streamed real
620
- * text. Now it builds:
621
- * - one synthetic assistant entry per chat turn from concatenated
622
- * `stream_delta` content (boundary = `stream_end` or `complete`)
623
- * - tool_use / tool_result entries pass through verbatim
624
- */
625
- getAssistantMessages() {
626
- const out = [];
627
- let textBuffer = '';
628
-
629
- const flushText = () => {
630
- if (!textBuffer) return;
631
- out.push({
632
- type: 'assistant',
633
- message: {
634
- role: 'assistant',
635
- content: [{ type: 'text', text: textBuffer }],
636
- },
637
- });
638
- textBuffer = '';
639
- };
640
-
641
- for (const raw of this.messages) {
642
- const data = typeof raw === 'string'
643
- ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
644
- : raw;
645
- if (!data) continue;
646
- if (data.type === 'status') continue;
647
-
648
- // Unified shape (every modern provider).
649
- // - `stream_delta`: incremental text chunk (most providers)
650
- // - `text`: full text part for one assistant turn (Claude SDK + history reads)
651
- // - `thinking`: reasoning blocks; we coalesce as plain text so the API caller sees something
652
- if ((data.kind === 'stream_delta' || data.kind === 'text' || data.kind === 'thinking')
653
- && (typeof data.content === 'string' || Array.isArray(data.content))) {
654
- const text = typeof data.content === 'string'
655
- ? data.content
656
- : data.content.map((part) => (typeof part === 'string' ? part : (part?.text || ''))).join('');
657
- textBuffer += text;
658
- continue;
659
- }
660
- if (data.kind === 'stream_end' || data.kind === 'complete') {
661
- flushText();
662
- continue;
663
- }
664
- if (data.kind === 'tool_use') {
665
- flushText();
666
- out.push({ type: 'tool_use', id: data.toolId, name: data.toolName, input: data.toolInput });
667
- continue;
668
- }
669
- if (data.kind === 'tool_result') {
670
- out.push({ type: 'tool_result', tool_use_id: data.toolId, content: data.content, is_error: data.isError });
671
- continue;
672
- }
673
- if (data.kind === 'error' && typeof data.content === 'string') {
674
- flushText();
675
- out.push({ type: 'error', content: data.content });
676
- continue;
677
- }
678
-
679
- // Legacy Claude shape — kept so old SDK builds still report cleanly.
680
- if (data.type === 'claude-response' && data.data && data.data.type === 'assistant') {
681
- flushText();
682
- out.push(data.data);
683
- }
684
- }
685
- flushText();
686
- return out;
687
- }
688
-
689
- /**
690
- * Calculate total tokens from all messages.
691
- *
692
- * Two usage shapes observed:
693
- * 1. Legacy Claude: { type:'claude-response', data:{ message:{ usage:{ input_tokens, output_tokens, cache_*_input_tokens } } } }
694
- * 2. Unified `complete`/ { kind:'complete'|'stream_end', usage:{ input, output, cacheRead?, cacheCreation? }, cost? }
695
- * `stream_end` events
696
- */
697
- getTotalTokens() {
698
- let inputTokens = 0;
699
- let outputTokens = 0;
700
- let cacheReadTokens = 0;
701
- let cacheCreationTokens = 0;
702
-
703
- for (const raw of this.messages) {
704
- const data = typeof raw === 'string'
705
- ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
706
- : raw;
707
- if (!data) continue;
708
-
709
- // Unified shape
710
- if (data.usage && typeof data.usage === 'object') {
711
- inputTokens += data.usage.input || data.usage.inputTokens || data.usage.input_tokens || 0;
712
- outputTokens += data.usage.output || data.usage.outputTokens || data.usage.output_tokens || 0;
713
- cacheReadTokens += data.usage.cacheRead || data.usage.cache_read_input_tokens || 0;
714
- cacheCreationTokens += data.usage.cacheCreation || data.usage.cache_creation_input_tokens || 0;
715
- continue;
716
- }
717
-
718
- // Legacy Claude
719
- if (data.type === 'claude-response' && data.data && data.data.message && data.data.message.usage) {
720
- const u = data.data.message.usage;
721
- inputTokens += u.input_tokens || 0;
722
- outputTokens += u.output_tokens || 0;
723
- cacheReadTokens += u.cache_read_input_tokens || 0;
724
- cacheCreationTokens += u.cache_creation_input_tokens || 0;
725
- }
726
- }
727
-
728
- return {
729
- inputTokens,
730
- outputTokens,
731
- cacheReadTokens,
732
- cacheCreationTokens,
733
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens,
734
- };
735
- }
736
- }
737
-
738
- // ===============================
739
- // External API Endpoint
740
- // ===============================
741
-
742
- /**
743
- * POST /api/agent
744
- *
745
- * Trigger an AI agent (Claude or Cursor) to work on a project.
746
- * Supports automatic GitHub branch and pull request creation after successful completion.
747
- *
748
- * ================================================================================================
749
- * REQUEST BODY PARAMETERS
750
- * ================================================================================================
751
- *
752
- * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
753
- * Supported formats:
754
- * - HTTPS: https://github.com/owner/repo
755
- * - HTTPS with .git: https://github.com/owner/repo.git
756
- * - SSH: git@github.com:owner/repo
757
- * - SSH with .git: git@github.com:owner/repo.git
758
- *
759
- * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
760
- * Behavior depends on usage:
761
- * - If used alone: Must point to existing project directory
762
- * - If used with githubUrl: Target location for cloning
763
- * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
764
- *
765
- * @param {string} message - (Required) Task description for the AI agent. Used as:
766
- * - Instructions for the agent
767
- * - Source for auto-generated branch names (if createBranch=true and no branchName)
768
- * - Fallback for PR title if no commits are made
769
- *
770
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
771
- * Default: 'claude'
772
- *
773
- * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
774
- * Default: true
775
- * - true: Returns text/event-stream with incremental updates
776
- * - false: Returns complete JSON response after completion
777
- *
778
- * @param {string} model - (Optional) Model identifier for providers.
779
- *
780
- * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
781
- * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
782
- * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
783
- * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
784
- * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
785
- * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
786
- *
787
- * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
788
- * Default: true
789
- * Behavior:
790
- * - Only applies when cloning via githubUrl (not for existing projectPath)
791
- * - Deletes cloned repository after 5 seconds
792
- * - Also deletes associated Claude session directory
793
- * - Remote branch and PR remain on GitHub if created
794
- *
795
- * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
796
- * Overrides stored token from user settings.
797
- * Required for:
798
- * - Private repositories
799
- * - Branch/PR creation features
800
- * Token must have 'repo' scope for full functionality.
801
- *
802
- * @param {string} branchName - (Optional) Custom name for the Git branch.
803
- * If provided, createBranch is automatically set to true.
804
- * Validation rules (errors returned if violated):
805
- * - Cannot be empty or whitespace only
806
- * - Cannot start or end with dot (.)
807
- * - Cannot contain consecutive dots (..)
808
- * - Cannot contain spaces
809
- * - Cannot contain special characters: ~ ^ : ? * [ \
810
- * - Cannot contain @{
811
- * - Cannot start or end with forward slash (/)
812
- * - Cannot contain consecutive slashes (//)
813
- * - Cannot end with .lock
814
- * - Cannot contain ASCII control characters
815
- * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
816
- *
817
- * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
818
- * Default: false (or true if branchName is provided)
819
- * Behavior:
820
- * - Creates branch locally and pushes to remote
821
- * - If branch exists locally: Checks out existing branch (no error)
822
- * - If branch exists on remote: Uses existing branch (no error)
823
- * - Branch name: Custom (if branchName provided) or auto-generated from message
824
- * - Requires either githubUrl OR projectPath with GitHub remote
825
- *
826
- * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
827
- * Default: false
828
- * Behavior:
829
- * - PR title: First commit message (or fallback to message parameter)
830
- * - PR description: Auto-generated from all commit messages
831
- * - Base branch: Always 'main' (currently hardcoded)
832
- * - If PR already exists: GitHub returns error with details
833
- * - Requires either githubUrl OR projectPath with GitHub remote
834
- *
835
- * ================================================================================================
836
- * PATH HANDLING BEHAVIOR
837
- * ================================================================================================
838
- *
839
- * Scenario 1: Only githubUrl provided
840
- * Input: { githubUrl: "https://github.com/owner/repo" }
841
- * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
842
- * Cleanup: Yes (if cleanup=true)
843
- *
844
- * Scenario 2: Only projectPath provided
845
- * Input: { projectPath: "/home/user/my-project" }
846
- * Action: Uses existing project at specified path
847
- * Validation: Path must exist and be accessible
848
- * Cleanup: No (never cleanup existing projects)
849
- *
850
- * Scenario 3: Both githubUrl and projectPath provided
851
- * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
852
- * Action: Clones githubUrl to projectPath location
853
- * Validation:
854
- * - If projectPath exists with git repo:
855
- * - Compares remote URL with githubUrl
856
- * - If URLs match: Reuses existing repo
857
- * - If URLs differ: Returns error
858
- * Cleanup: Yes (if cleanup=true)
859
- *
860
- * ================================================================================================
861
- * GITHUB BRANCH/PR CREATION REQUIREMENTS
862
- * ================================================================================================
863
- *
864
- * For createBranch or createPR to work, one of the following must be true:
865
- *
866
- * Option A: githubUrl provided
867
- * - Repository URL directly specified
868
- * - Works with both cloning and existing paths
869
- *
870
- * Option B: projectPath with GitHub remote
871
- * - Project must be a Git repository
872
- * - Must have 'origin' remote configured
873
- * - Remote URL must point to github.com
874
- * - System auto-detects GitHub URL via: git remote get-url origin
875
- *
876
- * Additional Requirements:
877
- * - Valid GitHub token (from settings or githubToken parameter)
878
- * - Token must have 'repo' scope for private repos
879
- * - Project must have commits (for PR creation)
880
- *
881
- * ================================================================================================
882
- * VALIDATION & ERROR HANDLING
883
- * ================================================================================================
884
- *
885
- * Input Validations (400 Bad Request):
886
- * - Either githubUrl OR projectPath must be provided (not neither)
887
- * - message must be non-empty string
888
- * - provider must be 'claude', 'cursor', 'codex', or 'gemini'
889
- * - createBranch/createPR requires githubUrl OR projectPath (not neither)
890
- * - branchName must pass Git naming rules (if provided)
891
- *
892
- * Runtime Validations (500 Internal Server Error or specific error in response):
893
- * - projectPath must exist (if used alone)
894
- * - GitHub URL format must be valid
895
- * - Git remote URL must include github.com (for projectPath + branch/PR)
896
- * - GitHub token must be available (for private repos and branch/PR)
897
- * - Directory conflicts handled (existing path with different repo)
898
- *
899
- * Branch Name Validation Errors (returned in response, not HTTP error):
900
- * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
901
- * Examples:
902
- * - "my branch" → "Branch name cannot contain spaces"
903
- * - ".feature" → "Branch name cannot start with a dot"
904
- * - "feature.lock" → "Branch name cannot end with .lock"
905
- *
906
- * ================================================================================================
907
- * RESPONSE FORMATS
908
- * ================================================================================================
909
- *
910
- * Streaming Response (stream=true):
911
- * Content-Type: text/event-stream
912
- * Events:
913
- * - { type: "status", message: "...", projectPath: "..." }
914
- * - { type: "claude-response", data: {...} }
915
- * - { type: "github-branch", branch: { name: "...", url: "..." } }
916
- * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
917
- * - { type: "github-error", error: "..." }
918
- * - { type: "done" }
919
- *
920
- * Non-Streaming Response (stream=false):
921
- * Content-Type: application/json
922
- * {
923
- * success: true,
924
- * sessionId: "session-123",
925
- * messages: [...], // Assistant messages only (filtered)
926
- * tokens: {
927
- * inputTokens: 150,
928
- * outputTokens: 50,
929
- * cacheReadTokens: 0,
930
- * cacheCreationTokens: 0,
931
- * totalTokens: 200
932
- * },
933
- * projectPath: "/path/to/project",
934
- * branch: { // Only if createBranch=true
935
- * name: "feature/xyz",
936
- * url: "https://github.com/owner/repo/tree/feature/xyz"
937
- * } | { error: "..." },
938
- * pullRequest: { // Only if createPR=true
939
- * number: 42,
940
- * url: "https://github.com/owner/repo/pull/42"
941
- * } | { error: "..." }
942
- * }
943
- *
944
- * Error Response:
945
- * HTTP Status: 400, 401, 500
946
- * Content-Type: application/json
947
- * { success: false, error: "Error description" }
948
- *
949
- * ================================================================================================
950
- * EXAMPLES
951
- * ================================================================================================
952
- *
953
- * Example 1: Clone and process with auto-cleanup
954
- * POST /api/agent
955
- * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
956
- *
957
- * Example 2: Use existing project with custom branch and PR
958
- * POST /api/agent
959
- * {
960
- * "projectPath": "/home/user/project",
961
- * "message": "Add feature",
962
- * "branchName": "feature/new-feature",
963
- * "createPR": true
964
- * }
965
- *
966
- * Example 3: Clone to specific path with auto-generated branch
967
- * POST /api/agent
968
- * {
969
- * "githubUrl": "https://github.com/user/repo",
970
- * "projectPath": "/tmp/work",
971
- * "message": "Refactor code",
972
- * "createBranch": true,
973
- * "cleanup": false
974
- * }
975
- */
976
- router.post('/', validateExternalApiKey, async (req, res) => {
977
- const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
978
- const requestedPermissionMode = typeof req.body.permissionMode === 'string' ? req.body.permissionMode : null;
979
- const permissionMode = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'auto_edit', 'yolo'].includes(requestedPermissionMode)
980
- ? requestedPermissionMode
981
- : null;
982
- const suppressNotifications = req.body.suppressNotifications === true || req.body.suppressNotifications === 'true';
983
-
984
- // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
985
- const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
986
- const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
987
-
988
- // If branchName is provided, automatically enable createBranch
989
- const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
990
- const createPR = req.body.createPR === true || req.body.createPR === 'true';
991
-
992
- // Validate inputs
993
- if (!githubUrl && !projectPath) {
994
- return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
995
- }
996
-
997
- if (!message || !message.trim()) {
998
- return res.status(400).json({ error: 'message is required' });
999
- }
1000
-
1001
- if (!['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'].includes(provider)) {
1002
- return res.status(400).json({ error: 'provider must be one of: claude, cursor, codex, gemini, qwen, opencode' });
1003
- }
1004
-
1005
- // Validate GitHub branch/PR creation requirements
1006
- // Allow branch/PR creation with projectPath as long as it has a GitHub remote
1007
- if ((createBranch || createPR) && !githubUrl && !projectPath) {
1008
- return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
1009
- }
1010
-
1011
- let finalProjectPath = null;
1012
- let writer = null;
1013
-
1014
- try {
1015
- // Determine the final project path
1016
- if (githubUrl) {
1017
- // Clone repository (to projectPath if provided, otherwise generate path)
1018
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1019
-
1020
- let targetPath;
1021
- if (projectPath) {
1022
- targetPath = projectPath;
1023
- } else {
1024
- // Generate a unique path for cloning
1025
- const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
1026
- targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
1027
- }
1028
-
1029
- finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
1030
- } else {
1031
- // Use existing project path
1032
- finalProjectPath = path.resolve(projectPath);
1033
-
1034
- // Verify the path exists
1035
- try {
1036
- await fs.access(finalProjectPath);
1037
- } catch (error) {
1038
- throw new Error(`Project path does not exist: ${finalProjectPath}`);
1039
- }
1040
- }
1041
-
1042
- // Register the project (or use existing registration)
1043
- let project;
1044
- try {
1045
- project = await addProjectManually(finalProjectPath);
1046
- console.log('📦 Project registered:', project);
1047
- } catch (error) {
1048
- // If project already exists, that's fine - continue with the existing registration
1049
- if (error.message && error.message.includes('Project already configured')) {
1050
- console.log('📦 Using existing project registration for:', finalProjectPath);
1051
- project = { path: finalProjectPath };
1052
- } else {
1053
- throw error;
1054
- }
1055
- }
1056
-
1057
- // Set up writer based on streaming mode
1058
- if (stream) {
1059
- // Set up SSE headers for streaming
1060
- res.setHeader('Content-Type', 'text/event-stream');
1061
- res.setHeader('Cache-Control', 'no-cache');
1062
- res.setHeader('Connection', 'keep-alive');
1063
- res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
1064
-
1065
- writer = new SSEStreamWriter(res, req.user.id);
1066
-
1067
- // Send initial status
1068
- writer.send({
1069
- type: 'status',
1070
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
1071
- projectPath: finalProjectPath
1072
- });
1073
- } else {
1074
- // Non-streaming mode: collect messages
1075
- writer = new ResponseCollector(req.user.id);
1076
-
1077
- // Collect initial status message
1078
- writer.send({
1079
- type: 'status',
1080
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
1081
- projectPath: finalProjectPath
1082
- });
1083
- }
1084
-
1085
- // Start the appropriate session
1086
- if (provider === 'claude') {
1087
- console.log('🤖 Starting Claude SDK session');
1088
-
1089
- await queryClaudeSDK(message.trim(), {
1090
- projectPath: finalProjectPath,
1091
- cwd: finalProjectPath,
1092
- sessionId: sessionId || null,
1093
- model: model,
1094
- permissionMode: permissionMode || 'bypassPermissions', // Bypass all permissions for API calls unless explicitly restricted
1095
- suppressNotifications,
1096
- }, writer);
1097
-
1098
- } else if (provider === 'cursor') {
1099
- console.log('🖱️ Starting Cursor CLI session');
1100
-
1101
- await spawnCursor(message.trim(), {
1102
- projectPath: finalProjectPath,
1103
- cwd: finalProjectPath,
1104
- sessionId: sessionId || null,
1105
- model: model || undefined,
1106
- skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1107
- suppressNotifications,
1108
- }, writer);
1109
- } else if (provider === 'codex') {
1110
- console.log('🤖 Starting Codex SDK session');
1111
-
1112
- await queryCodex(message.trim(), {
1113
- projectPath: finalProjectPath,
1114
- cwd: finalProjectPath,
1115
- sessionId: sessionId || null,
1116
- model: model || getDefaultProviderModel('codex'),
1117
- permissionMode: permissionMode || 'bypassPermissions',
1118
- suppressNotifications,
1119
- }, writer);
1120
- } else if (provider === 'gemini') {
1121
- console.log('✨ Starting Gemini CLI session');
1122
-
1123
- await spawnGemini(message.trim(), {
1124
- projectPath: finalProjectPath,
1125
- cwd: finalProjectPath,
1126
- sessionId: sessionId || null,
1127
- model: model,
1128
- permissionMode: permissionMode || undefined,
1129
- skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1130
- suppressNotifications,
1131
- }, writer);
1132
- } else if (provider === 'qwen') {
1133
- console.log('🐉 Starting Qwen Code CLI session');
1134
-
1135
- await spawnQwen(message.trim(), {
1136
- projectPath: finalProjectPath,
1137
- cwd: finalProjectPath,
1138
- sessionId: sessionId || null,
1139
- model: model,
1140
- permissionMode: permissionMode || undefined,
1141
- skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1142
- suppressNotifications,
1143
- }, writer);
1144
- } else if (provider === 'opencode') {
1145
- console.log('🅾️ Starting OpenCode CLI session');
1146
-
1147
- await spawnOpencode(message.trim(), {
1148
- projectPath: finalProjectPath,
1149
- cwd: finalProjectPath,
1150
- sessionId: sessionId || null,
1151
- model: model,
1152
- permissionMode: permissionMode || 'bypassPermissions',
1153
- toolsSettings: {
1154
- allowPatterns: [],
1155
- denyPatterns: [],
1156
- skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1157
- },
1158
- suppressNotifications,
1159
- }, writer);
1160
- }
1161
-
1162
- // Handle GitHub branch and PR creation after successful agent completion
1163
- let branchInfo = null;
1164
- let prInfo = null;
1165
-
1166
- if (createBranch || createPR) {
1167
- try {
1168
- console.log('🔄 Starting GitHub branch/PR creation workflow...');
1169
-
1170
- // Get GitHub token
1171
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1172
-
1173
- if (!tokenToUse) {
1174
- throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1175
- }
1176
-
1177
- // Initialize Octokit
1178
- const octokit = new Octokit({ auth: tokenToUse });
1179
-
1180
- // Get GitHub URL - either from parameter or from git remote
1181
- let repoUrl = githubUrl;
1182
- if (!repoUrl) {
1183
- console.log('🔍 Getting GitHub URL from git remote...');
1184
- try {
1185
- repoUrl = await getGitRemoteUrl(finalProjectPath);
1186
- if (!repoUrl.includes('github.com')) {
1187
- throw new Error('Project does not have a GitHub remote configured');
1188
- }
1189
- console.log(`✅ Found GitHub remote: ${repoUrl}`);
1190
- } catch (error) {
1191
- throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1192
- }
1193
- }
1194
-
1195
- // Parse GitHub URL to get owner and repo
1196
- const { owner, repo } = parseGitHubUrl(repoUrl);
1197
- console.log(`📦 Repository: ${owner}/${repo}`);
1198
-
1199
- // Use provided branch name or auto-generate from message
1200
- const finalBranchName = branchName || autogenerateBranchName(message);
1201
- if (branchName) {
1202
- console.log(`🌿 Using provided branch name: ${finalBranchName}`);
1203
-
1204
- // Validate custom branch name
1205
- const validation = validateBranchName(finalBranchName);
1206
- if (!validation.valid) {
1207
- throw new Error(`Invalid branch name: ${validation.error}`);
1208
- }
1209
- } else {
1210
- console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
1211
- }
1212
-
1213
- if (createBranch) {
1214
- // Create and checkout the new branch locally
1215
- console.log('🔄 Creating local branch...');
1216
- const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1217
- cwd: finalProjectPath,
1218
- stdio: 'pipe'
1219
- });
1220
-
1221
- await new Promise((resolve, reject) => {
1222
- let stderr = '';
1223
- checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1224
- checkoutProcess.on('close', (code) => {
1225
- if (code === 0) {
1226
- console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
1227
- resolve();
1228
- } else {
1229
- // Branch might already exist locally, try to checkout
1230
- if (stderr.includes('already exists')) {
1231
- console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);
1232
- const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1233
- cwd: finalProjectPath,
1234
- stdio: 'pipe'
1235
- });
1236
- checkoutExisting.on('close', (checkoutCode) => {
1237
- if (checkoutCode === 0) {
1238
- console.log(`✅ Checked out existing branch '${finalBranchName}'`);
1239
- resolve();
1240
- } else {
1241
- reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1242
- }
1243
- });
1244
- } else {
1245
- reject(new Error(`Failed to create branch: ${stderr}`));
1246
- }
1247
- }
1248
- });
1249
- });
1250
-
1251
- // Push the branch to remote
1252
- console.log('🔄 Pushing branch to remote...');
1253
- const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1254
- cwd: finalProjectPath,
1255
- stdio: 'pipe'
1256
- });
1257
-
1258
- await new Promise((resolve, reject) => {
1259
- let stderr = '';
1260
- let stdout = '';
1261
- pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1262
- pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1263
- pushProcess.on('close', (code) => {
1264
- if (code === 0) {
1265
- console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
1266
- resolve();
1267
- } else {
1268
- // Check if branch exists on remote but has different commits
1269
- if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1270
- console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);
1271
- resolve();
1272
- } else {
1273
- reject(new Error(`Failed to push branch: ${stderr}`));
1274
- }
1275
- }
1276
- });
1277
- });
1278
-
1279
- branchInfo = {
1280
- name: finalBranchName,
1281
- url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1282
- };
1283
- }
1284
-
1285
- if (createPR) {
1286
- // Get commit messages to generate PR description
1287
- console.log('🔄 Generating PR title and description...');
1288
- const commitMessages = await getCommitMessages(finalProjectPath, 5);
1289
-
1290
- // Use the first commit message as the PR title, or fallback to the agent message
1291
- const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1292
-
1293
- // Generate PR body from commit messages
1294
- let prBody = '## Changes\n\n';
1295
- if (commitMessages.length > 0) {
1296
- prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1297
- } else {
1298
- prBody += `Agent task: ${message}`;
1299
- }
1300
- prBody += '\n\n---\n*This pull request was automatically created by Pixcode Agent.*';
1301
-
1302
- console.log(`📝 PR Title: ${prTitle}`);
1303
-
1304
- // Create the pull request
1305
- console.log('🔄 Creating pull request...');
1306
- prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1307
- }
1308
-
1309
- // Send branch/PR info in response
1310
- if (stream) {
1311
- if (branchInfo) {
1312
- writer.send({
1313
- type: 'github-branch',
1314
- branch: branchInfo
1315
- });
1316
- }
1317
- if (prInfo) {
1318
- writer.send({
1319
- type: 'github-pr',
1320
- pullRequest: prInfo
1321
- });
1322
- }
1323
- }
1324
-
1325
- } catch (error) {
1326
- console.error('❌ GitHub branch/PR creation error:', error);
1327
-
1328
- // Send error but don't fail the entire request
1329
- if (stream) {
1330
- writer.send({
1331
- type: 'github-error',
1332
- error: error.message
1333
- });
1334
- }
1335
- // Store error info for non-streaming response
1336
- if (!stream) {
1337
- branchInfo = { error: error.message };
1338
- prInfo = { error: error.message };
1339
- }
1340
- }
1341
- }
1342
-
1343
- // Handle response based on streaming mode
1344
- if (stream) {
1345
- // Streaming mode: end the SSE stream
1346
- writer.end();
1347
- } else {
1348
- // Non-streaming mode: send filtered messages and token summary as JSON
1349
- const assistantMessages = writer.getAssistantMessages();
1350
- const tokenSummary = writer.getTotalTokens();
1351
-
1352
- // Promote provider-side errors (`writer.send({ kind:'error', ... })`)
1353
- // to the response envelope. Without this, providers like Codex —
1354
- // whose SDK swallows throws and only emits an error message — left
1355
- // callers with `{ success:true, messages:[] }`, indistinguishable
1356
- // from a quiet success. Now: any error event => success:false and
1357
- // the human-readable text on `error`.
1358
- const errorEntry = assistantMessages.find((m) => m.type === 'error');
1359
- const hasAssistantText = assistantMessages.some(
1360
- (m) => m.type === 'assistant' && m.message?.content?.some?.((p) => p.type === 'text' && p.text)
1361
- );
1362
- const succeeded = !errorEntry && (hasAssistantText || assistantMessages.some((m) => m.type === 'tool_use' || m.type === 'tool_result'));
1363
- const failureDetails = succeeded
1364
- ? null
1365
- : describeProviderFailure(
1366
- errorEntry?.content || 'Provider returned no assistant text. Check backend log for details.',
1367
- provider,
1368
- );
1369
-
1370
- const response = {
1371
- success: succeeded,
1372
- sessionId: writer.getSessionId(),
1373
- messages: assistantMessages,
1374
- tokens: tokenSummary,
1375
- projectPath: finalProjectPath
1376
- };
1377
- if (failureDetails) {
1378
- response.error = failureDetails.message;
1379
- response.rawError = failureDetails.rawMessage;
1380
- response.errorDetails = failureDetails;
1381
- }
1382
-
1383
- // Add branch/PR info if created
1384
- if (branchInfo) {
1385
- response.branch = branchInfo;
1386
- }
1387
- if (prInfo) {
1388
- response.pullRequest = prInfo;
1389
- }
1390
-
1391
- res.status(succeeded ? 200 : 502).json(response);
1392
- }
1393
-
1394
- // Clean up if requested
1395
- if (cleanup && githubUrl) {
1396
- // Only cleanup if we cloned a repo (not for existing project paths)
1397
- const sessionIdForCleanup = writer.getSessionId();
1398
- setTimeout(() => {
1399
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1400
- }, 5000);
1401
- }
1402
-
1403
- } catch (error) {
1404
- console.error('❌ External session error:', error);
1405
-
1406
- // Clean up on error
1407
- if (finalProjectPath && cleanup && githubUrl) {
1408
- const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1409
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1410
- }
1411
-
1412
- if (stream) {
1413
- // For streaming, send error event and stop
1414
- if (!writer) {
1415
- // Set up SSE headers if not already done
1416
- res.setHeader('Content-Type', 'text/event-stream');
1417
- res.setHeader('Cache-Control', 'no-cache');
1418
- res.setHeader('Connection', 'keep-alive');
1419
- res.setHeader('X-Accel-Buffering', 'no');
1420
- writer = new SSEStreamWriter(res, req.user.id);
1421
- }
1422
-
1423
- if (!res.writableEnded) {
1424
- writer.send({
1425
- type: 'error',
1426
- error: 'Failed to process agent request.',
1427
- message: 'Failed to process agent request.'
1428
- });
1429
- writer.end();
1430
- }
1431
- } else if (!res.headersSent) {
1432
- // Surface any provider-side stderr/error events the writer collected
1433
- // BEFORE the throw — without this, callers only see the bland
1434
- // "Gemini CLI exited with code 403" wrapper and lose the actual
1435
- // "PERMISSION_DENIED, model not enabled for this account" detail
1436
- // that the CLI printed to stderr.
1437
- let collectedError = null;
1438
- let collectedMessages = [];
1439
- if (writer && typeof writer.getAssistantMessages === 'function') {
1440
- try {
1441
- collectedMessages = writer.getAssistantMessages();
1442
- const errorText = collectedMessages
1443
- .filter((m) => m.type === 'error' && typeof m.content === 'string' && m.content.trim())
1444
- .map((m) => m.content.trim())
1445
- .join('\n');
1446
- if (errorText) collectedError = errorText;
1447
- } catch { /* ignore — fall back to error.message */ }
1448
- }
1449
- const failureDetails = describeProviderFailure(collectedError || 'Provider returned no assistant text.', provider);
1450
- res.status(502).json({
1451
- success: false,
1452
- sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
1453
- error: failureDetails.message,
1454
- rawError: failureDetails.rawMessage,
1455
- errorDetails: failureDetails,
1456
- messages: collectedMessages,
1457
- });
1458
- }
1459
- }
1460
- });
1461
-
1462
- export default router;
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { promises as fs } from 'fs';
5
+ import crypto from 'crypto';
6
+
7
+ import express from 'express';
8
+ import { Octokit } from '@octokit/rest';
9
+
10
+ import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
11
+ import { addProjectManually } from '../projects.js';
12
+ import { queryClaudeSDK } from '../claude-sdk.js';
13
+ import { spawnCursor } from '../cursor-cli.js';
14
+ import { queryCodex } from '../openai-codex.js';
15
+ import { spawnGemini } from '../gemini-cli.js';
16
+ import { spawnQwen } from '../qwen-code-cli.js';
17
+ import { spawnOpencode } from '../opencode-cli.js';
18
+ import { IS_PLATFORM } from '../constants/config.js';
19
+ import { getDefaultProviderModel } from '../services/model-registry.js';
20
+
21
+ const router = express.Router();
22
+ const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
23
+ const EXTERNAL_PROJECTS_BASE = path.join(os.homedir(), '.claude', 'external-projects');
24
+
25
+ function isPathWithinExternalProjects(resolvedPath) {
26
+ const normalized = path.resolve(resolvedPath);
27
+ return normalized.startsWith(EXTERNAL_PROJECTS_BASE + path.sep) || normalized === EXTERNAL_PROJECTS_BASE;
28
+ }
29
+
30
+ /**
31
+ * Middleware to authenticate agent API requests.
32
+ *
33
+ * Supports two authentication modes:
34
+ * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
35
+ * authentication is handled by an external proxy. Requests are trusted and
36
+ * the default user context is used.
37
+ *
38
+ * 2. API key mode (default): For self-hosted deployments where users authenticate
39
+ * via API keys created in the UI. Keys are validated against the local database.
40
+ */
41
+ const validateExternalApiKey = (req, res, next) => {
42
+ // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
43
+ // Trust the request and use the default user context.
44
+ if (IS_PLATFORM) {
45
+ try {
46
+ const user = userDb.getFirstUser();
47
+ if (!user) {
48
+ return res.status(500).json({ error: 'Platform mode: No user found in database' });
49
+ }
50
+ req.user = user;
51
+ return next();
52
+ } catch (error) {
53
+ console.error('Platform mode error:', error);
54
+ return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
55
+ }
56
+ }
57
+
58
+ // Self-hosted mode: validate API key from any of the supported transports.
59
+ // - Authorization: Bearer px_... (legacy ck_... still accepted)
60
+ // auth shape as the rest of the API, per the auth-unify in this turn)
61
+ // - X-API-Key: px_...
62
+ // - ?apiKey=px_... (EventSource workaround)
63
+ const authHeader = req.headers['authorization'];
64
+ const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
65
+ const apiKey = (isPixcodeApiKey(bearer) ? bearer : null)
66
+ || req.headers['x-api-key']
67
+ || (typeof req.query.apiKey === 'string' ? req.query.apiKey : null);
68
+
69
+ if (!apiKey) {
70
+ return res.status(401).json({ error: 'API key required (Authorization: Bearer px_..., X-API-Key, or ?apiKey=)' });
71
+ }
72
+
73
+ const user = apiKeysDb.validateApiKey(apiKey);
74
+
75
+ if (!user) {
76
+ return res.status(401).json({ error: 'Invalid or inactive API key' });
77
+ }
78
+
79
+ req.user = user;
80
+ next();
81
+ };
82
+
83
+ /**
84
+ * Get the remote URL of a git repository
85
+ * @param {string} repoPath - Path to the git repository
86
+ * @returns {Promise<string>} - Remote URL of the repository
87
+ */
88
+ async function getGitRemoteUrl(repoPath) {
89
+ return new Promise((resolve, reject) => {
90
+ const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
91
+ cwd: repoPath,
92
+ stdio: ['pipe', 'pipe', 'pipe']
93
+ });
94
+
95
+ let stdout = '';
96
+ let stderr = '';
97
+
98
+ gitProcess.stdout.on('data', (data) => {
99
+ stdout += data.toString();
100
+ });
101
+
102
+ gitProcess.stderr.on('data', (data) => {
103
+ stderr += data.toString();
104
+ });
105
+
106
+ gitProcess.on('close', (code) => {
107
+ if (code === 0) {
108
+ resolve(stdout.trim());
109
+ } else {
110
+ reject(new Error(`Failed to get git remote: ${stderr}`));
111
+ }
112
+ });
113
+
114
+ gitProcess.on('error', (error) => {
115
+ reject(new Error(`Failed to execute git: ${error.message}`));
116
+ });
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Normalize GitHub URLs for comparison
122
+ * @param {string} url - GitHub URL
123
+ * @returns {string} - Normalized URL
124
+ */
125
+ function normalizeGitHubUrl(url) {
126
+ // Remove .git suffix
127
+ let normalized = url.replace(/\.git$/, '');
128
+ // Convert SSH to HTTPS format for comparison
129
+ normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
130
+ // Remove trailing slash
131
+ normalized = normalized.replace(/\/$/, '');
132
+ return normalized.toLowerCase();
133
+ }
134
+
135
+ /**
136
+ * Parse GitHub URL to extract owner and repo
137
+ * @param {string} url - GitHub URL (HTTPS or SSH)
138
+ * @returns {{owner: string, repo: string}} - Parsed owner and repo
139
+ */
140
+ function parseGitHubUrl(url) {
141
+ // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
142
+ // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
143
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
144
+ if (!match) {
145
+ throw new Error('Invalid GitHub URL format');
146
+ }
147
+ return {
148
+ owner: match[1],
149
+ repo: match[2].replace(/\.git$/, '')
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Auto-generate a branch name from a message
155
+ * @param {string} message - The agent message
156
+ * @returns {string} - Generated branch name
157
+ */
158
+ function autogenerateBranchName(message) {
159
+ // Convert to lowercase, replace spaces/special chars with hyphens
160
+ let branchName = message
161
+ .toLowerCase()
162
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
163
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
164
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
165
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
166
+
167
+ // Ensure non-empty fallback
168
+ if (!branchName) {
169
+ branchName = 'task';
170
+ }
171
+
172
+ // Generate timestamp suffix (last 6 chars of base36 timestamp)
173
+ const timestamp = Date.now().toString(36).slice(-6);
174
+ const suffix = `-${timestamp}`;
175
+
176
+ // Limit length to ensure total length including suffix fits within 50 characters
177
+ const maxBaseLength = 50 - suffix.length;
178
+ if (branchName.length > maxBaseLength) {
179
+ branchName = branchName.substring(0, maxBaseLength);
180
+ }
181
+
182
+ // Remove any trailing hyphen after truncation and ensure no leading hyphen
183
+ branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
184
+
185
+ // If still empty or starts with hyphen after cleanup, use fallback
186
+ if (!branchName || branchName.startsWith('-')) {
187
+ branchName = 'task';
188
+ }
189
+
190
+ // Combine base name with timestamp suffix
191
+ branchName = `${branchName}${suffix}`;
192
+
193
+ // Final validation: ensure it matches safe pattern
194
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
195
+ // Fallback to deterministic safe name
196
+ return `branch-${timestamp}`;
197
+ }
198
+
199
+ return branchName;
200
+ }
201
+
202
+ /**
203
+ * Validate a Git branch name
204
+ * @param {string} branchName - Branch name to validate
205
+ * @returns {{valid: boolean, error?: string}} - Validation result
206
+ */
207
+ function validateBranchName(branchName) {
208
+ if (!branchName || branchName.trim() === '') {
209
+ return { valid: false, error: 'Branch name cannot be empty' };
210
+ }
211
+
212
+ // Git branch name rules
213
+ const invalidPatterns = [
214
+ { pattern: /^\./, message: 'Branch name cannot start with a dot' },
215
+ { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
216
+ { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
217
+ { pattern: /\s/, message: 'Branch name cannot contain spaces' },
218
+ { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
219
+ { pattern: /@{/, message: 'Branch name cannot contain @{' },
220
+ { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
221
+ { pattern: /^\//, message: 'Branch name cannot start with a slash' },
222
+ { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
223
+ { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
224
+ ];
225
+
226
+ for (const { pattern, message } of invalidPatterns) {
227
+ if (pattern.test(branchName)) {
228
+ return { valid: false, error: message };
229
+ }
230
+ }
231
+
232
+ // Check for ASCII control characters
233
+ if (/[\x00-\x1F\x7F]/.test(branchName)) {
234
+ return { valid: false, error: 'Branch name cannot contain control characters' };
235
+ }
236
+
237
+ return { valid: true };
238
+ }
239
+
240
+ function providerDisplayName(provider) {
241
+ return ({
242
+ claude: 'Claude',
243
+ cursor: 'Cursor',
244
+ codex: 'Codex',
245
+ gemini: 'Gemini',
246
+ qwen: 'Qwen',
247
+ opencode: 'OpenCode',
248
+ })[provider] || 'Provider';
249
+ }
250
+
251
+ function describeProviderFailure(rawError, provider) {
252
+ const rawMessage = String(rawError || '').trim() || 'Provider returned no assistant text.';
253
+ const normalized = rawMessage.toLowerCase();
254
+ const name = providerDisplayName(provider);
255
+
256
+ const details = {
257
+ provider,
258
+ providerName: name,
259
+ category: 'provider_error',
260
+ title: `${name} could not answer.`,
261
+ action: 'Check the provider output, then retry with a shorter prompt or a different model.',
262
+ rawMessage,
263
+ };
264
+
265
+ if (/(balance|billing|quota|credit|insufficient|payment required|402|usage limit|spend limit)/i.test(rawMessage)) {
266
+ details.category = 'quota';
267
+ details.title = `${name} could not answer because the account has no available balance or quota.`;
268
+ details.action = 'Add credits, increase the provider usage limit, or switch to a free/available model.';
269
+ } else if (/(rate limit|too many requests|429|temporarily overloaded|resource exhausted)/i.test(rawMessage)) {
270
+ details.category = 'rate_limit';
271
+ details.title = `${name} is rate limited right now.`;
272
+ details.action = 'Wait a bit, reduce parallel runs, or switch to another provider/model.';
273
+ } else if (/(unauthorized|forbidden|permission_denied|permission denied|api key|token|oauth|login|not authenticated|401|403|invalid credentials)/i.test(rawMessage)) {
274
+ details.category = 'auth';
275
+ details.title = `${name} is not authenticated or the selected model is not allowed.`;
276
+ details.action = 'Reconnect this provider in Settings, refresh the CLI login, or choose a model enabled for the account.';
277
+ } else if (/(not installed|command not found|enoent|spawn .* enoent|executable file not found|exited with code 127)/i.test(rawMessage)) {
278
+ details.category = 'missing_cli';
279
+ details.title = `${name} CLI is not installed or not on PATH.`;
280
+ details.action = 'Install the CLI from Settings -> Agents or set the matching CLI path environment variable.';
281
+ } else if (/(timeout|timed out|aborted|etimedout|deadline)/i.test(rawMessage)) {
282
+ details.category = 'timeout';
283
+ details.title = `${name} timed out before returning a complete answer.`;
284
+ details.action = 'Retry with a shorter request, reduce orchestration parallelism, or inspect the provider session log.';
285
+ } else if (normalized.includes('no assistant text') || normalized.includes('empty')) {
286
+ details.category = 'no_output';
287
+ details.title = `${name} finished without visible assistant text.`;
288
+ details.action = 'Retry once; if it repeats, check provider stderr/session logs because the CLI may have exited before streaming text.';
289
+ }
290
+
291
+ details.message = `${details.title} ${details.action}`;
292
+ return details;
293
+ }
294
+
295
+ /**
296
+ * Get recent commit messages from a repository
297
+ * @param {string} projectPath - Path to the git repository
298
+ * @param {number} limit - Number of commits to retrieve (default: 5)
299
+ * @returns {Promise<string[]>} - Array of commit messages
300
+ */
301
+ async function getCommitMessages(projectPath, limit = 5) {
302
+ return new Promise((resolve, reject) => {
303
+ const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
304
+ cwd: projectPath,
305
+ stdio: ['pipe', 'pipe', 'pipe']
306
+ });
307
+
308
+ let stdout = '';
309
+ let stderr = '';
310
+
311
+ gitProcess.stdout.on('data', (data) => {
312
+ stdout += data.toString();
313
+ });
314
+
315
+ gitProcess.stderr.on('data', (data) => {
316
+ stderr += data.toString();
317
+ });
318
+
319
+ gitProcess.on('close', (code) => {
320
+ if (code === 0) {
321
+ const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
322
+ resolve(messages);
323
+ } else {
324
+ reject(new Error(`Failed to get commit messages: ${stderr}`));
325
+ }
326
+ });
327
+
328
+ gitProcess.on('error', (error) => {
329
+ reject(new Error(`Failed to execute git: ${error.message}`));
330
+ });
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Create a new branch on GitHub using the API
336
+ * @param {Octokit} octokit - Octokit instance
337
+ * @param {string} owner - Repository owner
338
+ * @param {string} repo - Repository name
339
+ * @param {string} branchName - Name of the new branch
340
+ * @param {string} baseBranch - Base branch to branch from (default: 'main')
341
+ * @returns {Promise<void>}
342
+ */
343
+ async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
344
+ try {
345
+ // Get the SHA of the base branch
346
+ const { data: ref } = await octokit.git.getRef({
347
+ owner,
348
+ repo,
349
+ ref: `heads/${baseBranch}`
350
+ });
351
+
352
+ const baseSha = ref.object.sha;
353
+
354
+ // Create the new branch
355
+ await octokit.git.createRef({
356
+ owner,
357
+ repo,
358
+ ref: `refs/heads/${branchName}`,
359
+ sha: baseSha
360
+ });
361
+
362
+ console.log(`✅ Created branch '${branchName}' on GitHub`);
363
+ } catch (error) {
364
+ if (error.status === 422 && error.message.includes('Reference already exists')) {
365
+ console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`);
366
+ } else {
367
+ throw error;
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Create a pull request on GitHub
374
+ * @param {Octokit} octokit - Octokit instance
375
+ * @param {string} owner - Repository owner
376
+ * @param {string} repo - Repository name
377
+ * @param {string} branchName - Head branch name
378
+ * @param {string} title - PR title
379
+ * @param {string} body - PR body/description
380
+ * @param {string} baseBranch - Base branch (default: 'main')
381
+ * @returns {Promise<{number: number, url: string}>} - PR number and URL
382
+ */
383
+ async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
384
+ const { data: pr } = await octokit.pulls.create({
385
+ owner,
386
+ repo,
387
+ title,
388
+ head: branchName,
389
+ base: baseBranch,
390
+ body
391
+ });
392
+
393
+ console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
394
+
395
+ return {
396
+ number: pr.number,
397
+ url: pr.html_url
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Clone a GitHub repository to a directory
403
+ * @param {string} githubUrl - GitHub repository URL
404
+ * @param {string} githubToken - Optional GitHub token for private repos
405
+ * @param {string} projectPath - Path for cloning the repository
406
+ * @returns {Promise<string>} - Path to the cloned repository
407
+ */
408
+ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
409
+ return new Promise(async (resolve, reject) => {
410
+ try {
411
+ // Validate GitHub URL
412
+ if (!githubUrl || !githubUrl.includes('github.com')) {
413
+ throw new Error('Invalid GitHub URL');
414
+ }
415
+
416
+ const cloneDir = path.resolve(projectPath);
417
+
418
+ if (!isPathWithinExternalProjects(cloneDir)) {
419
+ throw new Error('Clone directory must be within ~/.claude/external-projects');
420
+ }
421
+
422
+ // Check if directory already exists
423
+ try {
424
+ await fs.access(cloneDir);
425
+ // Directory exists - check if it's a git repo with the same URL
426
+ try {
427
+ const existingUrl = await getGitRemoteUrl(cloneDir);
428
+ const normalizedExisting = normalizeGitHubUrl(existingUrl);
429
+ const normalizedRequested = normalizeGitHubUrl(githubUrl);
430
+
431
+ if (normalizedExisting === normalizedRequested) {
432
+ console.log('✅ Repository already exists at path with correct URL');
433
+ return resolve(cloneDir);
434
+ } else {
435
+ throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
436
+ }
437
+ } catch (gitError) {
438
+ throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
439
+ }
440
+ } catch (accessError) {
441
+ // Directory doesn't exist - proceed with clone
442
+ }
443
+
444
+ // Ensure parent directory exists
445
+ await fs.mkdir(path.dirname(cloneDir), { recursive: true });
446
+
447
+ // Prepare the git clone URL with authentication if token is provided
448
+ let cloneUrl = githubUrl;
449
+ if (githubToken) {
450
+ // Convert HTTPS URL to authenticated URL
451
+ // Example: https://github.com/user/repo -> https://token@github.com/user/repo
452
+ cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
453
+ }
454
+
455
+ console.log('🔄 Cloning repository:', githubUrl);
456
+ console.log('📁 Destination:', cloneDir);
457
+
458
+ // Execute git clone
459
+ const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
460
+ stdio: ['pipe', 'pipe', 'pipe']
461
+ });
462
+
463
+ let stdout = '';
464
+ let stderr = '';
465
+
466
+ gitProcess.stdout.on('data', (data) => {
467
+ stdout += data.toString();
468
+ });
469
+
470
+ gitProcess.stderr.on('data', (data) => {
471
+ stderr += data.toString();
472
+ console.log('Git stderr:', data.toString());
473
+ });
474
+
475
+ gitProcess.on('close', (code) => {
476
+ if (code === 0) {
477
+ console.log('✅ Repository cloned successfully');
478
+ resolve(cloneDir);
479
+ } else {
480
+ console.error('❌ Git clone failed:', stderr);
481
+ reject(new Error(`Git clone failed: ${stderr}`));
482
+ }
483
+ });
484
+
485
+ gitProcess.on('error', (error) => {
486
+ reject(new Error(`Failed to execute git: ${error.message}`));
487
+ });
488
+ } catch (error) {
489
+ reject(error);
490
+ }
491
+ });
492
+ }
493
+
494
+ /**
495
+ * Clean up a temporary project directory and its Claude session
496
+ * @param {string} projectPath - Path to the project directory
497
+ * @param {string} sessionId - Session ID to clean up
498
+ */
499
+ async function cleanupProject(projectPath, sessionId = null) {
500
+ try {
501
+ // Only clean up projects in the external-projects directory
502
+ const resolvedPath = path.resolve(projectPath);
503
+ if (!isPathWithinExternalProjects(resolvedPath)) {
504
+ console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
505
+ return;
506
+ }
507
+
508
+ console.log('🧹 Cleaning up project:', projectPath);
509
+ await fs.rm(projectPath, { recursive: true, force: true });
510
+ console.log('✅ Project cleaned up');
511
+
512
+ // Also clean up the Claude session directory if sessionId provided
513
+ if (sessionId) {
514
+ try {
515
+ const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
516
+ console.log('🧹 Cleaning up session directory:', sessionPath);
517
+ await fs.rm(sessionPath, { recursive: true, force: true });
518
+ console.log('✅ Session directory cleaned up');
519
+ } catch (error) {
520
+ console.error('⚠️ Failed to clean up session directory:', error.message);
521
+ }
522
+ }
523
+ } catch (error) {
524
+ console.error('❌ Failed to clean up project:', error);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
530
+ */
531
+ class SSEStreamWriter {
532
+ constructor(res, userId = null) {
533
+ this.res = res;
534
+ this.sessionId = null;
535
+ this.userId = userId;
536
+ this.isSSEStreamWriter = true; // Marker for transport detection
537
+ }
538
+
539
+ send(data) {
540
+ if (this.res.writableEnded) {
541
+ return;
542
+ }
543
+
544
+ // Format as SSE - providers send raw objects, we stringify
545
+ this.res.write(`data: ${JSON.stringify(data)}\n\n`);
546
+ }
547
+
548
+ end() {
549
+ if (!this.res.writableEnded) {
550
+ this.res.write('data: {"type":"done"}\n\n');
551
+ this.res.end();
552
+ }
553
+ }
554
+
555
+ setSessionId(sessionId) {
556
+ this.sessionId = sessionId;
557
+ this.send({ type: 'session-id', sessionId });
558
+ }
559
+
560
+ getSessionId() {
561
+ return this.sessionId;
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Non-streaming response collector
567
+ */
568
+ class ResponseCollector {
569
+ constructor(userId = null) {
570
+ this.messages = [];
571
+ this.sessionId = null;
572
+ this.userId = userId;
573
+ }
574
+
575
+ send(data) {
576
+ // Store ALL messages for now - we'll filter when returning
577
+ this.messages.push(data);
578
+
579
+ // Extract sessionId if present
580
+ if (typeof data === 'string') {
581
+ try {
582
+ const parsed = JSON.parse(data);
583
+ if (parsed.sessionId) {
584
+ this.sessionId = parsed.sessionId;
585
+ }
586
+ } catch (e) {
587
+ // Not JSON, ignore
588
+ }
589
+ } else if (data && data.sessionId) {
590
+ this.sessionId = data.sessionId;
591
+ }
592
+ }
593
+
594
+ end() {
595
+ // Do nothing - we'll collect all messages
596
+ }
597
+
598
+ setSessionId(sessionId) {
599
+ this.sessionId = sessionId;
600
+ }
601
+
602
+ getSessionId() {
603
+ return this.sessionId;
604
+ }
605
+
606
+ getMessages() {
607
+ return this.messages;
608
+ }
609
+
610
+ /**
611
+ * Get filtered assistant messages.
612
+ *
613
+ * Two message shapes are observed in the wild:
614
+ * 1. Legacy Claude-only: { type:'claude-response', data:{ type:'assistant', message:{...} } }
615
+ * 2. Unified normalized: { kind:'stream_delta'|'tool_use'|... , provider, content, ... }
616
+ * (every provider after the v1.30+ unified-message migration emits this)
617
+ *
618
+ * Pre-fix this method only matched (1), so qwen / gemini / opencode / codex
619
+ * runs all returned an empty array even when the provider streamed real
620
+ * text. Now it builds:
621
+ * - one synthetic assistant entry per chat turn from concatenated
622
+ * `stream_delta` content (boundary = `stream_end` or `complete`)
623
+ * - tool_use / tool_result entries pass through verbatim
624
+ */
625
+ getAssistantMessages() {
626
+ const out = [];
627
+ let textBuffer = '';
628
+
629
+ const flushText = () => {
630
+ if (!textBuffer) return;
631
+ out.push({
632
+ type: 'assistant',
633
+ message: {
634
+ role: 'assistant',
635
+ content: [{ type: 'text', text: textBuffer }],
636
+ },
637
+ });
638
+ textBuffer = '';
639
+ };
640
+
641
+ for (const raw of this.messages) {
642
+ const data = typeof raw === 'string'
643
+ ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
644
+ : raw;
645
+ if (!data) continue;
646
+ if (data.type === 'status') continue;
647
+
648
+ // Unified shape (every modern provider).
649
+ // - `stream_delta`: incremental text chunk (most providers)
650
+ // - `text`: full text part for one assistant turn (Claude SDK + history reads)
651
+ // - `thinking`: reasoning blocks; we coalesce as plain text so the API caller sees something
652
+ if ((data.kind === 'stream_delta' || data.kind === 'text' || data.kind === 'thinking')
653
+ && (typeof data.content === 'string' || Array.isArray(data.content))) {
654
+ const text = typeof data.content === 'string'
655
+ ? data.content
656
+ : data.content.map((part) => (typeof part === 'string' ? part : (part?.text || ''))).join('');
657
+ textBuffer += text;
658
+ continue;
659
+ }
660
+ if (data.kind === 'stream_end' || data.kind === 'complete') {
661
+ flushText();
662
+ continue;
663
+ }
664
+ if (data.kind === 'tool_use') {
665
+ flushText();
666
+ out.push({ type: 'tool_use', id: data.toolId, name: data.toolName, input: data.toolInput });
667
+ continue;
668
+ }
669
+ if (data.kind === 'tool_result') {
670
+ out.push({ type: 'tool_result', tool_use_id: data.toolId, content: data.content, is_error: data.isError });
671
+ continue;
672
+ }
673
+ if (data.kind === 'error' && typeof data.content === 'string') {
674
+ flushText();
675
+ out.push({ type: 'error', content: data.content });
676
+ continue;
677
+ }
678
+
679
+ // Legacy Claude shape — kept so old SDK builds still report cleanly.
680
+ if (data.type === 'claude-response' && data.data && data.data.type === 'assistant') {
681
+ flushText();
682
+ out.push(data.data);
683
+ }
684
+ }
685
+ flushText();
686
+ return out;
687
+ }
688
+
689
+ /**
690
+ * Calculate total tokens from all messages.
691
+ *
692
+ * Two usage shapes observed:
693
+ * 1. Legacy Claude: { type:'claude-response', data:{ message:{ usage:{ input_tokens, output_tokens, cache_*_input_tokens } } } }
694
+ * 2. Unified `complete`/ { kind:'complete'|'stream_end', usage:{ input, output, cacheRead?, cacheCreation? }, cost? }
695
+ * `stream_end` events
696
+ */
697
+ getTotalTokens() {
698
+ let inputTokens = 0;
699
+ let outputTokens = 0;
700
+ let cacheReadTokens = 0;
701
+ let cacheCreationTokens = 0;
702
+
703
+ for (const raw of this.messages) {
704
+ const data = typeof raw === 'string'
705
+ ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
706
+ : raw;
707
+ if (!data) continue;
708
+
709
+ // Unified shape
710
+ if (data.usage && typeof data.usage === 'object') {
711
+ inputTokens += data.usage.input || data.usage.inputTokens || data.usage.input_tokens || 0;
712
+ outputTokens += data.usage.output || data.usage.outputTokens || data.usage.output_tokens || 0;
713
+ cacheReadTokens += data.usage.cacheRead || data.usage.cache_read_input_tokens || 0;
714
+ cacheCreationTokens += data.usage.cacheCreation || data.usage.cache_creation_input_tokens || 0;
715
+ continue;
716
+ }
717
+
718
+ // Legacy Claude
719
+ if (data.type === 'claude-response' && data.data && data.data.message && data.data.message.usage) {
720
+ const u = data.data.message.usage;
721
+ inputTokens += u.input_tokens || 0;
722
+ outputTokens += u.output_tokens || 0;
723
+ cacheReadTokens += u.cache_read_input_tokens || 0;
724
+ cacheCreationTokens += u.cache_creation_input_tokens || 0;
725
+ }
726
+ }
727
+
728
+ return {
729
+ inputTokens,
730
+ outputTokens,
731
+ cacheReadTokens,
732
+ cacheCreationTokens,
733
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens,
734
+ };
735
+ }
736
+ }
737
+
738
+ // ===============================
739
+ // External API Endpoint
740
+ // ===============================
741
+
742
+ /**
743
+ * POST /api/agent
744
+ *
745
+ * Trigger an AI agent (Claude or Cursor) to work on a project.
746
+ * Supports automatic GitHub branch and pull request creation after successful completion.
747
+ *
748
+ * ================================================================================================
749
+ * REQUEST BODY PARAMETERS
750
+ * ================================================================================================
751
+ *
752
+ * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
753
+ * Supported formats:
754
+ * - HTTPS: https://github.com/owner/repo
755
+ * - HTTPS with .git: https://github.com/owner/repo.git
756
+ * - SSH: git@github.com:owner/repo
757
+ * - SSH with .git: git@github.com:owner/repo.git
758
+ *
759
+ * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
760
+ * Behavior depends on usage:
761
+ * - If used alone: Must point to existing project directory
762
+ * - If used with githubUrl: Target location for cloning
763
+ * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
764
+ *
765
+ * @param {string} message - (Required) Task description for the AI agent. Used as:
766
+ * - Instructions for the agent
767
+ * - Source for auto-generated branch names (if createBranch=true and no branchName)
768
+ * - Fallback for PR title if no commits are made
769
+ *
770
+ * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
771
+ * Default: 'claude'
772
+ *
773
+ * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
774
+ * Default: true
775
+ * - true: Returns text/event-stream with incremental updates
776
+ * - false: Returns complete JSON response after completion
777
+ *
778
+ * @param {string} model - (Optional) Model identifier for providers.
779
+ *
780
+ * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
781
+ * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
782
+ * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
783
+ * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
784
+ * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
785
+ * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
786
+ *
787
+ * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
788
+ * Default: true
789
+ * Behavior:
790
+ * - Only applies when cloning via githubUrl (not for existing projectPath)
791
+ * - Deletes cloned repository after 5 seconds
792
+ * - Also deletes associated Claude session directory
793
+ * - Remote branch and PR remain on GitHub if created
794
+ *
795
+ * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
796
+ * Overrides stored token from user settings.
797
+ * Required for:
798
+ * - Private repositories
799
+ * - Branch/PR creation features
800
+ * Token must have 'repo' scope for full functionality.
801
+ *
802
+ * @param {string} branchName - (Optional) Custom name for the Git branch.
803
+ * If provided, createBranch is automatically set to true.
804
+ * Validation rules (errors returned if violated):
805
+ * - Cannot be empty or whitespace only
806
+ * - Cannot start or end with dot (.)
807
+ * - Cannot contain consecutive dots (..)
808
+ * - Cannot contain spaces
809
+ * - Cannot contain special characters: ~ ^ : ? * [ \
810
+ * - Cannot contain @{
811
+ * - Cannot start or end with forward slash (/)
812
+ * - Cannot contain consecutive slashes (//)
813
+ * - Cannot end with .lock
814
+ * - Cannot contain ASCII control characters
815
+ * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
816
+ *
817
+ * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
818
+ * Default: false (or true if branchName is provided)
819
+ * Behavior:
820
+ * - Creates branch locally and pushes to remote
821
+ * - If branch exists locally: Checks out existing branch (no error)
822
+ * - If branch exists on remote: Uses existing branch (no error)
823
+ * - Branch name: Custom (if branchName provided) or auto-generated from message
824
+ * - Requires either githubUrl OR projectPath with GitHub remote
825
+ *
826
+ * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
827
+ * Default: false
828
+ * Behavior:
829
+ * - PR title: First commit message (or fallback to message parameter)
830
+ * - PR description: Auto-generated from all commit messages
831
+ * - Base branch: Always 'main' (currently hardcoded)
832
+ * - If PR already exists: GitHub returns error with details
833
+ * - Requires either githubUrl OR projectPath with GitHub remote
834
+ *
835
+ * ================================================================================================
836
+ * PATH HANDLING BEHAVIOR
837
+ * ================================================================================================
838
+ *
839
+ * Scenario 1: Only githubUrl provided
840
+ * Input: { githubUrl: "https://github.com/owner/repo" }
841
+ * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
842
+ * Cleanup: Yes (if cleanup=true)
843
+ *
844
+ * Scenario 2: Only projectPath provided
845
+ * Input: { projectPath: "/home/user/my-project" }
846
+ * Action: Uses existing project at specified path
847
+ * Validation: Path must exist and be accessible
848
+ * Cleanup: No (never cleanup existing projects)
849
+ *
850
+ * Scenario 3: Both githubUrl and projectPath provided
851
+ * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
852
+ * Action: Clones githubUrl to projectPath location
853
+ * Validation:
854
+ * - If projectPath exists with git repo:
855
+ * - Compares remote URL with githubUrl
856
+ * - If URLs match: Reuses existing repo
857
+ * - If URLs differ: Returns error
858
+ * Cleanup: Yes (if cleanup=true)
859
+ *
860
+ * ================================================================================================
861
+ * GITHUB BRANCH/PR CREATION REQUIREMENTS
862
+ * ================================================================================================
863
+ *
864
+ * For createBranch or createPR to work, one of the following must be true:
865
+ *
866
+ * Option A: githubUrl provided
867
+ * - Repository URL directly specified
868
+ * - Works with both cloning and existing paths
869
+ *
870
+ * Option B: projectPath with GitHub remote
871
+ * - Project must be a Git repository
872
+ * - Must have 'origin' remote configured
873
+ * - Remote URL must point to github.com
874
+ * - System auto-detects GitHub URL via: git remote get-url origin
875
+ *
876
+ * Additional Requirements:
877
+ * - Valid GitHub token (from settings or githubToken parameter)
878
+ * - Token must have 'repo' scope for private repos
879
+ * - Project must have commits (for PR creation)
880
+ *
881
+ * ================================================================================================
882
+ * VALIDATION & ERROR HANDLING
883
+ * ================================================================================================
884
+ *
885
+ * Input Validations (400 Bad Request):
886
+ * - Either githubUrl OR projectPath must be provided (not neither)
887
+ * - message must be non-empty string
888
+ * - provider must be 'claude', 'cursor', 'codex', or 'gemini'
889
+ * - createBranch/createPR requires githubUrl OR projectPath (not neither)
890
+ * - branchName must pass Git naming rules (if provided)
891
+ *
892
+ * Runtime Validations (500 Internal Server Error or specific error in response):
893
+ * - projectPath must exist (if used alone)
894
+ * - GitHub URL format must be valid
895
+ * - Git remote URL must include github.com (for projectPath + branch/PR)
896
+ * - GitHub token must be available (for private repos and branch/PR)
897
+ * - Directory conflicts handled (existing path with different repo)
898
+ *
899
+ * Branch Name Validation Errors (returned in response, not HTTP error):
900
+ * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
901
+ * Examples:
902
+ * - "my branch" → "Branch name cannot contain spaces"
903
+ * - ".feature" → "Branch name cannot start with a dot"
904
+ * - "feature.lock" → "Branch name cannot end with .lock"
905
+ *
906
+ * ================================================================================================
907
+ * RESPONSE FORMATS
908
+ * ================================================================================================
909
+ *
910
+ * Streaming Response (stream=true):
911
+ * Content-Type: text/event-stream
912
+ * Events:
913
+ * - { type: "status", message: "...", projectPath: "..." }
914
+ * - { type: "claude-response", data: {...} }
915
+ * - { type: "github-branch", branch: { name: "...", url: "..." } }
916
+ * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
917
+ * - { type: "github-error", error: "..." }
918
+ * - { type: "done" }
919
+ *
920
+ * Non-Streaming Response (stream=false):
921
+ * Content-Type: application/json
922
+ * {
923
+ * success: true,
924
+ * sessionId: "session-123",
925
+ * messages: [...], // Assistant messages only (filtered)
926
+ * tokens: {
927
+ * inputTokens: 150,
928
+ * outputTokens: 50,
929
+ * cacheReadTokens: 0,
930
+ * cacheCreationTokens: 0,
931
+ * totalTokens: 200
932
+ * },
933
+ * projectPath: "/path/to/project",
934
+ * branch: { // Only if createBranch=true
935
+ * name: "feature/xyz",
936
+ * url: "https://github.com/owner/repo/tree/feature/xyz"
937
+ * } | { error: "..." },
938
+ * pullRequest: { // Only if createPR=true
939
+ * number: 42,
940
+ * url: "https://github.com/owner/repo/pull/42"
941
+ * } | { error: "..." }
942
+ * }
943
+ *
944
+ * Error Response:
945
+ * HTTP Status: 400, 401, 500
946
+ * Content-Type: application/json
947
+ * { success: false, error: "Error description" }
948
+ *
949
+ * ================================================================================================
950
+ * EXAMPLES
951
+ * ================================================================================================
952
+ *
953
+ * Example 1: Clone and process with auto-cleanup
954
+ * POST /api/agent
955
+ * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
956
+ *
957
+ * Example 2: Use existing project with custom branch and PR
958
+ * POST /api/agent
959
+ * {
960
+ * "projectPath": "/home/user/project",
961
+ * "message": "Add feature",
962
+ * "branchName": "feature/new-feature",
963
+ * "createPR": true
964
+ * }
965
+ *
966
+ * Example 3: Clone to specific path with auto-generated branch
967
+ * POST /api/agent
968
+ * {
969
+ * "githubUrl": "https://github.com/user/repo",
970
+ * "projectPath": "/tmp/work",
971
+ * "message": "Refactor code",
972
+ * "createBranch": true,
973
+ * "cleanup": false
974
+ * }
975
+ */
976
+ router.post('/', validateExternalApiKey, async (req, res) => {
977
+ const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
978
+ const requestedPermissionMode = typeof req.body.permissionMode === 'string' ? req.body.permissionMode : null;
979
+ const permissionMode = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'auto_edit', 'yolo'].includes(requestedPermissionMode)
980
+ ? requestedPermissionMode
981
+ : null;
982
+ const suppressNotifications = req.body.suppressNotifications === true || req.body.suppressNotifications === 'true';
983
+
984
+ // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
985
+ const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
986
+ const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
987
+
988
+ // If branchName is provided, automatically enable createBranch
989
+ const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
990
+ const createPR = req.body.createPR === true || req.body.createPR === 'true';
991
+
992
+ // Validate inputs
993
+ if (!githubUrl && !projectPath) {
994
+ return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
995
+ }
996
+
997
+ if (!message || !message.trim()) {
998
+ return res.status(400).json({ error: 'message is required' });
999
+ }
1000
+
1001
+ if (!['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'].includes(provider)) {
1002
+ return res.status(400).json({ error: 'provider must be one of: claude, cursor, codex, gemini, qwen, opencode' });
1003
+ }
1004
+
1005
+ // Validate GitHub branch/PR creation requirements
1006
+ // Allow branch/PR creation with projectPath as long as it has a GitHub remote
1007
+ if ((createBranch || createPR) && !githubUrl && !projectPath) {
1008
+ return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
1009
+ }
1010
+
1011
+ let finalProjectPath = null;
1012
+ let writer = null;
1013
+
1014
+ try {
1015
+ // Determine the final project path
1016
+ if (githubUrl) {
1017
+ // Clone repository (to projectPath if provided, otherwise generate path)
1018
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1019
+
1020
+ let targetPath;
1021
+ if (projectPath) {
1022
+ targetPath = projectPath;
1023
+ } else {
1024
+ // Generate a unique path for cloning
1025
+ const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
1026
+ targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
1027
+ }
1028
+
1029
+ finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
1030
+ } else {
1031
+ // Use existing project path
1032
+ finalProjectPath = path.resolve(projectPath);
1033
+
1034
+ // Verify the path exists
1035
+ try {
1036
+ await fs.access(finalProjectPath);
1037
+ } catch (error) {
1038
+ throw new Error(`Project path does not exist: ${finalProjectPath}`);
1039
+ }
1040
+ }
1041
+
1042
+ // Register the project (or use existing registration)
1043
+ let project;
1044
+ try {
1045
+ project = await addProjectManually(finalProjectPath);
1046
+ console.log('📦 Project registered:', project);
1047
+ } catch (error) {
1048
+ // If project already exists, that's fine - continue with the existing registration
1049
+ if (error.message && error.message.includes('Project already configured')) {
1050
+ console.log('📦 Using existing project registration for:', finalProjectPath);
1051
+ project = { path: finalProjectPath };
1052
+ } else {
1053
+ throw error;
1054
+ }
1055
+ }
1056
+
1057
+ // Set up writer based on streaming mode
1058
+ if (stream) {
1059
+ // Set up SSE headers for streaming
1060
+ res.setHeader('Content-Type', 'text/event-stream');
1061
+ res.setHeader('Cache-Control', 'no-cache');
1062
+ res.setHeader('Connection', 'keep-alive');
1063
+ res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
1064
+
1065
+ writer = new SSEStreamWriter(res, req.user.id);
1066
+
1067
+ // Send initial status
1068
+ writer.send({
1069
+ type: 'status',
1070
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
1071
+ projectPath: finalProjectPath
1072
+ });
1073
+ } else {
1074
+ // Non-streaming mode: collect messages
1075
+ writer = new ResponseCollector(req.user.id);
1076
+
1077
+ // Collect initial status message
1078
+ writer.send({
1079
+ type: 'status',
1080
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
1081
+ projectPath: finalProjectPath
1082
+ });
1083
+ }
1084
+
1085
+ // Start the appropriate session
1086
+ if (provider === 'claude') {
1087
+ console.log('🤖 Starting Claude SDK session');
1088
+
1089
+ await queryClaudeSDK(message.trim(), {
1090
+ projectPath: finalProjectPath,
1091
+ cwd: finalProjectPath,
1092
+ sessionId: sessionId || null,
1093
+ model: model,
1094
+ permissionMode: permissionMode || 'bypassPermissions', // Bypass all permissions for API calls unless explicitly restricted
1095
+ suppressNotifications,
1096
+ }, writer);
1097
+
1098
+ } else if (provider === 'cursor') {
1099
+ console.log('🖱️ Starting Cursor CLI session');
1100
+
1101
+ await spawnCursor(message.trim(), {
1102
+ projectPath: finalProjectPath,
1103
+ cwd: finalProjectPath,
1104
+ sessionId: sessionId || null,
1105
+ model: model || undefined,
1106
+ skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1107
+ suppressNotifications,
1108
+ }, writer);
1109
+ } else if (provider === 'codex') {
1110
+ console.log('🤖 Starting Codex SDK session');
1111
+
1112
+ await queryCodex(message.trim(), {
1113
+ projectPath: finalProjectPath,
1114
+ cwd: finalProjectPath,
1115
+ sessionId: sessionId || null,
1116
+ model: model || getDefaultProviderModel('codex'),
1117
+ permissionMode: permissionMode || 'bypassPermissions',
1118
+ suppressNotifications,
1119
+ }, writer);
1120
+ } else if (provider === 'gemini') {
1121
+ console.log('✨ Starting Gemini CLI session');
1122
+
1123
+ await spawnGemini(message.trim(), {
1124
+ projectPath: finalProjectPath,
1125
+ cwd: finalProjectPath,
1126
+ sessionId: sessionId || null,
1127
+ model: model,
1128
+ permissionMode: permissionMode || undefined,
1129
+ skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1130
+ suppressNotifications,
1131
+ }, writer);
1132
+ } else if (provider === 'qwen') {
1133
+ console.log('🐉 Starting Qwen Code CLI session');
1134
+
1135
+ await spawnQwen(message.trim(), {
1136
+ projectPath: finalProjectPath,
1137
+ cwd: finalProjectPath,
1138
+ sessionId: sessionId || null,
1139
+ model: model,
1140
+ permissionMode: permissionMode || undefined,
1141
+ skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1142
+ suppressNotifications,
1143
+ }, writer);
1144
+ } else if (provider === 'opencode') {
1145
+ console.log('🅾️ Starting OpenCode CLI session');
1146
+
1147
+ await spawnOpencode(message.trim(), {
1148
+ projectPath: finalProjectPath,
1149
+ cwd: finalProjectPath,
1150
+ sessionId: sessionId || null,
1151
+ model: model,
1152
+ permissionMode: permissionMode || 'bypassPermissions',
1153
+ toolsSettings: {
1154
+ allowPatterns: [],
1155
+ denyPatterns: [],
1156
+ skipPermissions: permissionMode ? permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo' : true,
1157
+ },
1158
+ suppressNotifications,
1159
+ }, writer);
1160
+ }
1161
+
1162
+ // Handle GitHub branch and PR creation after successful agent completion
1163
+ let branchInfo = null;
1164
+ let prInfo = null;
1165
+
1166
+ if (createBranch || createPR) {
1167
+ try {
1168
+ console.log('🔄 Starting GitHub branch/PR creation workflow...');
1169
+
1170
+ // Get GitHub token
1171
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1172
+
1173
+ if (!tokenToUse) {
1174
+ throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1175
+ }
1176
+
1177
+ // Initialize Octokit
1178
+ const octokit = new Octokit({ auth: tokenToUse });
1179
+
1180
+ // Get GitHub URL - either from parameter or from git remote
1181
+ let repoUrl = githubUrl;
1182
+ if (!repoUrl) {
1183
+ console.log('🔍 Getting GitHub URL from git remote...');
1184
+ try {
1185
+ repoUrl = await getGitRemoteUrl(finalProjectPath);
1186
+ if (!repoUrl.includes('github.com')) {
1187
+ throw new Error('Project does not have a GitHub remote configured');
1188
+ }
1189
+ console.log(`✅ Found GitHub remote: ${repoUrl}`);
1190
+ } catch (error) {
1191
+ throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1192
+ }
1193
+ }
1194
+
1195
+ // Parse GitHub URL to get owner and repo
1196
+ const { owner, repo } = parseGitHubUrl(repoUrl);
1197
+ console.log(`📦 Repository: ${owner}/${repo}`);
1198
+
1199
+ // Use provided branch name or auto-generate from message
1200
+ const finalBranchName = branchName || autogenerateBranchName(message);
1201
+ if (branchName) {
1202
+ console.log(`🌿 Using provided branch name: ${finalBranchName}`);
1203
+
1204
+ // Validate custom branch name
1205
+ const validation = validateBranchName(finalBranchName);
1206
+ if (!validation.valid) {
1207
+ throw new Error(`Invalid branch name: ${validation.error}`);
1208
+ }
1209
+ } else {
1210
+ console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
1211
+ }
1212
+
1213
+ if (createBranch) {
1214
+ // Create and checkout the new branch locally
1215
+ console.log('🔄 Creating local branch...');
1216
+ const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1217
+ cwd: finalProjectPath,
1218
+ stdio: 'pipe'
1219
+ });
1220
+
1221
+ await new Promise((resolve, reject) => {
1222
+ let stderr = '';
1223
+ checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1224
+ checkoutProcess.on('close', (code) => {
1225
+ if (code === 0) {
1226
+ console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
1227
+ resolve();
1228
+ } else {
1229
+ // Branch might already exist locally, try to checkout
1230
+ if (stderr.includes('already exists')) {
1231
+ console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);
1232
+ const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1233
+ cwd: finalProjectPath,
1234
+ stdio: 'pipe'
1235
+ });
1236
+ checkoutExisting.on('close', (checkoutCode) => {
1237
+ if (checkoutCode === 0) {
1238
+ console.log(`✅ Checked out existing branch '${finalBranchName}'`);
1239
+ resolve();
1240
+ } else {
1241
+ reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1242
+ }
1243
+ });
1244
+ } else {
1245
+ reject(new Error(`Failed to create branch: ${stderr}`));
1246
+ }
1247
+ }
1248
+ });
1249
+ });
1250
+
1251
+ // Push the branch to remote
1252
+ console.log('🔄 Pushing branch to remote...');
1253
+ const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1254
+ cwd: finalProjectPath,
1255
+ stdio: 'pipe'
1256
+ });
1257
+
1258
+ await new Promise((resolve, reject) => {
1259
+ let stderr = '';
1260
+ let stdout = '';
1261
+ pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1262
+ pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1263
+ pushProcess.on('close', (code) => {
1264
+ if (code === 0) {
1265
+ console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
1266
+ resolve();
1267
+ } else {
1268
+ // Check if branch exists on remote but has different commits
1269
+ if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1270
+ console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);
1271
+ resolve();
1272
+ } else {
1273
+ reject(new Error(`Failed to push branch: ${stderr}`));
1274
+ }
1275
+ }
1276
+ });
1277
+ });
1278
+
1279
+ branchInfo = {
1280
+ name: finalBranchName,
1281
+ url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1282
+ };
1283
+ }
1284
+
1285
+ if (createPR) {
1286
+ // Get commit messages to generate PR description
1287
+ console.log('🔄 Generating PR title and description...');
1288
+ const commitMessages = await getCommitMessages(finalProjectPath, 5);
1289
+
1290
+ // Use the first commit message as the PR title, or fallback to the agent message
1291
+ const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1292
+
1293
+ // Generate PR body from commit messages
1294
+ let prBody = '## Changes\n\n';
1295
+ if (commitMessages.length > 0) {
1296
+ prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1297
+ } else {
1298
+ prBody += `Agent task: ${message}`;
1299
+ }
1300
+ prBody += '\n\n---\n*This pull request was automatically created by Pixcode Agent.*';
1301
+
1302
+ console.log(`📝 PR Title: ${prTitle}`);
1303
+
1304
+ // Create the pull request
1305
+ console.log('🔄 Creating pull request...');
1306
+ prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1307
+ }
1308
+
1309
+ // Send branch/PR info in response
1310
+ if (stream) {
1311
+ if (branchInfo) {
1312
+ writer.send({
1313
+ type: 'github-branch',
1314
+ branch: branchInfo
1315
+ });
1316
+ }
1317
+ if (prInfo) {
1318
+ writer.send({
1319
+ type: 'github-pr',
1320
+ pullRequest: prInfo
1321
+ });
1322
+ }
1323
+ }
1324
+
1325
+ } catch (error) {
1326
+ console.error('❌ GitHub branch/PR creation error:', error);
1327
+
1328
+ // Send error but don't fail the entire request
1329
+ if (stream) {
1330
+ writer.send({
1331
+ type: 'github-error',
1332
+ error: error.message
1333
+ });
1334
+ }
1335
+ // Store error info for non-streaming response
1336
+ if (!stream) {
1337
+ branchInfo = { error: error.message };
1338
+ prInfo = { error: error.message };
1339
+ }
1340
+ }
1341
+ }
1342
+
1343
+ // Handle response based on streaming mode
1344
+ if (stream) {
1345
+ // Streaming mode: end the SSE stream
1346
+ writer.end();
1347
+ } else {
1348
+ // Non-streaming mode: send filtered messages and token summary as JSON
1349
+ const assistantMessages = writer.getAssistantMessages();
1350
+ const tokenSummary = writer.getTotalTokens();
1351
+
1352
+ // Promote provider-side errors (`writer.send({ kind:'error', ... })`)
1353
+ // to the response envelope. Without this, providers like Codex —
1354
+ // whose SDK swallows throws and only emits an error message — left
1355
+ // callers with `{ success:true, messages:[] }`, indistinguishable
1356
+ // from a quiet success. Now: any error event => success:false and
1357
+ // the human-readable text on `error`.
1358
+ const errorEntry = assistantMessages.find((m) => m.type === 'error');
1359
+ const hasAssistantText = assistantMessages.some(
1360
+ (m) => m.type === 'assistant' && m.message?.content?.some?.((p) => p.type === 'text' && p.text)
1361
+ );
1362
+ const succeeded = !errorEntry && (hasAssistantText || assistantMessages.some((m) => m.type === 'tool_use' || m.type === 'tool_result'));
1363
+ const failureDetails = succeeded
1364
+ ? null
1365
+ : describeProviderFailure(
1366
+ errorEntry?.content || 'Provider returned no assistant text. Check backend log for details.',
1367
+ provider,
1368
+ );
1369
+
1370
+ const response = {
1371
+ success: succeeded,
1372
+ sessionId: writer.getSessionId(),
1373
+ messages: assistantMessages,
1374
+ tokens: tokenSummary,
1375
+ projectPath: finalProjectPath
1376
+ };
1377
+ if (failureDetails) {
1378
+ response.error = failureDetails.message;
1379
+ response.rawError = failureDetails.rawMessage;
1380
+ response.errorDetails = failureDetails;
1381
+ }
1382
+
1383
+ // Add branch/PR info if created
1384
+ if (branchInfo) {
1385
+ response.branch = branchInfo;
1386
+ }
1387
+ if (prInfo) {
1388
+ response.pullRequest = prInfo;
1389
+ }
1390
+
1391
+ res.status(succeeded ? 200 : 502).json(response);
1392
+ }
1393
+
1394
+ // Clean up if requested
1395
+ if (cleanup && githubUrl) {
1396
+ // Only cleanup if we cloned a repo (not for existing project paths)
1397
+ const sessionIdForCleanup = writer.getSessionId();
1398
+ setTimeout(() => {
1399
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1400
+ }, 5000);
1401
+ }
1402
+
1403
+ } catch (error) {
1404
+ console.error('❌ External session error:', error);
1405
+
1406
+ // Clean up on error
1407
+ if (finalProjectPath && cleanup && githubUrl) {
1408
+ const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1409
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1410
+ }
1411
+
1412
+ if (stream) {
1413
+ // For streaming, send error event and stop
1414
+ if (!writer) {
1415
+ // Set up SSE headers if not already done
1416
+ res.setHeader('Content-Type', 'text/event-stream');
1417
+ res.setHeader('Cache-Control', 'no-cache');
1418
+ res.setHeader('Connection', 'keep-alive');
1419
+ res.setHeader('X-Accel-Buffering', 'no');
1420
+ writer = new SSEStreamWriter(res, req.user.id);
1421
+ }
1422
+
1423
+ if (!res.writableEnded) {
1424
+ writer.send({
1425
+ type: 'error',
1426
+ error: 'Failed to process agent request.',
1427
+ message: 'Failed to process agent request.'
1428
+ });
1429
+ writer.end();
1430
+ }
1431
+ } else if (!res.headersSent) {
1432
+ // Surface any provider-side stderr/error events the writer collected
1433
+ // BEFORE the throw — without this, callers only see the bland
1434
+ // "Gemini CLI exited with code 403" wrapper and lose the actual
1435
+ // "PERMISSION_DENIED, model not enabled for this account" detail
1436
+ // that the CLI printed to stderr.
1437
+ let collectedError = null;
1438
+ let collectedMessages = [];
1439
+ if (writer && typeof writer.getAssistantMessages === 'function') {
1440
+ try {
1441
+ collectedMessages = writer.getAssistantMessages();
1442
+ const errorText = collectedMessages
1443
+ .filter((m) => m.type === 'error' && typeof m.content === 'string' && m.content.trim())
1444
+ .map((m) => m.content.trim())
1445
+ .join('\n');
1446
+ if (errorText) collectedError = errorText;
1447
+ } catch { /* ignore — fall back to error.message */ }
1448
+ }
1449
+ const failureDetails = describeProviderFailure(collectedError || 'Provider returned no assistant text.', provider);
1450
+ res.status(502).json({
1451
+ success: false,
1452
+ sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
1453
+ error: failureDetails.message,
1454
+ rawError: failureDetails.rawMessage,
1455
+ errorDetails: failureDetails,
1456
+ messages: collectedMessages,
1457
+ });
1458
+ }
1459
+ }
1460
+ });
1461
+
1462
+ export default router;