@axhub/genie 0.2.2 → 0.2.4

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 (72) hide show
  1. package/dist/assets/App-BxazfNJn.js +484 -0
  2. package/dist/assets/{ReviewApp-Bp_y3xff.js → ReviewApp-CsqTAlGU.js} +1 -1
  3. package/dist/assets/{_basePickBy-C5f221Kr.js → _basePickBy-CFRQvihx.js} +1 -1
  4. package/dist/assets/{_baseUniq-CeEXFlBh.js → _baseUniq-Dhh8nCvs.js} +1 -1
  5. package/dist/assets/{arc-CZVQXROF.js → arc-DQ0v3dU4.js} +1 -1
  6. package/dist/assets/{architectureDiagram-2XIMDMQ5-D91MBXeh.js → architectureDiagram-2XIMDMQ5-DmUHdvQH.js} +1 -1
  7. package/dist/assets/{blockDiagram-WCTKOSBZ-CsHP3zT2.js → blockDiagram-WCTKOSBZ-Bbxhj5KC.js} +1 -1
  8. package/dist/assets/{c4Diagram-IC4MRINW-DakKlk21.js → c4Diagram-IC4MRINW-BOivDlQU.js} +1 -1
  9. package/dist/assets/channel-Cj8xVD0X.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-BXZoxrtv.js → chunk-4BX2VUAB-DlvtrM0q.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-V9_WXk3w.js → chunk-55IACEB6-DJUSHyTa.js} +1 -1
  12. package/dist/assets/{chunk-FMBD7UC4-IgdHo6Dd.js → chunk-FMBD7UC4-C6Ch-htf.js} +1 -1
  13. package/dist/assets/{chunk-JSJVCQXG-CnaAsDTd.js → chunk-JSJVCQXG-DzQIht58.js} +1 -1
  14. package/dist/assets/{chunk-KX2RTZJC-D0qksU2H.js → chunk-KX2RTZJC-C05jARMH.js} +1 -1
  15. package/dist/assets/{chunk-NQ4KR5QH-rd6KG4-c.js → chunk-NQ4KR5QH-Ci-n7jfu.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-Cyltgv4l.js → chunk-QZHKN3VN-jxti9HTX.js} +1 -1
  17. package/dist/assets/{chunk-WL4C6EOR-DkNtSo86.js → chunk-WL4C6EOR-C559Mk71.js} +1 -1
  18. package/dist/assets/classDiagram-VBA2DB6C-CI2zklxw.js +1 -0
  19. package/dist/assets/classDiagram-v2-RAHNMMFH-CI2zklxw.js +1 -0
  20. package/dist/assets/clone-BEVqubrI.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-D_Wsd3iv.js → cose-bilkent-S5V4N54A-DNO9ncXL.js} +1 -1
  22. package/dist/assets/{dagre-KLK3FWXG-CrrmZeGu.js → dagre-KLK3FWXG-DJ3dNSYk.js} +1 -1
  23. package/dist/assets/{diagram-E7M64L7V-WzO26pGn.js → diagram-E7M64L7V-Ba_LGLun.js} +1 -1
  24. package/dist/assets/{diagram-IFDJBPK2-COynsdO3.js → diagram-IFDJBPK2-Da6K4aP-.js} +1 -1
  25. package/dist/assets/{diagram-P4PSJMXO-CSqD5HJx.js → diagram-P4PSJMXO-vZZKB92A.js} +1 -1
  26. package/dist/assets/{erDiagram-INFDFZHY-BiFhS6xi.js → erDiagram-INFDFZHY-Csb8dFdP.js} +1 -1
  27. package/dist/assets/{flowDiagram-PKNHOUZH-9jdAJSs0.js → flowDiagram-PKNHOUZH-DUV13pHi.js} +1 -1
  28. package/dist/assets/{ganttDiagram-A5KZAMGK-pGyMKWCo.js → ganttDiagram-A5KZAMGK-B5Kv9Wfz.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-K3NZZRJ6-D4jvwoW1.js → gitGraphDiagram-K3NZZRJ6-BZ5gW69I.js} +1 -1
  30. package/dist/assets/{graph-DMknFkkX.js → graph-BbvHswRd.js} +1 -1
  31. package/dist/assets/{highlighted-body-TPN3WLV5-DhMGh1O7.js → highlighted-body-TPN3WLV5-DZJajMGm.js} +1 -1
  32. package/dist/assets/index-BFX9lxRB.css +1 -0
  33. package/dist/assets/index-BiErUGrv.js +2 -0
  34. package/dist/assets/{infoDiagram-LFFYTUFH-CP5zYiVP.js → infoDiagram-LFFYTUFH-8auUIPKW.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-PHBUUO56-BplLZAQ5.js → ishikawaDiagram-PHBUUO56-JmsNlo2I.js} +1 -1
  36. package/dist/assets/{journeyDiagram-4ABVD52K-CYVgkl-y.js → journeyDiagram-4ABVD52K-Cuudv7Vv.js} +1 -1
  37. package/dist/assets/{kanban-definition-K7BYSVSG-D3on0q66.js → kanban-definition-K7BYSVSG-Bappd2YO.js} +1 -1
  38. package/dist/assets/{layout-DoKWZNVk.js → layout-BmbfFZKy.js} +1 -1
  39. package/dist/assets/{linear-D4YTLdon.js → linear-WZnF-PT6.js} +1 -1
  40. package/dist/assets/{mermaid-O7DHMXV3-KLW3VWsF.js → mermaid-O7DHMXV3-D-2fQRvw.js} +5 -5
  41. package/dist/assets/{mindmap-definition-YRQLILUH-EEAggxM3.js → mindmap-definition-YRQLILUH-BQHnzzud.js} +1 -1
  42. package/dist/assets/{pieDiagram-SKSYHLDU-Da-_fmYg.js → pieDiagram-SKSYHLDU-uxjlAy1t.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-337W2JSQ-Dq4gr7Sw.js → quadrantDiagram-337W2JSQ-DpwZU-f_.js} +1 -1
  44. package/dist/assets/{requirementDiagram-Z7DCOOCP-DNSXyCNU.js → requirementDiagram-Z7DCOOCP-C_9ClOWm.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-WA2Y5GQK-CT4ST2HW.js → sankeyDiagram-WA2Y5GQK-2-FHHM-R.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-2WXFIKYE-CshVYjrF.js → sequenceDiagram-2WXFIKYE-egns-0XI.js} +1 -1
  47. package/dist/assets/{stateDiagram-RAJIS63D-hsG5Yi2A.js → stateDiagram-RAJIS63D-DoW8U53H.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-FVOUBMTO-BoFZZ4Ds.js +1 -0
  49. package/dist/assets/{timeline-definition-YZTLITO2-EztFFK5F.js → timeline-definition-YZTLITO2-chPa8ppH.js} +1 -1
  50. package/dist/assets/{treemap-KZPCXAKY-D5UZCcq_.js → treemap-KZPCXAKY-ajdAP-72.js} +1 -1
  51. package/dist/assets/{vennDiagram-LZ73GAT5-9Qwv8UTR.js → vennDiagram-LZ73GAT5-C9If0AT0.js} +1 -1
  52. package/dist/assets/{xychartDiagram-JWTSCODW-BBjpC1Qv.js → xychartDiagram-JWTSCODW-DD42U6Or.js} +1 -1
  53. package/dist/index.html +2 -2
  54. package/package.json +2 -2
  55. package/server/database/db.js +0 -45
  56. package/server/external-agent/service.js +39 -490
  57. package/server/external-agent/service.test.js +12 -0
  58. package/server/index.js +21 -43
  59. package/server/middleware/auth.js +15 -1
  60. package/server/routes/agent.js +15 -1136
  61. package/server/routes/cli-auth.js +60 -52
  62. package/server/routes/projects.js +10 -304
  63. package/server/routes/settings.js +4 -0
  64. package/server/routes/user.js +0 -102
  65. package/dist/assets/App-Cay5kE3A.js +0 -504
  66. package/dist/assets/channel-CnzaP2H9.js +0 -1
  67. package/dist/assets/classDiagram-VBA2DB6C-CUcYxMZ8.js +0 -1
  68. package/dist/assets/classDiagram-v2-RAHNMMFH-CUcYxMZ8.js +0 -1
  69. package/dist/assets/clone-DJXJGSg2.js +0 -1
  70. package/dist/assets/index-2198VgsK.css +0 -1
  71. package/dist/assets/index-EMGPq9Uy.js +0 -2
  72. package/dist/assets/stateDiagram-v2-FVOUBMTO-BQa1Ppoo.js +0 -1
@@ -1,33 +1,12 @@
1
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 { queryCodex } from '../openai-codex.js';
11
- import { queryGemini } from '../gemini-cli.js';
12
- import { queryOpencode } from '../opencode-cli.js';
2
+
13
3
  import { SessionEventMirrorWriter } from '../session-core/runtimeWriter.js';
14
- import { ABORTABLE_AGENT_PROVIDERS, abortAgentSessionWithWriter, isAbortableAgentProvider } from '../session-core/abortSession.js';
15
- import {
16
- buildAgentCallbackPayload,
17
- createAgentCallbackEventId,
18
- createEmptyTokenSummary,
19
- normalizeAgentCallbackConfig,
20
- scheduleAgentCallbackDelivery,
21
- shouldDeliverAgentCallback
22
- } from '../utils/agentCallback.js';
23
- import { Octokit } from '@octokit/rest';
24
- import { CODEX_MODELS, GEMINI_MODELS, OPENCODE_MODELS } from '../../shared/modelConstants.js';
25
- import { IS_PLATFORM } from '../constants/config.js';
26
- import { validateExternalApiKey as validateExternalAgentApiKey } from '../external-agent/auth.js';
4
+ import { abortAgentSessionWithWriter } from '../session-core/abortSession.js';
5
+ import { validateExternalApiKey } from '../external-agent/auth.js';
27
6
  import {
28
- NoopWriter as ExternalNoopWriter,
29
- ResponseCollector as ExternalResponseCollector,
30
- SSEStreamWriter as ExternalSSEStreamWriter,
7
+ NoopWriter,
8
+ ResponseCollector,
9
+ SSEStreamWriter,
31
10
  normalizeExternalAgentAbortRequest,
32
11
  normalizeExternalAgentRunRequest,
33
12
  runExternalAgentRequest
@@ -35,1061 +14,7 @@ import {
35
14
 
36
15
  const router = express.Router();
37
16
 
38
- function buildSessionNavigation(sessionId) {
39
- const normalizedSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
40
- const sessionPath = normalizedSessionId ? `/session/${normalizedSessionId}` : null;
41
- const frontendPort = process.env.VITE_PORT || '5173';
42
- const configuredFrontendUrl = typeof process.env.FRONTEND_URL === 'string'
43
- ? process.env.FRONTEND_URL.trim().replace(/\/+$/, '')
44
- : '';
45
- const frontendBaseUrl = configuredFrontendUrl || `http://localhost:${frontendPort}`;
46
- const sessionUrl = normalizedSessionId ? `${frontendBaseUrl}${sessionPath}` : null;
47
-
48
- return {
49
- sessionPath,
50
- sessionUrl
51
- };
52
- }
53
-
54
- /**
55
- * Middleware to authenticate agent API requests.
56
- *
57
- * Supports three authentication modes:
58
- * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
59
- * authentication is handled by an external proxy. Requests are trusted and
60
- * the default user context is used.
61
- *
62
- * 2. Optional API key mode (default): For local MVP integration, API key is optional.
63
- * If provided, it is validated against the local database.
64
- *
65
- * 3. Required API key mode: Set AGENT_API_KEY_REQUIRED=true to force API key auth.
66
- */
67
- const validateExternalApiKey = (req, res, next) => {
68
- const requireApiKey = process.env.AGENT_API_KEY_REQUIRED === 'true';
69
-
70
- // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
71
- // Trust the request and use the default user context.
72
- if (IS_PLATFORM) {
73
- try {
74
- const user = userDb.getFirstUser();
75
- if (!user) {
76
- return res.status(500).json({ error: 'Platform mode: No user found in database' });
77
- }
78
- req.user = user;
79
- return next();
80
- } catch (error) {
81
- console.error('Platform mode error:', error);
82
- return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
83
- }
84
- }
85
-
86
- // Self-hosted mode: Validate API key from header or query parameter
87
- const apiKey = req.headers['x-api-key'] || req.query.apiKey;
88
-
89
- if (!apiKey) {
90
- if (requireApiKey) {
91
- return res.status(401).json({ error: 'API key required' });
92
- }
93
-
94
- try {
95
- const user = userDb.getFirstUser();
96
- if (user) {
97
- req.user = user;
98
- }
99
- } catch (error) {
100
- console.warn('Optional API key mode: failed to fetch default user:', error.message);
101
- }
102
-
103
- return next();
104
- }
105
-
106
- const user = apiKeysDb.validateApiKey(apiKey);
107
-
108
- if (!user) {
109
- return res.status(401).json({ error: 'Invalid or inactive API key' });
110
- }
111
-
112
- req.user = user;
113
- next();
114
- };
115
-
116
- /**
117
- * Get the remote URL of a git repository
118
- * @param {string} repoPath - Path to the git repository
119
- * @returns {Promise<string>} - Remote URL of the repository
120
- */
121
- async function getGitRemoteUrl(repoPath) {
122
- return new Promise((resolve, reject) => {
123
- const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
124
- cwd: repoPath,
125
- stdio: ['pipe', 'pipe', 'pipe']
126
- });
127
-
128
- let stdout = '';
129
- let stderr = '';
130
-
131
- gitProcess.stdout.on('data', (data) => {
132
- stdout += data.toString();
133
- });
134
-
135
- gitProcess.stderr.on('data', (data) => {
136
- stderr += data.toString();
137
- });
138
-
139
- gitProcess.on('close', (code) => {
140
- if (code === 0) {
141
- resolve(stdout.trim());
142
- } else {
143
- reject(new Error(`Failed to get git remote: ${stderr}`));
144
- }
145
- });
146
-
147
- gitProcess.on('error', (error) => {
148
- reject(new Error(`Failed to execute git: ${error.message}`));
149
- });
150
- });
151
- }
152
-
153
- /**
154
- * Normalize GitHub URLs for comparison
155
- * @param {string} url - GitHub URL
156
- * @returns {string} - Normalized URL
157
- */
158
- function normalizeGitHubUrl(url) {
159
- // Remove .git suffix
160
- let normalized = url.replace(/\.git$/, '');
161
- // Convert SSH to HTTPS format for comparison
162
- normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
163
- // Remove trailing slash
164
- normalized = normalized.replace(/\/$/, '');
165
- return normalized.toLowerCase();
166
- }
167
-
168
- /**
169
- * Parse GitHub URL to extract owner and repo
170
- * @param {string} url - GitHub URL (HTTPS or SSH)
171
- * @returns {{owner: string, repo: string}} - Parsed owner and repo
172
- */
173
- function parseGitHubUrl(url) {
174
- // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
175
- // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
176
- const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
177
- if (!match) {
178
- throw new Error('Invalid GitHub URL format');
179
- }
180
- return {
181
- owner: match[1],
182
- repo: match[2].replace(/\.git$/, '')
183
- };
184
- }
185
-
186
- /**
187
- * Auto-generate a branch name from a message
188
- * @param {string} message - The agent message
189
- * @returns {string} - Generated branch name
190
- */
191
- function autogenerateBranchName(message) {
192
- // Convert to lowercase, replace spaces/special chars with hyphens
193
- let branchName = message
194
- .toLowerCase()
195
- .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
196
- .replace(/\s+/g, '-') // Replace spaces with hyphens
197
- .replace(/-+/g, '-') // Replace multiple hyphens with single
198
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
199
-
200
- // Ensure non-empty fallback
201
- if (!branchName) {
202
- branchName = 'task';
203
- }
204
-
205
- // Generate timestamp suffix (last 6 chars of base36 timestamp)
206
- const timestamp = Date.now().toString(36).slice(-6);
207
- const suffix = `-${timestamp}`;
208
-
209
- // Limit length to ensure total length including suffix fits within 50 characters
210
- const maxBaseLength = 50 - suffix.length;
211
- if (branchName.length > maxBaseLength) {
212
- branchName = branchName.substring(0, maxBaseLength);
213
- }
214
-
215
- // Remove any trailing hyphen after truncation and ensure no leading hyphen
216
- branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
217
-
218
- // If still empty or starts with hyphen after cleanup, use fallback
219
- if (!branchName || branchName.startsWith('-')) {
220
- branchName = 'task';
221
- }
222
-
223
- // Combine base name with timestamp suffix
224
- branchName = `${branchName}${suffix}`;
225
-
226
- // Final validation: ensure it matches safe pattern
227
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
228
- // Fallback to deterministic safe name
229
- return `branch-${timestamp}`;
230
- }
231
-
232
- return branchName;
233
- }
234
-
235
- /**
236
- * Validate a Git branch name
237
- * @param {string} branchName - Branch name to validate
238
- * @returns {{valid: boolean, error?: string}} - Validation result
239
- */
240
- function validateBranchName(branchName) {
241
- if (!branchName || branchName.trim() === '') {
242
- return { valid: false, error: 'Branch name cannot be empty' };
243
- }
244
-
245
- // Git branch name rules
246
- const invalidPatterns = [
247
- { pattern: /^\./, message: 'Branch name cannot start with a dot' },
248
- { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
249
- { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
250
- { pattern: /\s/, message: 'Branch name cannot contain spaces' },
251
- { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
252
- { pattern: /@{/, message: 'Branch name cannot contain @{' },
253
- { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
254
- { pattern: /^\//, message: 'Branch name cannot start with a slash' },
255
- { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
256
- { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
257
- ];
258
-
259
- for (const { pattern, message } of invalidPatterns) {
260
- if (pattern.test(branchName)) {
261
- return { valid: false, error: message };
262
- }
263
- }
264
-
265
- // Check for ASCII control characters
266
- if (/[\x00-\x1F\x7F]/.test(branchName)) {
267
- return { valid: false, error: 'Branch name cannot contain control characters' };
268
- }
269
-
270
- return { valid: true };
271
- }
272
-
273
- /**
274
- * Get recent commit messages from a repository
275
- * @param {string} projectPath - Path to the git repository
276
- * @param {number} limit - Number of commits to retrieve (default: 5)
277
- * @returns {Promise<string[]>} - Array of commit messages
278
- */
279
- async function getCommitMessages(projectPath, limit = 5) {
280
- return new Promise((resolve, reject) => {
281
- const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
282
- cwd: projectPath,
283
- stdio: ['pipe', 'pipe', 'pipe']
284
- });
285
-
286
- let stdout = '';
287
- let stderr = '';
288
-
289
- gitProcess.stdout.on('data', (data) => {
290
- stdout += data.toString();
291
- });
292
-
293
- gitProcess.stderr.on('data', (data) => {
294
- stderr += data.toString();
295
- });
296
-
297
- gitProcess.on('close', (code) => {
298
- if (code === 0) {
299
- const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
300
- resolve(messages);
301
- } else {
302
- reject(new Error(`Failed to get commit messages: ${stderr}`));
303
- }
304
- });
305
-
306
- gitProcess.on('error', (error) => {
307
- reject(new Error(`Failed to execute git: ${error.message}`));
308
- });
309
- });
310
- }
311
-
312
- /**
313
- * Create a new branch on GitHub using the API
314
- * @param {Octokit} octokit - Octokit instance
315
- * @param {string} owner - Repository owner
316
- * @param {string} repo - Repository name
317
- * @param {string} branchName - Name of the new branch
318
- * @param {string} baseBranch - Base branch to branch from (default: 'main')
319
- * @returns {Promise<void>}
320
- */
321
- async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
322
- try {
323
- // Get the SHA of the base branch
324
- const { data: ref } = await octokit.git.getRef({
325
- owner,
326
- repo,
327
- ref: `heads/${baseBranch}`
328
- });
329
-
330
- const baseSha = ref.object.sha;
331
-
332
- // Create the new branch
333
- await octokit.git.createRef({
334
- owner,
335
- repo,
336
- ref: `refs/heads/${branchName}`,
337
- sha: baseSha
338
- });
339
-
340
- console.log(`✅ Created branch '${branchName}' on GitHub`);
341
- } catch (error) {
342
- if (error.status === 422 && error.message.includes('Reference already exists')) {
343
- console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`);
344
- } else {
345
- throw error;
346
- }
347
- }
348
- }
349
-
350
- /**
351
- * Create a pull request on GitHub
352
- * @param {Octokit} octokit - Octokit instance
353
- * @param {string} owner - Repository owner
354
- * @param {string} repo - Repository name
355
- * @param {string} branchName - Head branch name
356
- * @param {string} title - PR title
357
- * @param {string} body - PR body/description
358
- * @param {string} baseBranch - Base branch (default: 'main')
359
- * @returns {Promise<{number: number, url: string}>} - PR number and URL
360
- */
361
- async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
362
- const { data: pr } = await octokit.pulls.create({
363
- owner,
364
- repo,
365
- title,
366
- head: branchName,
367
- base: baseBranch,
368
- body
369
- });
370
-
371
- console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
372
-
373
- return {
374
- number: pr.number,
375
- url: pr.html_url
376
- };
377
- }
378
-
379
- /**
380
- * Clone a GitHub repository to a directory
381
- * @param {string} githubUrl - GitHub repository URL
382
- * @param {string} githubToken - Optional GitHub token for private repos
383
- * @param {string} projectPath - Path for cloning the repository
384
- * @returns {Promise<string>} - Path to the cloned repository
385
- */
386
- async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
387
- return new Promise(async (resolve, reject) => {
388
- try {
389
- // Validate GitHub URL
390
- if (!githubUrl || !githubUrl.includes('github.com')) {
391
- throw new Error('Invalid GitHub URL');
392
- }
393
-
394
- const cloneDir = path.resolve(projectPath);
395
-
396
- // Check if directory already exists
397
- try {
398
- await fs.access(cloneDir);
399
- // Directory exists - check if it's a git repo with the same URL
400
- try {
401
- const existingUrl = await getGitRemoteUrl(cloneDir);
402
- const normalizedExisting = normalizeGitHubUrl(existingUrl);
403
- const normalizedRequested = normalizeGitHubUrl(githubUrl);
404
-
405
- if (normalizedExisting === normalizedRequested) {
406
- console.log('✅ Repository already exists at path with correct URL');
407
- return resolve(cloneDir);
408
- } else {
409
- throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
410
- }
411
- } catch (gitError) {
412
- throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
413
- }
414
- } catch (accessError) {
415
- // Directory doesn't exist - proceed with clone
416
- }
417
-
418
- // Ensure parent directory exists
419
- await fs.mkdir(path.dirname(cloneDir), { recursive: true });
420
-
421
- // Prepare the git clone URL with authentication if token is provided
422
- let cloneUrl = githubUrl;
423
- if (githubToken) {
424
- // Convert HTTPS URL to authenticated URL
425
- // Example: https://github.com/user/repo -> https://token@github.com/user/repo
426
- cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
427
- }
428
-
429
- console.log('🔄 Cloning repository:', githubUrl);
430
- console.log('📁 Destination:', cloneDir);
431
-
432
- // Execute git clone
433
- const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
434
- stdio: ['pipe', 'pipe', 'pipe']
435
- });
436
-
437
- let stdout = '';
438
- let stderr = '';
439
-
440
- gitProcess.stdout.on('data', (data) => {
441
- stdout += data.toString();
442
- });
443
-
444
- gitProcess.stderr.on('data', (data) => {
445
- stderr += data.toString();
446
- console.log('Git stderr:', data.toString());
447
- });
448
-
449
- gitProcess.on('close', (code) => {
450
- if (code === 0) {
451
- console.log('✅ Repository cloned successfully');
452
- resolve(cloneDir);
453
- } else {
454
- console.error('❌ Git clone failed:', stderr);
455
- reject(new Error(`Git clone failed: ${stderr}`));
456
- }
457
- });
458
-
459
- gitProcess.on('error', (error) => {
460
- reject(new Error(`Failed to execute git: ${error.message}`));
461
- });
462
- } catch (error) {
463
- reject(error);
464
- }
465
- });
466
- }
467
-
468
- /**
469
- * Clean up a temporary project directory and its Claude session
470
- * @param {string} projectPath - Path to the project directory
471
- * @param {string} sessionId - Session ID to clean up
472
- */
473
- async function cleanupProject(projectPath, sessionId = null) {
474
- try {
475
- // Only clean up projects in the external-projects directory
476
- if (!projectPath.includes('.claude/external-projects')) {
477
- console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
478
- return;
479
- }
480
-
481
- console.log('🧹 Cleaning up project:', projectPath);
482
- await fs.rm(projectPath, { recursive: true, force: true });
483
- console.log('✅ Project cleaned up');
484
-
485
- // Also clean up the Claude session directory if sessionId provided
486
- if (sessionId) {
487
- try {
488
- const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
489
- console.log('🧹 Cleaning up session directory:', sessionPath);
490
- await fs.rm(sessionPath, { recursive: true, force: true });
491
- console.log('✅ Session directory cleaned up');
492
- } catch (error) {
493
- console.error('⚠️ Failed to clean up session directory:', error.message);
494
- }
495
- }
496
- } catch (error) {
497
- console.error('❌ Failed to clean up project:', error);
498
- }
499
- }
500
-
501
- /**
502
- * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
503
- */
504
- class SSEStreamWriter {
505
- constructor(res) {
506
- this.res = res;
507
- this.sessionId = null;
508
- this.isSSEStreamWriter = true; // Marker for transport detection
509
- }
510
-
511
- send(data) {
512
- if (this.res.writableEnded) {
513
- return;
514
- }
515
-
516
- // Format as SSE - providers send raw objects, we stringify
517
- this.res.write(`data: ${JSON.stringify(data)}\n\n`);
518
- }
519
-
520
- end() {
521
- if (!this.res.writableEnded) {
522
- this.res.write('data: {"type":"done"}\n\n');
523
- this.res.end();
524
- }
525
- }
526
-
527
- setSessionId(sessionId) {
528
- this.sessionId = sessionId;
529
- }
530
-
531
- getSessionId() {
532
- return this.sessionId;
533
- }
534
- }
535
-
536
- /**
537
- * Non-streaming response collector
538
- */
539
- class ResponseCollector {
540
- constructor() {
541
- this.messages = [];
542
- this.sessionId = null;
543
- }
544
-
545
- send(data) {
546
- // Store ALL messages for now - we'll filter when returning
547
- this.messages.push(data);
548
-
549
- // Extract sessionId if present
550
- if (typeof data === 'string') {
551
- try {
552
- const parsed = JSON.parse(data);
553
- if (parsed.sessionId) {
554
- this.sessionId = parsed.sessionId;
555
- }
556
- } catch (e) {
557
- // Not JSON, ignore
558
- }
559
- } else if (data && data.sessionId) {
560
- this.sessionId = data.sessionId;
561
- }
562
- }
563
-
564
- end() {
565
- // Do nothing - we'll collect all messages
566
- }
567
-
568
- setSessionId(sessionId) {
569
- this.sessionId = sessionId;
570
- }
571
-
572
- getSessionId() {
573
- return this.sessionId;
574
- }
575
-
576
- getMessages() {
577
- return this.messages;
578
- }
579
-
580
- /**
581
- * Get filtered assistant messages only
582
- */
583
- getAssistantMessages() {
584
- const assistantMessages = [];
585
-
586
- for (const msg of this.messages) {
587
- // Skip initial status message
588
- if (msg && msg.type === 'status') {
589
- continue;
590
- }
591
-
592
- // Handle JSON strings
593
- if (typeof msg === 'string') {
594
- try {
595
- const parsed = JSON.parse(msg);
596
- // Only include claude-response messages with assistant type
597
- if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
598
- assistantMessages.push(parsed.data);
599
- }
600
- } catch (e) {
601
- // Not JSON, skip
602
- }
603
- }
604
- }
605
-
606
- return assistantMessages;
607
- }
608
-
609
- /**
610
- * Calculate total tokens from all messages
611
- */
612
- getTotalTokens() {
613
- let totalInput = 0;
614
- let totalOutput = 0;
615
- let totalCacheRead = 0;
616
- let totalCacheCreation = 0;
617
-
618
- for (const msg of this.messages) {
619
- let data = msg;
620
-
621
- // Parse if string
622
- if (typeof msg === 'string') {
623
- try {
624
- data = JSON.parse(msg);
625
- } catch (e) {
626
- continue;
627
- }
628
- }
629
-
630
- // Extract usage from claude-response messages
631
- if (data && data.type === 'claude-response' && data.data) {
632
- const msgData = data.data;
633
- if (msgData.message && msgData.message.usage) {
634
- const usage = msgData.message.usage;
635
- totalInput += usage.input_tokens || 0;
636
- totalOutput += usage.output_tokens || 0;
637
- totalCacheRead += usage.cache_read_input_tokens || 0;
638
- totalCacheCreation += usage.cache_creation_input_tokens || 0;
639
- }
640
- }
641
- }
642
-
643
- return {
644
- inputTokens: totalInput,
645
- outputTokens: totalOutput,
646
- cacheReadTokens: totalCacheRead,
647
- cacheCreationTokens: totalCacheCreation,
648
- totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
649
- };
650
- }
651
- }
652
-
653
- class AgentSessionAbortedError extends Error {
654
- constructor(message = 'Session aborted') {
655
- super(message);
656
- this.name = 'AgentSessionAbortedError';
657
- }
658
- }
659
-
660
- function extractTerminalErrorMessage(payload) {
661
- if (!payload || typeof payload !== 'object') {
662
- return null;
663
- }
664
-
665
- if (typeof payload.error === 'string' && payload.error.trim()) {
666
- return payload.error.trim();
667
- }
668
-
669
- if (payload.error && typeof payload.error.message === 'string' && payload.error.message.trim()) {
670
- return payload.error.message.trim();
671
- }
672
-
673
- const nestedData = payload.data;
674
- if (nestedData && typeof nestedData === 'object') {
675
- if (typeof nestedData.error === 'string' && nestedData.error.trim()) {
676
- return nestedData.error.trim();
677
- }
678
-
679
- if (nestedData.error && typeof nestedData.error.message === 'string' && nestedData.error.message.trim()) {
680
- return nestedData.error.message.trim();
681
- }
682
-
683
- if (typeof nestedData.message === 'string' && nestedData.message.trim()) {
684
- return nestedData.message.trim();
685
- }
686
- }
687
-
688
- return null;
689
- }
690
-
691
- class CallbackCaptureWriter {
692
- constructor() {
693
- this.sessionId = null;
694
- this.assistantMessages = [];
695
- this.tokenSummary = createEmptyTokenSummary();
696
- this.terminalState = null;
697
- this.terminalErrorMessage = null;
698
- }
699
-
700
- send(payload) {
701
- if (!payload || typeof payload !== 'object') {
702
- return;
703
- }
704
-
705
- if (typeof payload.sessionId === 'string' && payload.sessionId.trim()) {
706
- this.sessionId = payload.sessionId.trim();
707
- }
708
-
709
- if (payload.type === 'session-created' && typeof payload.sessionId === 'string' && payload.sessionId.trim()) {
710
- this.sessionId = payload.sessionId.trim();
711
- }
712
-
713
- if (payload.type === 'token-budget' && payload.data && typeof payload.data === 'object') {
714
- this.tokenSummary = {
715
- inputTokens: Number(payload.data.inputTokens) || 0,
716
- outputTokens: Number(payload.data.outputTokens) || 0,
717
- cacheReadTokens: Number(payload.data.cacheReadTokens) || 0,
718
- cacheCreationTokens: Number(payload.data.cacheCreationTokens) || 0,
719
- totalTokens: Number(payload.data.totalTokens) || 0
720
- };
721
- }
722
-
723
- if (payload.type === 'claude-response' && payload.data?.type === 'assistant') {
724
- this.assistantMessages.push(payload.data);
725
- }
726
-
727
- if (
728
- payload.type === 'codex-response' &&
729
- payload.data?.type === 'item_done' &&
730
- payload.data?.itemType === 'agent_message' &&
731
- typeof payload.data?.content === 'string' &&
732
- payload.data.content.trim()
733
- ) {
734
- this.assistantMessages.push({
735
- type: 'assistant',
736
- message: {
737
- role: 'assistant',
738
- content: payload.data.content
739
- }
740
- });
741
- }
742
-
743
- if (payload.type === 'session-aborted') {
744
- this.terminalState = 'aborted';
745
- this.terminalErrorMessage = 'Session aborted';
746
- return;
747
- }
748
-
749
- if (payload.type === 'claude-complete' || payload.type === 'codex-complete') {
750
- this.terminalState = 'completed';
751
- this.terminalErrorMessage = null;
752
- return;
753
- }
754
-
755
- if (
756
- payload.type === 'claude-error' ||
757
- payload.type === 'codex-error' ||
758
- payload.type === 'error' ||
759
- (payload.type === 'codex-response' && payload.data?.type === 'turn_failed')
760
- ) {
761
- this.terminalState = 'errored';
762
- this.terminalErrorMessage = extractTerminalErrorMessage(payload) || this.terminalErrorMessage || 'Agent session failed';
763
- }
764
- }
765
-
766
- end() {}
767
-
768
- setSessionId(sessionId) {
769
- if (typeof sessionId === 'string' && sessionId.trim()) {
770
- this.sessionId = sessionId.trim();
771
- }
772
- }
773
-
774
- getSessionId() {
775
- return this.sessionId;
776
- }
777
-
778
- getAssistantMessages() {
779
- return this.assistantMessages;
780
- }
781
-
782
- getTotalTokens() {
783
- return this.tokenSummary;
784
- }
785
-
786
- getTerminalState() {
787
- return this.terminalState;
788
- }
789
-
790
- getTerminalErrorMessage() {
791
- return this.terminalErrorMessage;
792
- }
793
- }
794
-
795
- class TeeWriter {
796
- constructor(primaryWriter, secondaryWriter) {
797
- this.primaryWriter = primaryWriter;
798
- this.secondaryWriter = secondaryWriter;
799
- this.isSSEStreamWriter = !!primaryWriter?.isSSEStreamWriter;
800
- this.isWebSocketWriter = !!primaryWriter?.isWebSocketWriter;
801
- }
802
-
803
- send(payload) {
804
- this.primaryWriter?.send?.(payload);
805
- this.secondaryWriter?.send?.(payload);
806
- }
807
-
808
- end() {
809
- this.primaryWriter?.end?.();
810
- this.secondaryWriter?.end?.();
811
- }
812
-
813
- setSessionId(sessionId) {
814
- this.primaryWriter?.setSessionId?.(sessionId);
815
- this.secondaryWriter?.setSessionId?.(sessionId);
816
- }
817
-
818
- getSessionId() {
819
- return this.primaryWriter?.getSessionId?.() || this.secondaryWriter?.getSessionId?.() || null;
820
- }
821
- }
822
-
823
- class NoopWriter {
824
- send() {}
825
-
826
- end() {}
827
- }
828
-
829
- // ===============================
830
- // External API Endpoint
831
- // ===============================
832
-
833
- /**
834
- * POST /api/agent
835
- *
836
- * Trigger an AI agent (Claude, Codex, Gemini, or OpenCode) to work on a project.
837
- * Supports automatic GitHub branch and pull request creation after successful completion.
838
- *
839
- * ================================================================================================
840
- * REQUEST BODY PARAMETERS
841
- * ================================================================================================
842
- *
843
- * @param {string} githubUrl - (Optional) GitHub repository URL to clone.
844
- * Supported formats:
845
- * - HTTPS: https://github.com/owner/repo
846
- * - HTTPS with .git: https://github.com/owner/repo.git
847
- * - SSH: git@github.com:owner/repo
848
- * - SSH with .git: git@github.com:owner/repo.git
849
- *
850
- * @param {string} projectPath - (Optional) Path to existing project OR destination for cloning.
851
- * Behavior depends on usage:
852
- * - If used alone: Must point to existing project directory
853
- * - If used with githubUrl: Target location for cloning
854
- * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
855
- * - If omitted without githubUrl: Falls back to service default working directory, then process cwd
856
- *
857
- * @param {string} message - (Required) Task description for the AI agent. Used as:
858
- * - Instructions for the agent
859
- * - Source for auto-generated branch names (if createBranch=true and no branchName)
860
- * - Fallback for PR title if no commits are made
861
- *
862
- * @param {string} sessionId - (Optional) Existing session ID to resume.
863
- * If provided, the request continues that session instead of creating a new one.
864
- *
865
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'codex' | 'gemini' | 'opencode'
866
- * Default: 'claude'
867
- *
868
- * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
869
- * Default: true
870
- * - true: Returns text/event-stream with incremental updates
871
- * - false: Returns complete JSON response after completion
872
- *
873
- * @param {string} model - (Optional) Model identifier for providers.
874
- *
875
- * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
876
- * * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
877
- * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
878
- * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
879
- * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
880
- * Gemini models: 'gemini-2.5-pro' (default), 'gemini-2.5-flash', 'gemini-2.5-flash-lite'
881
- *
882
- * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
883
- * Default: true
884
- * Behavior:
885
- * - Only applies when cloning via githubUrl (not for existing projectPath)
886
- * - Deletes cloned repository after 5 seconds
887
- * - Also deletes associated Claude session directory
888
- * - Remote branch and PR remain on GitHub if created
889
- *
890
- * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
891
- * Overrides stored token from user settings.
892
- * Required for:
893
- * - Private repositories
894
- * - Branch/PR creation features
895
- * Token must have 'repo' scope for full functionality.
896
- *
897
- * @param {string} branchName - (Optional) Custom name for the Git branch.
898
- * If provided, createBranch is automatically set to true.
899
- * Validation rules (errors returned if violated):
900
- * - Cannot be empty or whitespace only
901
- * - Cannot start or end with dot (.)
902
- * - Cannot contain consecutive dots (..)
903
- * - Cannot contain spaces
904
- * - Cannot contain special characters: ~ ^ : ? * [ \
905
- * - Cannot contain @{
906
- * - Cannot start or end with forward slash (/)
907
- * - Cannot contain consecutive slashes (//)
908
- * - Cannot end with .lock
909
- * - Cannot contain ASCII control characters
910
- * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
911
- *
912
- * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
913
- * Default: false (or true if branchName is provided)
914
- * Behavior:
915
- * - Creates branch locally and pushes to remote
916
- * - If branch exists locally: Checks out existing branch (no error)
917
- * - If branch exists on remote: Uses existing branch (no error)
918
- * - Branch name: Custom (if branchName provided) or auto-generated from message
919
- * - Requires either githubUrl OR projectPath with GitHub remote
920
- *
921
- * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
922
- * Default: false
923
- * Behavior:
924
- * - PR title: First commit message (or fallback to message parameter)
925
- * - PR description: Auto-generated from all commit messages
926
- * - Base branch: Always 'main' (currently hardcoded)
927
- * - If PR already exists: GitHub returns error with details
928
- * - Requires either githubUrl OR projectPath with GitHub remote
929
- *
930
- * @param {object} callback - (Optional) Terminal webhook configuration for external systems.
931
- * Behavior:
932
- * - Sent once when the run ends as completed, errored, or aborted
933
- * - Delivery is asynchronous and never blocks the main API response
934
- * - Not supported with openOnly=true
935
- * Fields:
936
- * - url: Required HTTP/HTTPS target URL
937
- * - events: Optional subset of ["completed", "errored", "aborted"]
938
- * - secret: Optional shared secret for HMAC signing
939
- *
940
- * ================================================================================================
941
- * PATH HANDLING BEHAVIOR
942
- * ================================================================================================
943
- *
944
- * Scenario 1: Only githubUrl provided
945
- * Input: { githubUrl: "https://github.com/owner/repo" }
946
- * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
947
- * Cleanup: Yes (if cleanup=true)
948
- *
949
- * Scenario 2: Only projectPath provided
950
- * Input: { projectPath: "/home/user/my-project" }
951
- * Action: Uses existing project at specified path
952
- * Validation: Path must exist and be accessible
953
- * Cleanup: No (never cleanup existing projects)
954
- *
955
- * Scenario 3: Both githubUrl and projectPath provided
956
- * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
957
- * Action: Clones githubUrl to projectPath location
958
- * Validation:
959
- * - If projectPath exists with git repo:
960
- * - Compares remote URL with githubUrl
961
- * - If URLs match: Reuses existing repo
962
- * - If URLs differ: Returns error
963
- * Cleanup: Yes (if cleanup=true)
964
- *
965
- * ================================================================================================
966
- * GITHUB BRANCH/PR CREATION REQUIREMENTS
967
- * ================================================================================================
968
- *
969
- * For createBranch or createPR to work, one of the following must be true:
970
- *
971
- * Option A: githubUrl provided
972
- * - Repository URL directly specified
973
- * - Works with both cloning and existing paths
974
- *
975
- * Option B: projectPath with GitHub remote
976
- * - Project must be a Git repository
977
- * - Must have 'origin' remote configured
978
- * - Remote URL must point to github.com
979
- * - System auto-detects GitHub URL via: git remote get-url origin
980
- *
981
- * Additional Requirements:
982
- * - Valid GitHub token (from settings or githubToken parameter)
983
- * - Token must have 'repo' scope for private repos
984
- * - Project must have commits (for PR creation)
985
- *
986
- * ================================================================================================
987
- * VALIDATION & ERROR HANDLING
988
- * ================================================================================================
989
- *
990
- * Input Validations (400 Bad Request):
991
- * - message must be non-empty string
992
- * - provider must be 'claude', 'codex', 'gemini', or 'opencode'
993
- * - createBranch/createPR requires a resolvable working directory or githubUrl
994
- * - branchName must pass Git naming rules (if provided)
995
- * - callback must be an object when provided
996
- * - callback.url must be a valid HTTP/HTTPS URL
997
- * - callback.events can only include completed/errored/aborted
998
- * - callback is not supported when openOnly=true
999
- *
1000
- * Runtime Validations (500 Internal Server Error or specific error in response):
1001
- * - projectPath must exist (if used alone)
1002
- * - GitHub URL format must be valid
1003
- * - Git remote URL must include github.com (for projectPath + branch/PR)
1004
- * - GitHub token must be available (for private repos and branch/PR)
1005
- * - Directory conflicts handled (existing path with different repo)
1006
- *
1007
- * Branch Name Validation Errors (returned in response, not HTTP error):
1008
- * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
1009
- * Examples:
1010
- * - "my branch" → "Branch name cannot contain spaces"
1011
- * - ".feature" → "Branch name cannot start with a dot"
1012
- * - "feature.lock" → "Branch name cannot end with .lock"
1013
- *
1014
- * ================================================================================================
1015
- * RESPONSE FORMATS
1016
- * ================================================================================================
1017
- *
1018
- * Streaming Response (stream=true):
1019
- * Content-Type: text/event-stream
1020
- * Events:
1021
- * - { type: "status", message: "...", projectPath: "..." }
1022
- * - { type: "claude-response", data: {...} }
1023
- * - { type: "github-branch", branch: { name: "...", url: "..." } }
1024
- * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
1025
- * - { type: "github-error", error: "..." }
1026
- * - { type: "done" }
1027
- *
1028
- * Non-Streaming Response (stream=false):
1029
- * Content-Type: application/json
1030
- * {
1031
- * success: true,
1032
- * sessionId: "session-123",
1033
- * messages: [...], // Assistant messages only (filtered)
1034
- * tokens: {
1035
- * inputTokens: 150,
1036
- * outputTokens: 50,
1037
- * cacheReadTokens: 0,
1038
- * cacheCreationTokens: 0,
1039
- * totalTokens: 200
1040
- * },
1041
- * projectPath: "/path/to/project",
1042
- * branch: { // Only if createBranch=true
1043
- * name: "feature/xyz",
1044
- * url: "https://github.com/owner/repo/tree/feature/xyz"
1045
- * } | { error: "..." },
1046
- * pullRequest: { // Only if createPR=true
1047
- * number: 42,
1048
- * url: "https://github.com/owner/repo/pull/42"
1049
- * } | { error: "..." }
1050
- * }
1051
- *
1052
- * Error Response:
1053
- * HTTP Status: 400, 401, 500
1054
- * Content-Type: application/json
1055
- * { success: false, error: "Error description" }
1056
- *
1057
- * Callback Delivery:
1058
- * Content-Type: application/json
1059
- * Headers:
1060
- * - X-Agent-Event
1061
- * - X-Agent-Event-Id
1062
- * - X-Agent-Delivery-Attempt
1063
- * - X-Agent-Signature (only when callback.secret is provided)
1064
- *
1065
- * ================================================================================================
1066
- * EXAMPLES
1067
- * ================================================================================================
1068
- *
1069
- * Example 1: Clone and process with auto-cleanup
1070
- * POST /api/agent
1071
- * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
1072
- *
1073
- * Example 2: Use existing project with custom branch and PR
1074
- * POST /api/agent
1075
- * {
1076
- * "projectPath": "/home/user/project",
1077
- * "message": "Add feature",
1078
- * "branchName": "feature/new-feature",
1079
- * "createPR": true
1080
- * }
1081
- *
1082
- * Example 3: Clone to specific path with auto-generated branch
1083
- * POST /api/agent
1084
- * {
1085
- * "githubUrl": "https://github.com/user/repo",
1086
- * "projectPath": "/tmp/work",
1087
- * "message": "Refactor code",
1088
- * "createBranch": true,
1089
- * "cleanup": false
1090
- * }
1091
- */
1092
- router.post('/', validateExternalAgentApiKey, async (req, res) => {
17
+ router.post('/', validateExternalApiKey, async (req, res) => {
1093
18
  let normalized = null;
1094
19
  try {
1095
20
  normalized = normalizeExternalAgentRunRequest(req.body);
@@ -1100,16 +25,15 @@ router.post('/', validateExternalAgentApiKey, async (req, res) => {
1100
25
  }
1101
26
 
1102
27
  try {
1103
- let transportWriter = null;
28
+ const transportWriter = normalized.stream
29
+ ? new SSEStreamWriter(res)
30
+ : new ResponseCollector();
1104
31
 
1105
32
  if (normalized.stream) {
1106
33
  res.setHeader('Content-Type', 'text/event-stream');
1107
34
  res.setHeader('Cache-Control', 'no-cache');
1108
35
  res.setHeader('Connection', 'keep-alive');
1109
36
  res.setHeader('X-Accel-Buffering', 'no');
1110
- transportWriter = new ExternalSSEStreamWriter(res);
1111
- } else {
1112
- transportWriter = new ExternalResponseCollector();
1113
37
  }
1114
38
 
1115
39
  const response = await runExternalAgentRequest({
@@ -1123,7 +47,7 @@ router.post('/', validateExternalAgentApiKey, async (req, res) => {
1123
47
  return res.json(response);
1124
48
  }
1125
49
  } catch (error) {
1126
- console.error('External session error:', error);
50
+ console.error('External session error:', error);
1127
51
 
1128
52
  if (normalized?.stream) {
1129
53
  if (!res.headersSent) {
@@ -1133,7 +57,7 @@ router.post('/', validateExternalAgentApiKey, async (req, res) => {
1133
57
  res.setHeader('X-Accel-Buffering', 'no');
1134
58
  }
1135
59
 
1136
- const writer = new ExternalSSEStreamWriter(res);
60
+ const writer = new SSEStreamWriter(res);
1137
61
  writer.send({
1138
62
  type: 'error',
1139
63
  error: error.message,
@@ -1150,52 +74,7 @@ router.post('/', validateExternalAgentApiKey, async (req, res) => {
1150
74
  }
1151
75
  });
1152
76
 
1153
- /**
1154
- * POST /api/agent/abort
1155
- *
1156
- * Interrupt an active AI agent session.
1157
- *
1158
- * ================================================================================================
1159
- * REQUEST BODY PARAMETERS
1160
- * ================================================================================================
1161
- *
1162
- * @param {string} sessionId - (Required) Session ID to interrupt.
1163
- * @param {string} provider - (Optional) AI provider for the active session.
1164
- * Options: 'claude' | 'codex' | 'gemini' | 'opencode'
1165
- * Default: 'claude'
1166
- *
1167
- * ================================================================================================
1168
- * VALIDATION & ERROR HANDLING
1169
- * ================================================================================================
1170
- *
1171
- * Input Validations (400 Bad Request):
1172
- * - sessionId must be a non-empty string
1173
- * - provider must be 'claude', 'codex', 'gemini', or 'opencode' when provided
1174
- *
1175
- * Success Response (200 OK):
1176
- * {
1177
- * success: true,
1178
- * aborted: true,
1179
- * sessionId: "session-123",
1180
- * provider: "claude",
1181
- * message: "Session aborted"
1182
- * }
1183
- *
1184
- * Not Found Response (200 OK):
1185
- * {
1186
- * success: false,
1187
- * aborted: false,
1188
- * sessionId: "session-123",
1189
- * provider: "claude",
1190
- * error: "Active session not found"
1191
- * }
1192
- *
1193
- * Error Response:
1194
- * HTTP Status: 400, 401, 500
1195
- * Content-Type: application/json
1196
- * { success: false, error: "Error description" }
1197
- */
1198
- router.post('/abort', validateExternalAgentApiKey, async (req, res) => {
77
+ router.post('/abort', validateExternalApiKey, async (req, res) => {
1199
78
  let normalized = null;
1200
79
  try {
1201
80
  normalized = normalizeExternalAgentAbortRequest(req.body);
@@ -1207,7 +86,7 @@ router.post('/abort', validateExternalAgentApiKey, async (req, res) => {
1207
86
  }
1208
87
 
1209
88
  try {
1210
- const writer = new SessionEventMirrorWriter(new ExternalNoopWriter(), normalized.provider);
89
+ const writer = new SessionEventMirrorWriter(new NoopWriter(), normalized.provider);
1211
90
  const { success } = await abortAgentSessionWithWriter({
1212
91
  provider: normalized.provider,
1213
92
  sessionId: normalized.sessionId,
@@ -1232,7 +111,7 @@ router.post('/abort', validateExternalAgentApiKey, async (req, res) => {
1232
111
  message: 'Session aborted'
1233
112
  });
1234
113
  } catch (error) {
1235
- console.error('External abort session error:', error);
114
+ console.error('External abort session error:', error);
1236
115
  return res.status(500).json({
1237
116
  success: false,
1238
117
  error: error.message