@pixelbyte-software/pixcode 1.35.0 → 1.35.1

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