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