@pixelbyte-software/pixcode 1.51.1 → 1.51.3

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