claudedesk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +431 -0
  3. package/config/repos.example.json +128 -0
  4. package/config/settings.example.json +64 -0
  5. package/config/skills/code-review.md +76 -0
  6. package/config/skills/full-check.md +26 -0
  7. package/config/skills/lint-fix.md +23 -0
  8. package/dist/api/agent-routes.d.ts +2 -0
  9. package/dist/api/agent-routes.d.ts.map +1 -0
  10. package/dist/api/agent-routes.js +251 -0
  11. package/dist/api/agent-routes.js.map +1 -0
  12. package/dist/api/app-routes.d.ts +2 -0
  13. package/dist/api/app-routes.d.ts.map +1 -0
  14. package/dist/api/app-routes.js +150 -0
  15. package/dist/api/app-routes.js.map +1 -0
  16. package/dist/api/docker-routes.d.ts +2 -0
  17. package/dist/api/docker-routes.d.ts.map +1 -0
  18. package/dist/api/docker-routes.js +167 -0
  19. package/dist/api/docker-routes.js.map +1 -0
  20. package/dist/api/middleware.d.ts +6 -0
  21. package/dist/api/middleware.d.ts.map +1 -0
  22. package/dist/api/middleware.js +293 -0
  23. package/dist/api/middleware.js.map +1 -0
  24. package/dist/api/pin-auth.d.ts +65 -0
  25. package/dist/api/pin-auth.d.ts.map +1 -0
  26. package/dist/api/pin-auth.js +218 -0
  27. package/dist/api/pin-auth.js.map +1 -0
  28. package/dist/api/routes.d.ts +2 -0
  29. package/dist/api/routes.d.ts.map +1 -0
  30. package/dist/api/routes.js +473 -0
  31. package/dist/api/routes.js.map +1 -0
  32. package/dist/api/settings-routes.d.ts +2 -0
  33. package/dist/api/settings-routes.d.ts.map +1 -0
  34. package/dist/api/settings-routes.js +570 -0
  35. package/dist/api/settings-routes.js.map +1 -0
  36. package/dist/api/skill-routes.d.ts +2 -0
  37. package/dist/api/skill-routes.d.ts.map +1 -0
  38. package/dist/api/skill-routes.js +88 -0
  39. package/dist/api/skill-routes.js.map +1 -0
  40. package/dist/api/terminal-routes.d.ts +2 -0
  41. package/dist/api/terminal-routes.d.ts.map +1 -0
  42. package/dist/api/terminal-routes.js +3524 -0
  43. package/dist/api/terminal-routes.js.map +1 -0
  44. package/dist/api/tunnel-routes.d.ts +2 -0
  45. package/dist/api/tunnel-routes.d.ts.map +1 -0
  46. package/dist/api/tunnel-routes.js +196 -0
  47. package/dist/api/tunnel-routes.js.map +1 -0
  48. package/dist/api/workspace-routes.d.ts +3 -0
  49. package/dist/api/workspace-routes.d.ts.map +1 -0
  50. package/dist/api/workspace-routes.js +649 -0
  51. package/dist/api/workspace-routes.js.map +1 -0
  52. package/dist/cli.d.ts +3 -0
  53. package/dist/cli.d.ts.map +1 -0
  54. package/dist/cli.js +276 -0
  55. package/dist/cli.js.map +1 -0
  56. package/dist/client/assets/index-B4r0njGe.js +780 -0
  57. package/dist/client/assets/index-CY_9MyE0.css +1 -0
  58. package/dist/client/favicon.svg +5 -0
  59. package/dist/client/icons/icon-192.svg +5 -0
  60. package/dist/client/icons/icon-512.svg +5 -0
  61. package/dist/client/icons/logo-with-message.png +0 -0
  62. package/dist/client/icons/logo.png +0 -0
  63. package/dist/client/index.html +25 -0
  64. package/dist/client/manifest.json +62 -0
  65. package/dist/client/sw.js +243 -0
  66. package/dist/config/agent-usage.d.ts +34 -0
  67. package/dist/config/agent-usage.d.ts.map +1 -0
  68. package/dist/config/agent-usage.js +87 -0
  69. package/dist/config/agent-usage.js.map +1 -0
  70. package/dist/config/repos.d.ts +34 -0
  71. package/dist/config/repos.d.ts.map +1 -0
  72. package/dist/config/repos.js +412 -0
  73. package/dist/config/repos.js.map +1 -0
  74. package/dist/config/settings.d.ts +634 -0
  75. package/dist/config/settings.d.ts.map +1 -0
  76. package/dist/config/settings.js +459 -0
  77. package/dist/config/settings.js.map +1 -0
  78. package/dist/config/skills.d.ts +18 -0
  79. package/dist/config/skills.d.ts.map +1 -0
  80. package/dist/config/skills.js +174 -0
  81. package/dist/config/skills.js.map +1 -0
  82. package/dist/config/workspaces.d.ts +961 -0
  83. package/dist/config/workspaces.d.ts.map +1 -0
  84. package/dist/config/workspaces.js +482 -0
  85. package/dist/config/workspaces.js.map +1 -0
  86. package/dist/core/app-manager.d.ts +85 -0
  87. package/dist/core/app-manager.d.ts.map +1 -0
  88. package/dist/core/app-manager.js +447 -0
  89. package/dist/core/app-manager.js.map +1 -0
  90. package/dist/core/claude-invoker.d.ts +49 -0
  91. package/dist/core/claude-invoker.d.ts.map +1 -0
  92. package/dist/core/claude-invoker.js +583 -0
  93. package/dist/core/claude-invoker.js.map +1 -0
  94. package/dist/core/claude-session-reader.d.ts +25 -0
  95. package/dist/core/claude-session-reader.d.ts.map +1 -0
  96. package/dist/core/claude-session-reader.js +184 -0
  97. package/dist/core/claude-session-reader.js.map +1 -0
  98. package/dist/core/claude-usage-query.d.ts +78 -0
  99. package/dist/core/claude-usage-query.d.ts.map +1 -0
  100. package/dist/core/claude-usage-query.js +294 -0
  101. package/dist/core/claude-usage-query.js.map +1 -0
  102. package/dist/core/git-credential-helper.d.ts +57 -0
  103. package/dist/core/git-credential-helper.d.ts.map +1 -0
  104. package/dist/core/git-credential-helper.js +176 -0
  105. package/dist/core/git-credential-helper.js.map +1 -0
  106. package/dist/core/git-sandbox.d.ts +135 -0
  107. package/dist/core/git-sandbox.d.ts.map +1 -0
  108. package/dist/core/git-sandbox.js +907 -0
  109. package/dist/core/git-sandbox.js.map +1 -0
  110. package/dist/core/github-integration.d.ts +66 -0
  111. package/dist/core/github-integration.d.ts.map +1 -0
  112. package/dist/core/github-integration.js +350 -0
  113. package/dist/core/github-integration.js.map +1 -0
  114. package/dist/core/github-oauth.d.ts +88 -0
  115. package/dist/core/github-oauth.d.ts.map +1 -0
  116. package/dist/core/github-oauth.js +244 -0
  117. package/dist/core/github-oauth.js.map +1 -0
  118. package/dist/core/gitlab-integration.d.ts +66 -0
  119. package/dist/core/gitlab-integration.d.ts.map +1 -0
  120. package/dist/core/gitlab-integration.js +353 -0
  121. package/dist/core/gitlab-integration.js.map +1 -0
  122. package/dist/core/gitlab-oauth.d.ts +100 -0
  123. package/dist/core/gitlab-oauth.d.ts.map +1 -0
  124. package/dist/core/gitlab-oauth.js +366 -0
  125. package/dist/core/gitlab-oauth.js.map +1 -0
  126. package/dist/core/insights-extractor.d.ts +68 -0
  127. package/dist/core/insights-extractor.d.ts.map +1 -0
  128. package/dist/core/insights-extractor.js +402 -0
  129. package/dist/core/insights-extractor.js.map +1 -0
  130. package/dist/core/logger.d.ts +27 -0
  131. package/dist/core/logger.d.ts.map +1 -0
  132. package/dist/core/logger.js +70 -0
  133. package/dist/core/logger.js.map +1 -0
  134. package/dist/core/process-runner.d.ts +27 -0
  135. package/dist/core/process-runner.d.ts.map +1 -0
  136. package/dist/core/process-runner.js +147 -0
  137. package/dist/core/process-runner.js.map +1 -0
  138. package/dist/core/project-detector.d.ts +30 -0
  139. package/dist/core/project-detector.d.ts.map +1 -0
  140. package/dist/core/project-detector.js +482 -0
  141. package/dist/core/project-detector.js.map +1 -0
  142. package/dist/core/qr-generator.d.ts +18 -0
  143. package/dist/core/qr-generator.d.ts.map +1 -0
  144. package/dist/core/qr-generator.js +61 -0
  145. package/dist/core/qr-generator.js.map +1 -0
  146. package/dist/core/remote-tunnel-manager.d.ts +59 -0
  147. package/dist/core/remote-tunnel-manager.d.ts.map +1 -0
  148. package/dist/core/remote-tunnel-manager.js +235 -0
  149. package/dist/core/remote-tunnel-manager.js.map +1 -0
  150. package/dist/core/shared-docker-manager.d.ts +41 -0
  151. package/dist/core/shared-docker-manager.d.ts.map +1 -0
  152. package/dist/core/shared-docker-manager.js +409 -0
  153. package/dist/core/shared-docker-manager.js.map +1 -0
  154. package/dist/core/skill-executor.d.ts +25 -0
  155. package/dist/core/skill-executor.d.ts.map +1 -0
  156. package/dist/core/skill-executor.js +171 -0
  157. package/dist/core/skill-executor.js.map +1 -0
  158. package/dist/core/terminal-session.d.ts +149 -0
  159. package/dist/core/terminal-session.d.ts.map +1 -0
  160. package/dist/core/terminal-session.js +2340 -0
  161. package/dist/core/terminal-session.js.map +1 -0
  162. package/dist/core/tunnel-manager.d.ts +35 -0
  163. package/dist/core/tunnel-manager.d.ts.map +1 -0
  164. package/dist/core/tunnel-manager.js +137 -0
  165. package/dist/core/tunnel-manager.js.map +1 -0
  166. package/dist/core/usage-manager.d.ts +57 -0
  167. package/dist/core/usage-manager.d.ts.map +1 -0
  168. package/dist/core/usage-manager.js +363 -0
  169. package/dist/core/usage-manager.js.map +1 -0
  170. package/dist/core/ws-manager.d.ts +39 -0
  171. package/dist/core/ws-manager.d.ts.map +1 -0
  172. package/dist/core/ws-manager.js +190 -0
  173. package/dist/core/ws-manager.js.map +1 -0
  174. package/dist/index.d.ts +7 -0
  175. package/dist/index.d.ts.map +1 -0
  176. package/dist/index.js +229 -0
  177. package/dist/index.js.map +1 -0
  178. package/dist/types.d.ts +868 -0
  179. package/dist/types.d.ts.map +1 -0
  180. package/dist/types.js +119 -0
  181. package/dist/types.js.map +1 -0
  182. package/package.json +96 -0
@@ -0,0 +1,3524 @@
1
+ import { Router } from 'express';
2
+ import { execSync } from 'child_process';
3
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync, mkdirSync, renameSync, readdirSync, statSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { join, extname } from 'path';
6
+ import { randomUUID } from 'crypto';
7
+ import multer from 'multer';
8
+ import { terminalSessionManager } from '../core/terminal-session.js';
9
+ import { repoRegistry } from '../config/repos.js';
10
+ import { gitSandbox } from '../core/git-sandbox.js';
11
+ import { workspaceManager } from '../config/workspaces.js';
12
+ import { getGitCredentialEnv, cleanupGitCredentialEnv } from '../core/git-credential-helper.js';
13
+ import { githubIntegration } from '../core/github-integration.js';
14
+ import { gitlabIntegration } from '../core/gitlab-integration.js';
15
+ import { usageManager } from '../core/usage-manager.js';
16
+ import { settingsManager } from '../config/settings.js';
17
+ import { queryClaudeQuota, clearQuotaCache } from '../core/claude-usage-query.js';
18
+ /**
19
+ * Try to refresh a GitLab token using the refresh token.
20
+ * Returns the new access token if successful, null otherwise.
21
+ * Also updates the workspace with the new token.
22
+ */
23
+ function tryRefreshGitLabToken(workspaceId) {
24
+ const tokenData = workspaceManager.getGitLabToken(workspaceId);
25
+ if (!tokenData?.refreshToken) {
26
+ console.log(`[GitLab] No refresh token available for workspace ${workspaceId}`);
27
+ return null;
28
+ }
29
+ const settings = settingsManager.get();
30
+ const clientId = settings.gitlab?.clientId;
31
+ if (!clientId) {
32
+ console.log(`[GitLab] No GitLab clientId configured in settings`);
33
+ return null;
34
+ }
35
+ console.log(`[GitLab] Attempting to refresh token for workspace ${workspaceId}`);
36
+ const tempScriptPath = join(tmpdir(), `gitlab-refresh-${randomUUID()}.mjs`);
37
+ const tempResultPath = join(tmpdir(), `gitlab-refresh-result-${randomUUID()}.json`);
38
+ const scriptContent = `
39
+ import { writeFileSync } from 'fs';
40
+ try {
41
+ const response = await fetch('https://gitlab.com/oauth/token', {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Accept': 'application/json',
45
+ 'Content-Type': 'application/x-www-form-urlencoded',
46
+ },
47
+ body: new URLSearchParams({
48
+ client_id: ${JSON.stringify(clientId)},
49
+ refresh_token: ${JSON.stringify(tokenData.refreshToken)},
50
+ grant_type: 'refresh_token',
51
+ }).toString(),
52
+ });
53
+
54
+ const text = await response.text();
55
+
56
+ if (!response.ok) {
57
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: text }));
58
+ } else {
59
+ const data = JSON.parse(text);
60
+ if (data.access_token) {
61
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
62
+ success: true,
63
+ accessToken: data.access_token,
64
+ refreshToken: data.refresh_token,
65
+ expiresIn: data.expires_in
66
+ }));
67
+ } else {
68
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: 'No access_token in response' }));
69
+ }
70
+ }
71
+ } catch (e) {
72
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: e.message }));
73
+ }
74
+ `;
75
+ try {
76
+ writeFileSync(tempScriptPath, scriptContent);
77
+ execSync(`node "${tempScriptPath}"`, { encoding: 'utf-8', timeout: 15000 });
78
+ const resultJson = readFileSync(tempResultPath, 'utf-8');
79
+ const result = JSON.parse(resultJson);
80
+ if (result.success) {
81
+ // Get the current workspace to preserve other fields
82
+ const workspace = workspaceManager.get(workspaceId);
83
+ if (workspace?.gitlab) {
84
+ // Calculate expiry time
85
+ const expiresAt = result.expiresIn
86
+ ? new Date(Date.now() + result.expiresIn * 1000).toISOString()
87
+ : null;
88
+ // Update the token in workspace
89
+ workspaceManager.setGitLabToken(workspaceId, result.accessToken, workspace.gitlab.username || 'unknown', workspace.gitlab.tokenScope || 'api', result.refreshToken, expiresAt);
90
+ console.log(`[GitLab] Token refreshed successfully for workspace ${workspaceId}`);
91
+ return result.accessToken;
92
+ }
93
+ }
94
+ console.log(`[GitLab] Token refresh failed: ${result.error}`);
95
+ return null;
96
+ }
97
+ catch (error) {
98
+ console.error(`[GitLab] Token refresh error:`, error);
99
+ return null;
100
+ }
101
+ finally {
102
+ try {
103
+ unlinkSync(tempScriptPath);
104
+ }
105
+ catch { }
106
+ try {
107
+ unlinkSync(tempResultPath);
108
+ }
109
+ catch { }
110
+ }
111
+ }
112
+ // Configure multer for terminal attachments
113
+ const ATTACHMENTS_DIR = join(process.cwd(), 'temp', 'terminal-attachments');
114
+ if (!existsSync(ATTACHMENTS_DIR)) {
115
+ mkdirSync(ATTACHMENTS_DIR, { recursive: true });
116
+ }
117
+ const attachmentUpload = multer({
118
+ dest: ATTACHMENTS_DIR,
119
+ limits: {
120
+ fileSize: 20 * 1024 * 1024, // 20MB per file
121
+ files: 10, // Max 10 files per request
122
+ },
123
+ fileFilter: (_req, file, cb) => {
124
+ // Allowed MIME types
125
+ const allowedMimes = [
126
+ // Images
127
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
128
+ // Documents
129
+ 'application/pdf',
130
+ // Microsoft Office
131
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
132
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
133
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
134
+ 'application/msword', // .doc
135
+ 'application/vnd.ms-excel', // .xls
136
+ 'application/vnd.ms-powerpoint', // .ppt
137
+ // Text
138
+ 'text/plain', 'text/markdown', 'text/csv',
139
+ // Code (text/* covers most)
140
+ 'text/javascript', 'text/typescript', 'text/html', 'text/css',
141
+ 'application/json', 'application/xml', 'text/xml',
142
+ 'application/x-yaml', 'text/yaml',
143
+ ];
144
+ // Also allow by extension for code files
145
+ const allowedExtensions = [
146
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
147
+ '.pdf',
148
+ // Office documents
149
+ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
150
+ '.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.toml',
151
+ '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs',
152
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
153
+ '.c', '.cpp', '.h', '.hpp', '.cs',
154
+ '.sql', '.sh', '.bash', '.zsh', '.ps1',
155
+ '.dockerfile', '.env', '.gitignore',
156
+ '.html', '.css', '.scss', '.sass', '.less',
157
+ ];
158
+ const ext = extname(file.originalname).toLowerCase();
159
+ if (allowedMimes.includes(file.mimetype) || allowedExtensions.includes(ext)) {
160
+ cb(null, true);
161
+ }
162
+ else {
163
+ cb(new Error(`Unsupported file type: ${file.mimetype} (${ext})`));
164
+ }
165
+ },
166
+ });
167
+ export const terminalRouter = Router();
168
+ /**
169
+ * Resolve the correct repo for a session, supporting multi-repo sessions.
170
+ * For multi-repo sessions, accepts an optional repoId from request body/query.
171
+ * Falls back to primary repo (repoIds[0]) if not specified or invalid.
172
+ */
173
+ function resolveSessionRepo(session, requestedRepoId) {
174
+ // If a specific repoId was requested and it's valid for this session, use it
175
+ if (requestedRepoId && session.repoIds.includes(requestedRepoId)) {
176
+ const repo = repoRegistry.get(requestedRepoId);
177
+ if (repo) {
178
+ return { repo, repoId: requestedRepoId };
179
+ }
180
+ }
181
+ // Fall back to primary repo (first in the list)
182
+ const primaryRepoId = session.repoIds[0];
183
+ const repo = repoRegistry.get(primaryRepoId);
184
+ if (repo) {
185
+ return { repo, repoId: primaryRepoId };
186
+ }
187
+ return null;
188
+ }
189
+ /**
190
+ * Get the working directory for git operations.
191
+ * For worktree sessions, uses the worktree path instead of repo path.
192
+ */
193
+ function getWorkingDir(session, repo) {
194
+ if (session.worktreeMode && session.worktreePath) {
195
+ return session.worktreePath;
196
+ }
197
+ return repo.path;
198
+ }
199
+ /**
200
+ * Get exec options for git commands with OAuth credentials if available.
201
+ * Returns both the options and a cleanup function.
202
+ */
203
+ function getGitExecOptions(workingDir, timeout = 60000) {
204
+ const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
205
+ let gitCredEnv = null;
206
+ let env = { ...process.env };
207
+ if (creds.token && creds.platform) {
208
+ gitCredEnv = getGitCredentialEnv(creds.token, creds.platform, creds.username || undefined);
209
+ env = { ...env, ...gitCredEnv };
210
+ console.log(`[terminal-routes] Using ${creds.platform} OAuth for git operation`);
211
+ }
212
+ return {
213
+ options: {
214
+ cwd: workingDir,
215
+ encoding: 'utf-8',
216
+ timeout,
217
+ env,
218
+ stdio: ['pipe', 'pipe', 'pipe'],
219
+ },
220
+ cleanup: () => {
221
+ if (gitCredEnv) {
222
+ cleanupGitCredentialEnv(gitCredEnv);
223
+ }
224
+ },
225
+ };
226
+ }
227
+ // Create a new terminal session (supports both single repo and multi-repo, and worktree mode)
228
+ terminalRouter.post('/sessions', (req, res) => {
229
+ try {
230
+ const { repoId, repoIds, worktreeMode, branch, baseBranch, existingWorktreePath } = req.body;
231
+ // Support both single repoId and array of repoIds
232
+ let ids;
233
+ if (repoIds && Array.isArray(repoIds)) {
234
+ ids = repoIds;
235
+ }
236
+ else if (repoId && typeof repoId === 'string') {
237
+ ids = [repoId];
238
+ }
239
+ else {
240
+ res.status(400).json({ success: false, error: 'repoId or repoIds is required' });
241
+ return;
242
+ }
243
+ if (ids.length === 0) {
244
+ res.status(400).json({ success: false, error: 'At least one repository is required' });
245
+ return;
246
+ }
247
+ // Build worktree options if worktree mode is requested
248
+ let worktreeOptions;
249
+ if (worktreeMode) {
250
+ // Either use existing worktree or create new one
251
+ if (existingWorktreePath && typeof existingWorktreePath === 'string') {
252
+ // Use existing worktree
253
+ worktreeOptions = {
254
+ worktreeMode: true,
255
+ existingWorktreePath,
256
+ branch: '', // Will be detected from worktree
257
+ };
258
+ }
259
+ else if (branch && typeof branch === 'string') {
260
+ // Create new worktree
261
+ worktreeOptions = {
262
+ worktreeMode: true,
263
+ branch,
264
+ baseBranch,
265
+ };
266
+ }
267
+ else {
268
+ res.status(400).json({ success: false, error: 'Branch name or existingWorktreePath is required when worktreeMode is enabled' });
269
+ return;
270
+ }
271
+ }
272
+ const session = terminalSessionManager.createSession(ids, worktreeOptions);
273
+ res.status(201).json({
274
+ success: true,
275
+ data: {
276
+ id: session.id,
277
+ repoIds: session.repoIds,
278
+ repoId: session.repoIds[0], // Backward compatibility
279
+ isMultiRepo: session.isMultiRepo,
280
+ status: session.status,
281
+ mode: session.mode,
282
+ messages: session.messages,
283
+ createdAt: session.createdAt,
284
+ lastActivityAt: session.lastActivityAt,
285
+ // Worktree fields
286
+ worktreeMode: session.worktreeMode,
287
+ worktreePath: session.worktreePath,
288
+ branch: session.branch,
289
+ baseBranch: session.baseBranch,
290
+ ownsWorktree: session.ownsWorktree,
291
+ },
292
+ });
293
+ }
294
+ catch (error) {
295
+ const errorMsg = error instanceof Error ? error.message : String(error);
296
+ res.status(400).json({ success: false, error: errorMsg });
297
+ }
298
+ });
299
+ // Search across all session messages
300
+ terminalRouter.get('/sessions/search', (req, res) => {
301
+ try {
302
+ const query = req.query.q;
303
+ const limit = parseInt(req.query.limit) || 50;
304
+ if (!query || query.trim().length === 0) {
305
+ res.json({ success: true, data: [] });
306
+ return;
307
+ }
308
+ const results = terminalSessionManager.searchMessages(query, limit);
309
+ res.json({
310
+ success: true,
311
+ data: results,
312
+ });
313
+ }
314
+ catch (error) {
315
+ const errorMsg = error instanceof Error ? error.message : String(error);
316
+ res.status(500).json({ success: false, error: errorMsg });
317
+ }
318
+ });
319
+ // List all worktree sessions
320
+ terminalRouter.get('/sessions/worktrees', (_req, res) => {
321
+ try {
322
+ const sessions = terminalSessionManager.getWorktreeSessions();
323
+ res.json({
324
+ success: true,
325
+ data: sessions.map((session) => ({
326
+ id: session.id,
327
+ repoIds: session.repoIds,
328
+ repoId: session.repoIds[0],
329
+ status: session.status,
330
+ mode: session.mode,
331
+ messageCount: session.messages.length,
332
+ createdAt: session.createdAt,
333
+ lastActivityAt: session.lastActivityAt,
334
+ isBookmarked: session.isBookmarked,
335
+ name: session.name,
336
+ // Worktree specific fields
337
+ worktreeMode: session.worktreeMode,
338
+ worktreePath: session.worktreePath,
339
+ branch: session.branch,
340
+ baseBranch: session.baseBranch,
341
+ ownsWorktree: session.ownsWorktree,
342
+ })),
343
+ });
344
+ }
345
+ catch (error) {
346
+ const errorMsg = error instanceof Error ? error.message : String(error);
347
+ res.status(500).json({ success: false, error: errorMsg });
348
+ }
349
+ });
350
+ // Get branches for a repository (for worktree creation UI)
351
+ // Pass ?fetch=true to fetch from remote first
352
+ terminalRouter.get('/repos/:repoId/branches', (req, res) => {
353
+ try {
354
+ const { repoId } = req.params;
355
+ const shouldFetch = req.query.fetch === 'true';
356
+ const repo = repoRegistry.get(repoId);
357
+ if (!repo) {
358
+ res.status(404).json({ success: false, error: 'Repository not found' });
359
+ return;
360
+ }
361
+ let localBranches = [];
362
+ let remoteBranches = [];
363
+ let currentBranch = '';
364
+ // Fetch from remote first if requested (to get latest branches)
365
+ if (shouldFetch) {
366
+ try {
367
+ // Get exec options with credentials for authenticated fetch
368
+ const { options: execOptions, cleanup } = getGitExecOptions(repo.path, 60000);
369
+ try {
370
+ execSync('git fetch --all --prune', execOptions);
371
+ }
372
+ finally {
373
+ if (cleanup)
374
+ cleanup();
375
+ }
376
+ }
377
+ catch (fetchErr) {
378
+ // Log but don't fail - we can still show local branches
379
+ console.log('[branches] Fetch failed (may need auth):', fetchErr instanceof Error ? fetchErr.message : fetchErr);
380
+ }
381
+ }
382
+ try {
383
+ // Get current branch
384
+ currentBranch = execSync('git branch --show-current', {
385
+ cwd: repo.path,
386
+ encoding: 'utf-8',
387
+ timeout: 5000,
388
+ }).trim();
389
+ // Get all local branches
390
+ const localOutput = execSync('git branch --format="%(refname:short)"', {
391
+ cwd: repo.path,
392
+ encoding: 'utf-8',
393
+ timeout: 10000,
394
+ });
395
+ localBranches = localOutput
396
+ .split('\n')
397
+ .map(b => b.trim())
398
+ .filter(b => b.length > 0);
399
+ // Get all remote branches (strip origin/ prefix for display)
400
+ const remoteOutput = execSync('git branch -r --format="%(refname:short)"', {
401
+ cwd: repo.path,
402
+ encoding: 'utf-8',
403
+ timeout: 10000,
404
+ });
405
+ remoteBranches = remoteOutput
406
+ .split('\n')
407
+ .map(b => b.trim())
408
+ .filter(b => b.length > 0 && !b.includes('HEAD'))
409
+ .map(b => b.replace(/^origin\//, '')); // Strip origin/ prefix
410
+ // Remove duplicates (branches that exist both locally and remotely)
411
+ remoteBranches = remoteBranches.filter(rb => !localBranches.includes(rb));
412
+ }
413
+ catch {
414
+ // Ignore errors - might not be a git repo
415
+ }
416
+ // Determine main branch
417
+ const mainBranch = gitSandbox.getMainBranch(repo.path);
418
+ // Combine branches: local first, then remote-only
419
+ const allBranches = [...localBranches, ...remoteBranches];
420
+ res.json({
421
+ success: true,
422
+ data: {
423
+ branches: allBranches,
424
+ localBranches,
425
+ remoteBranches,
426
+ currentBranch,
427
+ mainBranch,
428
+ },
429
+ });
430
+ }
431
+ catch (error) {
432
+ const errorMsg = error instanceof Error ? error.message : String(error);
433
+ res.status(500).json({ success: false, error: errorMsg });
434
+ }
435
+ });
436
+ // List existing worktrees for a repository
437
+ terminalRouter.get('/repos/:repoId/worktrees', (req, res) => {
438
+ try {
439
+ const { repoId } = req.params;
440
+ const repo = repoRegistry.get(repoId);
441
+ if (!repo) {
442
+ res.status(404).json({ success: false, error: 'Repository not found' });
443
+ return;
444
+ }
445
+ // Get list of worktree paths
446
+ const worktreePaths = gitSandbox.listWorktrees(repo.path);
447
+ // Get branch info for each worktree
448
+ const worktrees = worktreePaths.map((worktreePath) => {
449
+ let branch = '';
450
+ let isMain = false;
451
+ try {
452
+ // Get branch name for this worktree
453
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
454
+ cwd: worktreePath,
455
+ encoding: 'utf-8',
456
+ timeout: 5000,
457
+ }).trim();
458
+ // Check if this is the main worktree (same path as repo)
459
+ isMain = worktreePath.replace(/\\/g, '/') === repo.path.replace(/\\/g, '/');
460
+ }
461
+ catch {
462
+ // Ignore errors - might be a corrupted worktree
463
+ }
464
+ return {
465
+ path: worktreePath,
466
+ branch,
467
+ isMain,
468
+ };
469
+ });
470
+ // Filter out the main worktree and any without a branch (corrupted)
471
+ const usableWorktrees = worktrees.filter(wt => !wt.isMain && wt.branch);
472
+ res.json({
473
+ success: true,
474
+ data: usableWorktrees,
475
+ });
476
+ }
477
+ catch (error) {
478
+ const errorMsg = error instanceof Error ? error.message : String(error);
479
+ res.status(500).json({ success: false, error: errorMsg });
480
+ }
481
+ });
482
+ // Get worktree info for a specific session
483
+ terminalRouter.get('/sessions/:id/worktree', (req, res) => {
484
+ try {
485
+ const { id } = req.params;
486
+ const info = terminalSessionManager.getWorktreeInfo(id);
487
+ if (!info) {
488
+ res.status(404).json({ success: false, error: 'Session not found' });
489
+ return;
490
+ }
491
+ res.json({
492
+ success: true,
493
+ data: info,
494
+ });
495
+ }
496
+ catch (error) {
497
+ const errorMsg = error instanceof Error ? error.message : String(error);
498
+ res.status(500).json({ success: false, error: errorMsg });
499
+ }
500
+ });
501
+ // List all sessions
502
+ terminalRouter.get('/sessions', (_req, res) => {
503
+ try {
504
+ const sessions = terminalSessionManager.getAllSessions();
505
+ res.json({
506
+ success: true,
507
+ data: sessions.map((session) => ({
508
+ id: session.id,
509
+ repoIds: session.repoIds,
510
+ repoId: session.repoIds[0], // Backward compatibility
511
+ isMultiRepo: session.isMultiRepo,
512
+ status: session.status,
513
+ mode: session.mode,
514
+ messageCount: session.messages.length,
515
+ lastMessage: session.messages[session.messages.length - 1]?.content.slice(0, 100),
516
+ createdAt: session.createdAt,
517
+ lastActivityAt: session.lastActivityAt,
518
+ isBookmarked: session.isBookmarked,
519
+ bookmarkedAt: session.bookmarkedAt,
520
+ name: session.name,
521
+ // Worktree fields
522
+ worktreeMode: session.worktreeMode,
523
+ branch: session.branch,
524
+ baseBranch: session.baseBranch,
525
+ ownsWorktree: session.ownsWorktree,
526
+ })),
527
+ });
528
+ }
529
+ catch (error) {
530
+ const errorMsg = error instanceof Error ? error.message : String(error);
531
+ res.status(500).json({ success: false, error: errorMsg });
532
+ }
533
+ });
534
+ // Get a specific session with full history
535
+ terminalRouter.get('/sessions/:id', (req, res) => {
536
+ try {
537
+ const { id } = req.params;
538
+ const session = terminalSessionManager.getSession(id);
539
+ if (!session) {
540
+ res.status(404).json({ success: false, error: 'Session not found' });
541
+ return;
542
+ }
543
+ res.json({
544
+ success: true,
545
+ data: {
546
+ id: session.id,
547
+ repoIds: session.repoIds,
548
+ repoId: session.repoIds[0], // Backward compatibility
549
+ isMultiRepo: session.isMultiRepo,
550
+ mergedFromSessionIds: session.mergedFromSessionIds,
551
+ status: session.status,
552
+ mode: session.mode,
553
+ messages: session.messages,
554
+ createdAt: session.createdAt,
555
+ lastActivityAt: session.lastActivityAt,
556
+ isBookmarked: session.isBookmarked,
557
+ bookmarkedAt: session.bookmarkedAt,
558
+ name: session.name,
559
+ // Worktree fields
560
+ worktreeMode: session.worktreeMode,
561
+ worktreePath: session.worktreePath,
562
+ branch: session.branch,
563
+ baseBranch: session.baseBranch,
564
+ ownsWorktree: session.ownsWorktree,
565
+ },
566
+ });
567
+ }
568
+ catch (error) {
569
+ const errorMsg = error instanceof Error ? error.message : String(error);
570
+ res.status(500).json({ success: false, error: errorMsg });
571
+ }
572
+ });
573
+ // Upload file attachments for a session
574
+ terminalRouter.post('/sessions/:id/attachments', attachmentUpload.array('files', 10), (req, res) => {
575
+ const { id } = req.params;
576
+ const files = req.files;
577
+ try {
578
+ const session = terminalSessionManager.getSession(id);
579
+ if (!session) {
580
+ // Clean up uploaded files if session not found
581
+ files?.forEach((f) => existsSync(f.path) && unlinkSync(f.path));
582
+ res.status(404).json({ success: false, error: 'Session not found' });
583
+ return;
584
+ }
585
+ if (!files || files.length === 0) {
586
+ res.status(400).json({ success: false, error: 'No files provided' });
587
+ return;
588
+ }
589
+ // Rename files with session prefix for easier cleanup
590
+ const attachments = files.map((file) => {
591
+ const newFilename = `${id}_${Date.now()}_${file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
592
+ const newPath = join(ATTACHMENTS_DIR, newFilename);
593
+ renameSync(file.path, newPath);
594
+ return {
595
+ id: randomUUID(),
596
+ originalName: file.originalname,
597
+ path: newPath,
598
+ size: file.size,
599
+ mimeType: file.mimetype,
600
+ uploadedAt: new Date().toISOString(),
601
+ };
602
+ });
603
+ res.json({
604
+ success: true,
605
+ data: { attachments },
606
+ });
607
+ }
608
+ catch (error) {
609
+ // Clean up files on error
610
+ files?.forEach((f) => existsSync(f.path) && unlinkSync(f.path));
611
+ const errorMsg = error instanceof Error ? error.message : String(error);
612
+ res.status(500).json({ success: false, error: errorMsg });
613
+ }
614
+ });
615
+ // List attachments for a session
616
+ terminalRouter.get('/sessions/:id/attachments', (req, res) => {
617
+ const { id } = req.params;
618
+ try {
619
+ const session = terminalSessionManager.getSession(id);
620
+ if (!session) {
621
+ res.status(404).json({ success: false, error: 'Session not found' });
622
+ return;
623
+ }
624
+ // Find all files prefixed with this session ID
625
+ const files = readdirSync(ATTACHMENTS_DIR)
626
+ .filter((f) => f.startsWith(`${id}_`))
627
+ .map((filename) => {
628
+ const filePath = join(ATTACHMENTS_DIR, filename);
629
+ const stat = statSync(filePath);
630
+ // Extract original name from filename (format: sessionId_timestamp_originalName)
631
+ const parts = filename.split('_');
632
+ const originalName = parts.slice(2).join('_');
633
+ return {
634
+ id: filename,
635
+ originalName,
636
+ path: filePath,
637
+ size: stat.size,
638
+ uploadedAt: stat.mtime.toISOString(),
639
+ };
640
+ });
641
+ res.json({
642
+ success: true,
643
+ data: { attachments: files },
644
+ });
645
+ }
646
+ catch (error) {
647
+ const errorMsg = error instanceof Error ? error.message : String(error);
648
+ res.status(500).json({ success: false, error: errorMsg });
649
+ }
650
+ });
651
+ // Delete a specific attachment
652
+ terminalRouter.delete('/sessions/:id/attachments/:attachmentId', (req, res) => {
653
+ const { id, attachmentId } = req.params;
654
+ try {
655
+ const session = terminalSessionManager.getSession(id);
656
+ if (!session) {
657
+ res.status(404).json({ success: false, error: 'Session not found' });
658
+ return;
659
+ }
660
+ // Ensure the attachment belongs to this session
661
+ if (!attachmentId.startsWith(`${id}_`)) {
662
+ res.status(403).json({ success: false, error: 'Attachment does not belong to this session' });
663
+ return;
664
+ }
665
+ const filePath = join(ATTACHMENTS_DIR, attachmentId);
666
+ if (existsSync(filePath)) {
667
+ unlinkSync(filePath);
668
+ res.json({ success: true, data: { deleted: attachmentId } });
669
+ }
670
+ else {
671
+ res.status(404).json({ success: false, error: 'Attachment not found' });
672
+ }
673
+ }
674
+ catch (error) {
675
+ const errorMsg = error instanceof Error ? error.message : String(error);
676
+ res.status(500).json({ success: false, error: errorMsg });
677
+ }
678
+ });
679
+ // Send a message to a session (REST fallback for non-WebSocket clients)
680
+ terminalRouter.post('/sessions/:id/message', async (req, res) => {
681
+ try {
682
+ const { id } = req.params;
683
+ const { content } = req.body;
684
+ if (!content || typeof content !== 'string') {
685
+ res.status(400).json({ success: false, error: 'content is required' });
686
+ return;
687
+ }
688
+ const session = terminalSessionManager.getSession(id);
689
+ if (!session) {
690
+ res.status(404).json({ success: false, error: 'Session not found' });
691
+ return;
692
+ }
693
+ // This is async but we respond immediately
694
+ // Client should use WebSocket for real-time updates
695
+ terminalSessionManager.sendMessage(id, content).catch((err) => {
696
+ console.error(`[Terminal] Message send error:`, err);
697
+ });
698
+ res.json({
699
+ success: true,
700
+ data: { message: 'Message sent. Use WebSocket for real-time updates.' },
701
+ });
702
+ }
703
+ catch (error) {
704
+ const errorMsg = error instanceof Error ? error.message : String(error);
705
+ res.status(400).json({ success: false, error: errorMsg });
706
+ }
707
+ });
708
+ // Set session mode
709
+ terminalRouter.patch('/sessions/:id/mode', (req, res) => {
710
+ try {
711
+ const { id } = req.params;
712
+ const { mode } = req.body;
713
+ if (mode !== 'plan' && mode !== 'direct') {
714
+ res.status(400).json({ success: false, error: 'mode must be "plan" or "direct"' });
715
+ return;
716
+ }
717
+ const session = terminalSessionManager.getSession(id);
718
+ if (!session) {
719
+ res.status(404).json({ success: false, error: 'Session not found' });
720
+ return;
721
+ }
722
+ terminalSessionManager.setMode(id, mode);
723
+ res.json({
724
+ success: true,
725
+ data: { mode },
726
+ });
727
+ }
728
+ catch (error) {
729
+ const errorMsg = error instanceof Error ? error.message : String(error);
730
+ res.status(400).json({ success: false, error: errorMsg });
731
+ }
732
+ });
733
+ // Export session as markdown or JSON
734
+ terminalRouter.get('/sessions/:id/export', (req, res) => {
735
+ try {
736
+ const { id } = req.params;
737
+ const format = req.query.format || 'markdown';
738
+ if (format !== 'markdown' && format !== 'json') {
739
+ res.status(400).json({ success: false, error: 'format must be "markdown" or "json"' });
740
+ return;
741
+ }
742
+ const content = terminalSessionManager.exportSession(id, format);
743
+ if (!content) {
744
+ res.status(404).json({ success: false, error: 'Session not found' });
745
+ return;
746
+ }
747
+ const session = terminalSessionManager.getSession(id);
748
+ const filename = `session-${session?.name || session?.repoIds[0] || id}-${new Date().toISOString().split('T')[0]}`;
749
+ if (format === 'json') {
750
+ res.setHeader('Content-Type', 'application/json');
751
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
752
+ }
753
+ else {
754
+ res.setHeader('Content-Type', 'text/markdown');
755
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}.md"`);
756
+ }
757
+ res.send(content);
758
+ }
759
+ catch (error) {
760
+ const errorMsg = error instanceof Error ? error.message : String(error);
761
+ res.status(500).json({ success: false, error: errorMsg });
762
+ }
763
+ });
764
+ // Toggle session bookmark
765
+ terminalRouter.patch('/sessions/:id/bookmark', (req, res) => {
766
+ try {
767
+ const { id } = req.params;
768
+ const { isBookmarked } = req.body;
769
+ if (typeof isBookmarked !== 'boolean') {
770
+ res.status(400).json({ success: false, error: 'isBookmarked must be a boolean' });
771
+ return;
772
+ }
773
+ const session = terminalSessionManager.setBookmark(id, isBookmarked);
774
+ if (!session) {
775
+ res.status(404).json({ success: false, error: 'Session not found' });
776
+ return;
777
+ }
778
+ res.json({
779
+ success: true,
780
+ data: {
781
+ id: session.id,
782
+ isBookmarked: session.isBookmarked,
783
+ bookmarkedAt: session.bookmarkedAt,
784
+ },
785
+ });
786
+ }
787
+ catch (error) {
788
+ const errorMsg = error instanceof Error ? error.message : String(error);
789
+ res.status(400).json({ success: false, error: errorMsg });
790
+ }
791
+ });
792
+ // Cancel running operation
793
+ terminalRouter.post('/sessions/:id/cancel', (req, res) => {
794
+ try {
795
+ const { id } = req.params;
796
+ const session = terminalSessionManager.getSession(id);
797
+ if (!session) {
798
+ res.status(404).json({ success: false, error: 'Session not found' });
799
+ return;
800
+ }
801
+ terminalSessionManager.cancelSession(id);
802
+ res.json({
803
+ success: true,
804
+ data: { message: 'Cancelled' },
805
+ });
806
+ }
807
+ catch (error) {
808
+ const errorMsg = error instanceof Error ? error.message : String(error);
809
+ res.status(400).json({ success: false, error: errorMsg });
810
+ }
811
+ });
812
+ // Clear session messages
813
+ terminalRouter.post('/sessions/:id/clear', (req, res) => {
814
+ try {
815
+ const { id } = req.params;
816
+ const session = terminalSessionManager.getSession(id);
817
+ if (!session) {
818
+ res.status(404).json({ success: false, error: 'Session not found' });
819
+ return;
820
+ }
821
+ terminalSessionManager.clearMessages(id);
822
+ res.json({
823
+ success: true,
824
+ data: { message: 'Messages cleared' },
825
+ });
826
+ }
827
+ catch (error) {
828
+ const errorMsg = error instanceof Error ? error.message : String(error);
829
+ res.status(400).json({ success: false, error: errorMsg });
830
+ }
831
+ });
832
+ // Delete a session
833
+ // For worktree sessions:
834
+ // - ?deleteBranch=true to also delete the git branch
835
+ // - ?deleteWorktree=false to keep the worktree on disk (close session only)
836
+ terminalRouter.delete('/sessions/:id', (req, res) => {
837
+ try {
838
+ const { id } = req.params;
839
+ const deleteBranch = req.query.deleteBranch === 'true';
840
+ // Default to true for backwards compatibility - only skip worktree deletion if explicitly set to 'false'
841
+ const deleteWorktree = req.query.deleteWorktree !== 'false';
842
+ const session = terminalSessionManager.getSession(id);
843
+ if (!session) {
844
+ res.status(404).json({ success: false, error: 'Session not found' });
845
+ return;
846
+ }
847
+ const result = terminalSessionManager.deleteSession(id, deleteBranch, deleteWorktree);
848
+ res.json({
849
+ success: true,
850
+ data: {
851
+ message: 'Session deleted',
852
+ worktreeDeleted: result.worktreeDeleted,
853
+ branchDeleted: result.branchDeleted,
854
+ },
855
+ });
856
+ }
857
+ catch (error) {
858
+ const errorMsg = error instanceof Error ? error.message : String(error);
859
+ res.status(400).json({ success: false, error: errorMsg });
860
+ }
861
+ });
862
+ // Merge multiple sessions into one multi-repo session
863
+ terminalRouter.post('/sessions/merge', (req, res) => {
864
+ try {
865
+ const { sessionIds } = req.body;
866
+ if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length < 2) {
867
+ res.status(400).json({
868
+ success: false,
869
+ error: 'sessionIds array with at least 2 session IDs is required',
870
+ });
871
+ return;
872
+ }
873
+ const session = terminalSessionManager.mergeSessions(sessionIds);
874
+ res.status(201).json({
875
+ success: true,
876
+ data: {
877
+ id: session.id,
878
+ repoIds: session.repoIds,
879
+ repoId: session.repoIds[0],
880
+ isMultiRepo: session.isMultiRepo,
881
+ mergedFromSessionIds: session.mergedFromSessionIds,
882
+ status: session.status,
883
+ mode: session.mode,
884
+ messages: session.messages,
885
+ createdAt: session.createdAt,
886
+ lastActivityAt: session.lastActivityAt,
887
+ },
888
+ });
889
+ }
890
+ catch (error) {
891
+ const errorMsg = error instanceof Error ? error.message : String(error);
892
+ res.status(400).json({ success: false, error: errorMsg });
893
+ }
894
+ });
895
+ // Add a repository to an existing session
896
+ terminalRouter.post('/sessions/:id/add-repo', (req, res) => {
897
+ try {
898
+ const { id } = req.params;
899
+ const { repoId } = req.body;
900
+ if (!repoId || typeof repoId !== 'string') {
901
+ res.status(400).json({ success: false, error: 'repoId is required' });
902
+ return;
903
+ }
904
+ const session = terminalSessionManager.addRepoToSession(id, repoId);
905
+ res.json({
906
+ success: true,
907
+ data: {
908
+ id: session.id,
909
+ repoIds: session.repoIds,
910
+ isMultiRepo: session.isMultiRepo,
911
+ },
912
+ });
913
+ }
914
+ catch (error) {
915
+ const errorMsg = error instanceof Error ? error.message : String(error);
916
+ res.status(400).json({ success: false, error: errorMsg });
917
+ }
918
+ });
919
+ // Remove a repository from a session
920
+ terminalRouter.post('/sessions/:id/remove-repo', (req, res) => {
921
+ try {
922
+ const { id } = req.params;
923
+ const { repoId } = req.body;
924
+ if (!repoId || typeof repoId !== 'string') {
925
+ res.status(400).json({ success: false, error: 'repoId is required' });
926
+ return;
927
+ }
928
+ const session = terminalSessionManager.removeRepoFromSession(id, repoId);
929
+ res.json({
930
+ success: true,
931
+ data: {
932
+ id: session.id,
933
+ repoIds: session.repoIds,
934
+ isMultiRepo: session.isMultiRepo,
935
+ },
936
+ });
937
+ }
938
+ catch (error) {
939
+ const errorMsg = error instanceof Error ? error.message : String(error);
940
+ res.status(400).json({ success: false, error: errorMsg });
941
+ }
942
+ });
943
+ // Get git status for all repositories in a session (multi-repo support)
944
+ terminalRouter.get('/sessions/:id/multi-git-status', (req, res) => {
945
+ try {
946
+ const { id } = req.params;
947
+ const session = terminalSessionManager.getSession(id);
948
+ if (!session) {
949
+ res.status(404).json({ success: false, error: 'Session not found' });
950
+ return;
951
+ }
952
+ const repos = {};
953
+ for (const repoId of session.repoIds) {
954
+ const repo = repoRegistry.get(repoId);
955
+ if (!repo)
956
+ continue;
957
+ // For worktree sessions (single repo), use worktree path
958
+ const workingDir = session.worktreeMode && session.worktreePath
959
+ ? session.worktreePath
960
+ : repo.path;
961
+ let branch = 'unknown';
962
+ let modified = 0;
963
+ let staged = 0;
964
+ let untracked = 0;
965
+ try {
966
+ branch = execSync('git branch --show-current', {
967
+ cwd: workingDir,
968
+ encoding: 'utf-8',
969
+ timeout: 5000,
970
+ }).trim();
971
+ }
972
+ catch {
973
+ // Ignore errors
974
+ }
975
+ try {
976
+ const status = execSync('git status --porcelain', {
977
+ cwd: workingDir,
978
+ encoding: 'utf-8',
979
+ timeout: 5000,
980
+ });
981
+ const lines = status.split('\n').filter((line) => line.trim());
982
+ for (const line of lines) {
983
+ const indexStatus = line[0];
984
+ const workTreeStatus = line[1];
985
+ if (indexStatus !== ' ' && indexStatus !== '?') {
986
+ staged++;
987
+ }
988
+ if (workTreeStatus === 'M' || workTreeStatus === 'D') {
989
+ modified++;
990
+ }
991
+ if (indexStatus === '?') {
992
+ untracked++;
993
+ }
994
+ }
995
+ }
996
+ catch {
997
+ // Ignore errors
998
+ }
999
+ repos[repoId] = {
1000
+ repoId,
1001
+ repoPath: workingDir,
1002
+ branch,
1003
+ modified,
1004
+ staged,
1005
+ untracked,
1006
+ worktreeMode: session.worktreeMode,
1007
+ };
1008
+ }
1009
+ res.json({
1010
+ success: true,
1011
+ data: {
1012
+ isMultiRepo: session.isMultiRepo,
1013
+ worktreeMode: session.worktreeMode,
1014
+ repos,
1015
+ },
1016
+ });
1017
+ }
1018
+ catch (error) {
1019
+ const errorMsg = error instanceof Error ? error.message : String(error);
1020
+ res.status(500).json({ success: false, error: errorMsg });
1021
+ }
1022
+ });
1023
+ // Get git status for a session's repository
1024
+ terminalRouter.get('/sessions/:id/git-status', (req, res) => {
1025
+ try {
1026
+ const { id } = req.params;
1027
+ const requestedRepoId = req.query.repoId;
1028
+ const session = terminalSessionManager.getSession(id);
1029
+ if (!session) {
1030
+ res.status(404).json({ success: false, error: 'Session not found' });
1031
+ return;
1032
+ }
1033
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1034
+ if (!resolved) {
1035
+ res.status(404).json({ success: false, error: 'Repository not found' });
1036
+ return;
1037
+ }
1038
+ const { repo } = resolved;
1039
+ const workingDir = getWorkingDir(session, repo);
1040
+ // Get git branch
1041
+ let branch = 'unknown';
1042
+ try {
1043
+ branch = execSync('git branch --show-current', {
1044
+ cwd: workingDir,
1045
+ encoding: 'utf-8',
1046
+ timeout: 5000,
1047
+ }).trim();
1048
+ }
1049
+ catch {
1050
+ // Ignore errors
1051
+ }
1052
+ // Get git status counts and file details
1053
+ let modified = 0;
1054
+ let staged = 0;
1055
+ let untracked = 0;
1056
+ const files = [];
1057
+ try {
1058
+ const status = execSync('git status --porcelain', {
1059
+ cwd: workingDir,
1060
+ encoding: 'utf-8',
1061
+ timeout: 5000,
1062
+ });
1063
+ const lines = status.split('\n').filter(line => line.trim());
1064
+ for (const line of lines) {
1065
+ const indexStatus = line[0];
1066
+ const workTreeStatus = line[1];
1067
+ // File path starts at position 3 (after "XY ")
1068
+ const filePath = line.substring(3).trim();
1069
+ if (indexStatus !== ' ' && indexStatus !== '?') {
1070
+ staged++;
1071
+ }
1072
+ if (workTreeStatus === 'M' || workTreeStatus === 'D') {
1073
+ modified++;
1074
+ }
1075
+ if (indexStatus === '?') {
1076
+ untracked++;
1077
+ }
1078
+ // Determine file status for display
1079
+ let fileStatus = 'modified';
1080
+ if (indexStatus === '?' && workTreeStatus === '?') {
1081
+ fileStatus = 'untracked';
1082
+ }
1083
+ else if (indexStatus === 'A') {
1084
+ fileStatus = 'added';
1085
+ }
1086
+ else if (indexStatus === 'D' || workTreeStatus === 'D') {
1087
+ fileStatus = 'deleted';
1088
+ }
1089
+ else if (indexStatus === 'R') {
1090
+ fileStatus = 'renamed';
1091
+ }
1092
+ else if (indexStatus === 'M' || workTreeStatus === 'M') {
1093
+ fileStatus = 'modified';
1094
+ }
1095
+ if (filePath) {
1096
+ files.push({ path: filePath, status: fileStatus });
1097
+ }
1098
+ }
1099
+ }
1100
+ catch {
1101
+ // Ignore errors
1102
+ }
1103
+ res.json({
1104
+ success: true,
1105
+ data: { branch, modified, staged, untracked, files, worktreeMode: session.worktreeMode },
1106
+ });
1107
+ }
1108
+ catch (error) {
1109
+ const errorMsg = error instanceof Error ? error.message : String(error);
1110
+ res.status(500).json({ success: false, error: errorMsg });
1111
+ }
1112
+ });
1113
+ // Read a file from a session's repository
1114
+ terminalRouter.post('/sessions/:id/read-file', (req, res) => {
1115
+ try {
1116
+ const { id } = req.params;
1117
+ const { filePath, repoId: requestedRepoId } = req.body;
1118
+ if (!filePath || typeof filePath !== 'string') {
1119
+ res.status(400).json({ success: false, error: 'filePath is required' });
1120
+ return;
1121
+ }
1122
+ const session = terminalSessionManager.getSession(id);
1123
+ if (!session) {
1124
+ res.status(404).json({ success: false, error: 'Session not found' });
1125
+ return;
1126
+ }
1127
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1128
+ if (!resolved) {
1129
+ res.status(404).json({ success: false, error: 'Repository not found' });
1130
+ return;
1131
+ }
1132
+ const { repo } = resolved;
1133
+ const workingDir = getWorkingDir(session, repo);
1134
+ // Ensure path is within working dir (security check)
1135
+ const fullPath = join(workingDir, filePath);
1136
+ if (!fullPath.startsWith(workingDir)) {
1137
+ res.status(400).json({ success: false, error: 'Invalid file path' });
1138
+ return;
1139
+ }
1140
+ if (!existsSync(fullPath)) {
1141
+ res.status(404).json({ success: false, error: 'File not found' });
1142
+ return;
1143
+ }
1144
+ const content = readFileSync(fullPath, 'utf-8');
1145
+ const extension = extname(filePath).slice(1) || 'txt';
1146
+ // Map extensions to language names
1147
+ const languageMap = {
1148
+ ts: 'typescript',
1149
+ tsx: 'tsx',
1150
+ js: 'javascript',
1151
+ jsx: 'jsx',
1152
+ py: 'python',
1153
+ rb: 'ruby',
1154
+ go: 'go',
1155
+ rs: 'rust',
1156
+ java: 'java',
1157
+ cpp: 'cpp',
1158
+ c: 'c',
1159
+ cs: 'csharp',
1160
+ php: 'php',
1161
+ swift: 'swift',
1162
+ kt: 'kotlin',
1163
+ sql: 'sql',
1164
+ json: 'json',
1165
+ yaml: 'yaml',
1166
+ yml: 'yaml',
1167
+ xml: 'xml',
1168
+ html: 'html',
1169
+ css: 'css',
1170
+ scss: 'scss',
1171
+ md: 'markdown',
1172
+ sh: 'bash',
1173
+ bash: 'bash',
1174
+ zsh: 'bash',
1175
+ };
1176
+ res.json({
1177
+ success: true,
1178
+ data: {
1179
+ content,
1180
+ language: languageMap[extension] || extension,
1181
+ path: filePath,
1182
+ },
1183
+ });
1184
+ }
1185
+ catch (error) {
1186
+ const errorMsg = error instanceof Error ? error.message : String(error);
1187
+ res.status(500).json({ success: false, error: errorMsg });
1188
+ }
1189
+ });
1190
+ // Get git diff for a file
1191
+ terminalRouter.post('/sessions/:id/file-diff', (req, res) => {
1192
+ try {
1193
+ const { id } = req.params;
1194
+ const { filePath, staged, repoId: requestedRepoId } = req.body;
1195
+ const session = terminalSessionManager.getSession(id);
1196
+ if (!session) {
1197
+ res.status(404).json({ success: false, error: 'Session not found' });
1198
+ return;
1199
+ }
1200
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1201
+ if (!resolved) {
1202
+ res.status(404).json({ success: false, error: 'Repository not found' });
1203
+ return;
1204
+ }
1205
+ const { repo } = resolved;
1206
+ const workingDir = getWorkingDir(session, repo);
1207
+ let diff = '';
1208
+ try {
1209
+ // Use --cached for staged files, regular diff for unstaged
1210
+ const cachedFlag = staged ? '--cached' : '';
1211
+ if (filePath) {
1212
+ // Diff for specific file (use -M to detect renames)
1213
+ diff = execSync(`git diff ${cachedFlag} -M -- "${filePath}"`, {
1214
+ cwd: workingDir,
1215
+ encoding: 'utf-8',
1216
+ timeout: 10000,
1217
+ });
1218
+ }
1219
+ else {
1220
+ // Diff for all changed files
1221
+ diff = execSync(`git diff ${cachedFlag} -M`, {
1222
+ cwd: workingDir,
1223
+ encoding: 'utf-8',
1224
+ timeout: 10000,
1225
+ });
1226
+ }
1227
+ }
1228
+ catch {
1229
+ // No changes or git error
1230
+ diff = '';
1231
+ }
1232
+ res.json({
1233
+ success: true,
1234
+ data: { diff },
1235
+ });
1236
+ }
1237
+ catch (error) {
1238
+ const errorMsg = error instanceof Error ? error.message : String(error);
1239
+ res.status(500).json({ success: false, error: errorMsg });
1240
+ }
1241
+ });
1242
+ // Get list of changed files
1243
+ terminalRouter.get('/sessions/:id/changed-files', (req, res) => {
1244
+ try {
1245
+ const { id } = req.params;
1246
+ const requestedRepoId = req.query.repoId;
1247
+ const session = terminalSessionManager.getSession(id);
1248
+ if (!session) {
1249
+ res.status(404).json({ success: false, error: 'Session not found' });
1250
+ return;
1251
+ }
1252
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1253
+ if (!resolved) {
1254
+ res.status(404).json({ success: false, error: 'Repository not found' });
1255
+ return;
1256
+ }
1257
+ const { repo } = resolved;
1258
+ const workingDir = getWorkingDir(session, repo);
1259
+ const files = [];
1260
+ try {
1261
+ const status = execSync('git status --porcelain', {
1262
+ cwd: workingDir,
1263
+ encoding: 'utf-8',
1264
+ timeout: 5000,
1265
+ });
1266
+ const lines = status.split('\n').filter(line => line.trim());
1267
+ for (const line of lines) {
1268
+ const statusCode = line.slice(0, 2);
1269
+ const filePath = line.slice(3);
1270
+ let fileStatus = 'modified';
1271
+ if (statusCode.includes('A'))
1272
+ fileStatus = 'added';
1273
+ else if (statusCode.includes('D'))
1274
+ fileStatus = 'deleted';
1275
+ else if (statusCode.includes('?'))
1276
+ fileStatus = 'untracked';
1277
+ else if (statusCode.includes('M'))
1278
+ fileStatus = 'modified';
1279
+ files.push({ path: filePath, status: fileStatus });
1280
+ }
1281
+ }
1282
+ catch {
1283
+ // Ignore errors
1284
+ }
1285
+ res.json({
1286
+ success: true,
1287
+ data: { files },
1288
+ });
1289
+ }
1290
+ catch (error) {
1291
+ const errorMsg = error instanceof Error ? error.message : String(error);
1292
+ res.status(500).json({ success: false, error: errorMsg });
1293
+ }
1294
+ });
1295
+ // Get branch info (current branch and base branch)
1296
+ terminalRouter.get('/sessions/:id/branch-info', (req, res) => {
1297
+ try {
1298
+ const { id } = req.params;
1299
+ const requestedRepoId = req.query.repoId;
1300
+ const session = terminalSessionManager.getSession(id);
1301
+ if (!session) {
1302
+ res.status(404).json({ success: false, error: 'Session not found' });
1303
+ return;
1304
+ }
1305
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1306
+ if (!resolved) {
1307
+ res.status(404).json({ success: false, error: 'Repository not found' });
1308
+ return;
1309
+ }
1310
+ const { repo } = resolved;
1311
+ const workingDir = getWorkingDir(session, repo);
1312
+ let currentBranch = '';
1313
+ let baseBranch = 'main'; // Default to main
1314
+ try {
1315
+ // Get current branch
1316
+ currentBranch = execSync('git branch --show-current', {
1317
+ cwd: workingDir,
1318
+ encoding: 'utf-8',
1319
+ timeout: 5000,
1320
+ }).trim();
1321
+ // Try to find base branch (main or master)
1322
+ const branches = execSync('git branch -a', {
1323
+ cwd: workingDir,
1324
+ encoding: 'utf-8',
1325
+ timeout: 5000,
1326
+ });
1327
+ if (branches.includes('main')) {
1328
+ baseBranch = 'main';
1329
+ }
1330
+ else if (branches.includes('master')) {
1331
+ baseBranch = 'master';
1332
+ }
1333
+ }
1334
+ catch {
1335
+ // Ignore errors
1336
+ }
1337
+ // Get commit count ahead of base branch
1338
+ let commitsAhead = 0;
1339
+ try {
1340
+ const count = execSync(`git rev-list --count ${baseBranch}..HEAD`, {
1341
+ cwd: workingDir,
1342
+ encoding: 'utf-8',
1343
+ timeout: 5000,
1344
+ }).trim();
1345
+ commitsAhead = parseInt(count, 10) || 0;
1346
+ }
1347
+ catch {
1348
+ // Branch may not have diverged yet
1349
+ }
1350
+ res.json({
1351
+ success: true,
1352
+ data: { currentBranch, baseBranch, commitsAhead, worktreeMode: session.worktreeMode },
1353
+ });
1354
+ }
1355
+ catch (error) {
1356
+ const errorMsg = error instanceof Error ? error.message : String(error);
1357
+ res.status(500).json({ success: false, error: errorMsg });
1358
+ }
1359
+ });
1360
+ // Get files changed in current branch vs base branch (main/master)
1361
+ terminalRouter.get('/sessions/:id/branch-changed-files', (req, res) => {
1362
+ try {
1363
+ const { id } = req.params;
1364
+ const baseBranch = req.query.base || 'main';
1365
+ const requestedRepoId = req.query.repoId;
1366
+ const session = terminalSessionManager.getSession(id);
1367
+ if (!session) {
1368
+ res.status(404).json({ success: false, error: 'Session not found' });
1369
+ return;
1370
+ }
1371
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1372
+ if (!resolved) {
1373
+ res.status(404).json({ success: false, error: 'Repository not found' });
1374
+ return;
1375
+ }
1376
+ const { repo } = resolved;
1377
+ const workingDir = getWorkingDir(session, repo);
1378
+ const files = [];
1379
+ try {
1380
+ // Get files changed between base branch and HEAD
1381
+ // Using merge-base to find common ancestor
1382
+ const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
1383
+ cwd: workingDir,
1384
+ encoding: 'utf-8',
1385
+ timeout: 5000,
1386
+ }).trim();
1387
+ // Use -M to detect renames
1388
+ const diffOutput = execSync(`git diff -M --name-status ${mergeBase} HEAD`, {
1389
+ cwd: workingDir,
1390
+ encoding: 'utf-8',
1391
+ timeout: 10000,
1392
+ });
1393
+ const lines = diffOutput.split('\n').filter(line => line.trim());
1394
+ for (const line of lines) {
1395
+ const parts = line.split('\t');
1396
+ const statusCode = parts[0];
1397
+ let fileStatus = 'modified';
1398
+ if (statusCode === 'A')
1399
+ fileStatus = 'added';
1400
+ else if (statusCode === 'D')
1401
+ fileStatus = 'deleted';
1402
+ else if (statusCode === 'M')
1403
+ fileStatus = 'modified';
1404
+ else if (statusCode.startsWith('R'))
1405
+ fileStatus = 'renamed';
1406
+ // For renames (R###), format is: R###\toldPath\tnewPath
1407
+ if (statusCode.startsWith('R') && parts.length >= 3) {
1408
+ const oldPath = parts[1];
1409
+ const newPath = parts[2];
1410
+ if (newPath) {
1411
+ files.push({ path: newPath, status: fileStatus, oldPath });
1412
+ }
1413
+ }
1414
+ else if (parts[1]) {
1415
+ files.push({ path: parts[1], status: fileStatus });
1416
+ }
1417
+ }
1418
+ }
1419
+ catch {
1420
+ // Ignore errors (e.g., no common ancestor)
1421
+ }
1422
+ res.json({
1423
+ success: true,
1424
+ data: { files, baseBranch },
1425
+ });
1426
+ }
1427
+ catch (error) {
1428
+ const errorMsg = error instanceof Error ? error.message : String(error);
1429
+ res.status(500).json({ success: false, error: errorMsg });
1430
+ }
1431
+ });
1432
+ // Get diff for a file vs base branch
1433
+ terminalRouter.post('/sessions/:id/branch-file-diff', (req, res) => {
1434
+ try {
1435
+ const { id } = req.params;
1436
+ const { filePath, baseBranch = 'main', repoId: requestedRepoId } = req.body;
1437
+ const session = terminalSessionManager.getSession(id);
1438
+ if (!session) {
1439
+ res.status(404).json({ success: false, error: 'Session not found' });
1440
+ return;
1441
+ }
1442
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1443
+ if (!resolved) {
1444
+ res.status(404).json({ success: false, error: 'Repository not found' });
1445
+ return;
1446
+ }
1447
+ const { repo } = resolved;
1448
+ const workingDir = getWorkingDir(session, repo);
1449
+ let diff = '';
1450
+ try {
1451
+ // Get merge base for accurate diff
1452
+ const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
1453
+ cwd: workingDir,
1454
+ encoding: 'utf-8',
1455
+ timeout: 5000,
1456
+ }).trim();
1457
+ if (filePath) {
1458
+ // Diff for specific file
1459
+ diff = execSync(`git diff ${mergeBase} HEAD -- "${filePath}"`, {
1460
+ cwd: workingDir,
1461
+ encoding: 'utf-8',
1462
+ timeout: 10000,
1463
+ });
1464
+ }
1465
+ else {
1466
+ // Diff for all changed files
1467
+ diff = execSync(`git diff ${mergeBase} HEAD`, {
1468
+ cwd: workingDir,
1469
+ encoding: 'utf-8',
1470
+ timeout: 30000,
1471
+ });
1472
+ }
1473
+ }
1474
+ catch {
1475
+ // No changes or git error
1476
+ diff = '';
1477
+ }
1478
+ res.json({
1479
+ success: true,
1480
+ data: { diff },
1481
+ });
1482
+ }
1483
+ catch (error) {
1484
+ const errorMsg = error instanceof Error ? error.message : String(error);
1485
+ res.status(500).json({ success: false, error: errorMsg });
1486
+ }
1487
+ });
1488
+ // Get detailed working tree status (staged vs unstaged)
1489
+ terminalRouter.get('/sessions/:id/working-status', (req, res) => {
1490
+ try {
1491
+ const { id } = req.params;
1492
+ const requestedRepoId = req.query.repoId;
1493
+ const session = terminalSessionManager.getSession(id);
1494
+ if (!session) {
1495
+ res.status(404).json({ success: false, error: 'Session not found' });
1496
+ return;
1497
+ }
1498
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1499
+ if (!resolved) {
1500
+ res.status(404).json({ success: false, error: 'Repository not found' });
1501
+ return;
1502
+ }
1503
+ const { repo } = resolved;
1504
+ const workingDir = getWorkingDir(session, repo);
1505
+ const staged = [];
1506
+ const unstaged = [];
1507
+ try {
1508
+ // Use -M to detect renames, -unormal to show untracked files/dirs
1509
+ const status = execSync('git status --porcelain -M -unormal', {
1510
+ cwd: workingDir,
1511
+ encoding: 'utf-8',
1512
+ timeout: 10000,
1513
+ });
1514
+ const lines = status.split('\n').filter(line => line.trim());
1515
+ for (const line of lines) {
1516
+ const indexStatus = line[0]; // Staged status
1517
+ const workTreeStatus = line[1]; // Unstaged status
1518
+ let filePath = line.slice(3);
1519
+ // Handle renamed files: "old-path -> new-path"
1520
+ let oldPath = null;
1521
+ if (filePath.includes(' -> ')) {
1522
+ const parts = filePath.split(' -> ');
1523
+ oldPath = parts[0];
1524
+ filePath = parts[1]; // Use the new path as the main path
1525
+ }
1526
+ // Staged changes (index)
1527
+ if (indexStatus !== ' ' && indexStatus !== '?') {
1528
+ let fileStatus = 'modified';
1529
+ if (indexStatus === 'A')
1530
+ fileStatus = 'added';
1531
+ else if (indexStatus === 'D')
1532
+ fileStatus = 'deleted';
1533
+ else if (indexStatus === 'M')
1534
+ fileStatus = 'modified';
1535
+ else if (indexStatus === 'R')
1536
+ fileStatus = 'renamed';
1537
+ if (indexStatus === 'R' && oldPath) {
1538
+ // For renames, store oldPath separately
1539
+ staged.push({ path: filePath, status: fileStatus, oldPath });
1540
+ }
1541
+ else {
1542
+ staged.push({ path: filePath, status: fileStatus });
1543
+ }
1544
+ }
1545
+ // Unstaged changes (work tree)
1546
+ if (workTreeStatus === 'M' || workTreeStatus === 'D') {
1547
+ const fileStatus = workTreeStatus === 'D' ? 'deleted' : 'modified';
1548
+ unstaged.push({ path: filePath, status: fileStatus });
1549
+ }
1550
+ // Skip untracked from git status - we'll get them from ls-files for individual files
1551
+ }
1552
+ // Get untracked files using ls-files (shows individual files, not just directories)
1553
+ try {
1554
+ const untrackedOutput = execSync('git ls-files --others --exclude-standard', {
1555
+ cwd: workingDir,
1556
+ encoding: 'utf-8',
1557
+ timeout: 10000,
1558
+ });
1559
+ const untrackedFiles = untrackedOutput.split('\n').filter(f => f.trim());
1560
+ for (const file of untrackedFiles) {
1561
+ unstaged.push({ path: file, status: 'untracked' });
1562
+ }
1563
+ }
1564
+ catch {
1565
+ // Ignore ls-files errors
1566
+ }
1567
+ }
1568
+ catch {
1569
+ // Ignore errors
1570
+ }
1571
+ res.json({
1572
+ success: true,
1573
+ data: { staged, unstaged },
1574
+ });
1575
+ }
1576
+ catch (error) {
1577
+ const errorMsg = error instanceof Error ? error.message : String(error);
1578
+ res.status(500).json({ success: false, error: errorMsg });
1579
+ }
1580
+ });
1581
+ // Stage files (git add)
1582
+ terminalRouter.post('/sessions/:id/git-stage', (req, res) => {
1583
+ try {
1584
+ const { id } = req.params;
1585
+ const { files, repoId: requestedRepoId } = req.body; // Array of file paths, or empty for all; repoId for multi-repo
1586
+ const session = terminalSessionManager.getSession(id);
1587
+ if (!session) {
1588
+ res.status(404).json({ success: false, error: 'Session not found' });
1589
+ return;
1590
+ }
1591
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1592
+ if (!resolved) {
1593
+ res.status(404).json({ success: false, error: 'Repository not found' });
1594
+ return;
1595
+ }
1596
+ const { repo } = resolved;
1597
+ const workingDir = getWorkingDir(session, repo);
1598
+ try {
1599
+ if (files && files.length > 0) {
1600
+ // Stage specific files - try to stage all at once first (more efficient)
1601
+ // Escape special characters and handle paths properly
1602
+ const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
1603
+ try {
1604
+ execSync(`git add -- ${escapedFiles}`, {
1605
+ cwd: workingDir,
1606
+ encoding: 'utf-8',
1607
+ timeout: 10000,
1608
+ });
1609
+ }
1610
+ catch {
1611
+ // If batch add fails, try adding files individually and collect errors
1612
+ const errors = [];
1613
+ const succeeded = [];
1614
+ for (const file of files) {
1615
+ try {
1616
+ execSync(`git add -- "${file.replace(/"/g, '\\"')}"`, {
1617
+ cwd: workingDir,
1618
+ encoding: 'utf-8',
1619
+ timeout: 5000,
1620
+ });
1621
+ succeeded.push(file);
1622
+ }
1623
+ catch (fileErr) {
1624
+ const errMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
1625
+ errors.push(`${file}: ${errMsg}`);
1626
+ }
1627
+ }
1628
+ if (errors.length > 0 && succeeded.length === 0) {
1629
+ // All files failed
1630
+ res.status(400).json({
1631
+ success: false,
1632
+ error: `Failed to stage files:\n${errors.join('\n')}`
1633
+ });
1634
+ return;
1635
+ }
1636
+ else if (errors.length > 0) {
1637
+ // Some files failed, some succeeded
1638
+ res.json({
1639
+ success: true,
1640
+ data: {
1641
+ message: `Staged ${succeeded.length} files, ${errors.length} failed`,
1642
+ warnings: errors
1643
+ }
1644
+ });
1645
+ return;
1646
+ }
1647
+ }
1648
+ }
1649
+ else {
1650
+ // Stage all
1651
+ execSync('git add -A', {
1652
+ cwd: workingDir,
1653
+ encoding: 'utf-8',
1654
+ timeout: 5000,
1655
+ });
1656
+ }
1657
+ }
1658
+ catch (err) {
1659
+ const errorMsg = err instanceof Error ? err.message : String(err);
1660
+ res.status(400).json({ success: false, error: `Failed to stage: ${errorMsg}` });
1661
+ return;
1662
+ }
1663
+ res.json({ success: true, data: { message: 'Files staged' } });
1664
+ }
1665
+ catch (error) {
1666
+ const errorMsg = error instanceof Error ? error.message : String(error);
1667
+ res.status(500).json({ success: false, error: errorMsg });
1668
+ }
1669
+ });
1670
+ // Unstage files (git reset)
1671
+ terminalRouter.post('/sessions/:id/git-unstage', (req, res) => {
1672
+ try {
1673
+ const { id } = req.params;
1674
+ const { files, repoId: requestedRepoId } = req.body; // Array of file paths, or empty for all; repoId for multi-repo
1675
+ const session = terminalSessionManager.getSession(id);
1676
+ if (!session) {
1677
+ res.status(404).json({ success: false, error: 'Session not found' });
1678
+ return;
1679
+ }
1680
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1681
+ if (!resolved) {
1682
+ res.status(404).json({ success: false, error: 'Repository not found' });
1683
+ return;
1684
+ }
1685
+ const { repo } = resolved;
1686
+ const workingDir = getWorkingDir(session, repo);
1687
+ try {
1688
+ if (files && files.length > 0) {
1689
+ // Unstage specific files - try to unstage all at once first
1690
+ const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
1691
+ try {
1692
+ execSync(`git reset HEAD -- ${escapedFiles}`, {
1693
+ cwd: workingDir,
1694
+ encoding: 'utf-8',
1695
+ timeout: 10000,
1696
+ });
1697
+ }
1698
+ catch {
1699
+ // If batch reset fails, try individually and collect errors
1700
+ const errors = [];
1701
+ const succeeded = [];
1702
+ for (const file of files) {
1703
+ try {
1704
+ execSync(`git reset HEAD -- "${file.replace(/"/g, '\\"')}"`, {
1705
+ cwd: workingDir,
1706
+ encoding: 'utf-8',
1707
+ timeout: 5000,
1708
+ });
1709
+ succeeded.push(file);
1710
+ }
1711
+ catch (fileErr) {
1712
+ const errMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
1713
+ errors.push(`${file}: ${errMsg}`);
1714
+ }
1715
+ }
1716
+ if (errors.length > 0 && succeeded.length === 0) {
1717
+ res.status(400).json({
1718
+ success: false,
1719
+ error: `Failed to unstage files:\n${errors.join('\n')}`
1720
+ });
1721
+ return;
1722
+ }
1723
+ else if (errors.length > 0) {
1724
+ res.json({
1725
+ success: true,
1726
+ data: {
1727
+ message: `Unstaged ${succeeded.length} files, ${errors.length} failed`,
1728
+ warnings: errors
1729
+ }
1730
+ });
1731
+ return;
1732
+ }
1733
+ }
1734
+ }
1735
+ else {
1736
+ // Unstage all
1737
+ execSync('git reset HEAD', {
1738
+ cwd: workingDir,
1739
+ encoding: 'utf-8',
1740
+ timeout: 5000,
1741
+ });
1742
+ }
1743
+ }
1744
+ catch (err) {
1745
+ const errorMsg = err instanceof Error ? err.message : String(err);
1746
+ res.status(400).json({ success: false, error: `Failed to unstage: ${errorMsg}` });
1747
+ return;
1748
+ }
1749
+ res.json({ success: true, data: { message: 'Files unstaged' } });
1750
+ }
1751
+ catch (error) {
1752
+ const errorMsg = error instanceof Error ? error.message : String(error);
1753
+ res.status(500).json({ success: false, error: errorMsg });
1754
+ }
1755
+ });
1756
+ // Discard changes (git restore / git checkout)
1757
+ terminalRouter.post('/sessions/:id/git-discard', (req, res) => {
1758
+ try {
1759
+ const { id } = req.params;
1760
+ const { files, repoId: requestedRepoId } = req.body; // Array of file paths (required); repoId for multi-repo
1761
+ if (!files || files.length === 0) {
1762
+ res.status(400).json({ success: false, error: 'Files are required' });
1763
+ return;
1764
+ }
1765
+ const session = terminalSessionManager.getSession(id);
1766
+ if (!session) {
1767
+ res.status(404).json({ success: false, error: 'Session not found' });
1768
+ return;
1769
+ }
1770
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1771
+ if (!resolved) {
1772
+ res.status(404).json({ success: false, error: 'Repository not found' });
1773
+ return;
1774
+ }
1775
+ const { repo } = resolved;
1776
+ const workingDir = getWorkingDir(session, repo);
1777
+ try {
1778
+ for (const file of files) {
1779
+ // Try git restore first (newer), fall back to checkout
1780
+ try {
1781
+ execSync(`git restore "${file}"`, {
1782
+ cwd: workingDir,
1783
+ encoding: 'utf-8',
1784
+ timeout: 5000,
1785
+ });
1786
+ }
1787
+ catch {
1788
+ execSync(`git checkout -- "${file}"`, {
1789
+ cwd: workingDir,
1790
+ encoding: 'utf-8',
1791
+ timeout: 5000,
1792
+ });
1793
+ }
1794
+ }
1795
+ }
1796
+ catch (err) {
1797
+ const errorMsg = err instanceof Error ? err.message : String(err);
1798
+ res.status(400).json({ success: false, error: `Failed to discard: ${errorMsg}` });
1799
+ return;
1800
+ }
1801
+ res.json({ success: true, data: { message: 'Changes discarded' } });
1802
+ }
1803
+ catch (error) {
1804
+ const errorMsg = error instanceof Error ? error.message : String(error);
1805
+ res.status(500).json({ success: false, error: errorMsg });
1806
+ }
1807
+ });
1808
+ // Delete untracked file
1809
+ terminalRouter.post('/sessions/:id/git-delete-untracked', (req, res) => {
1810
+ try {
1811
+ const { id } = req.params;
1812
+ const { file, repoId: requestedRepoId } = req.body;
1813
+ if (!file) {
1814
+ res.status(400).json({ success: false, error: 'File path is required' });
1815
+ return;
1816
+ }
1817
+ const session = terminalSessionManager.getSession(id);
1818
+ if (!session) {
1819
+ res.status(404).json({ success: false, error: 'Session not found' });
1820
+ return;
1821
+ }
1822
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1823
+ if (!resolved) {
1824
+ res.status(404).json({ success: false, error: 'Repository not found' });
1825
+ return;
1826
+ }
1827
+ const { repo } = resolved;
1828
+ const workingDir = getWorkingDir(session, repo);
1829
+ const fullPath = join(workingDir, file);
1830
+ // Security check - ensure path is within working dir
1831
+ if (!fullPath.startsWith(workingDir)) {
1832
+ res.status(400).json({ success: false, error: 'Invalid file path' });
1833
+ return;
1834
+ }
1835
+ try {
1836
+ // Use rmSync for Windows compatibility (handles both files and directories)
1837
+ rmSync(fullPath, { recursive: true, force: true });
1838
+ }
1839
+ catch (err) {
1840
+ const errorMsg = err instanceof Error ? err.message : String(err);
1841
+ res.status(400).json({ success: false, error: `Failed to delete: ${errorMsg}` });
1842
+ return;
1843
+ }
1844
+ res.json({ success: true, data: { message: 'File deleted' } });
1845
+ }
1846
+ catch (error) {
1847
+ const errorMsg = error instanceof Error ? error.message : String(error);
1848
+ res.status(500).json({ success: false, error: errorMsg });
1849
+ }
1850
+ });
1851
+ // Commit staged changes
1852
+ terminalRouter.post('/sessions/:id/git-commit', (req, res) => {
1853
+ try {
1854
+ const { id } = req.params;
1855
+ const { message, repoId: requestedRepoId } = req.body;
1856
+ if (!message || typeof message !== 'string' || !message.trim()) {
1857
+ res.status(400).json({ success: false, error: 'Commit message is required' });
1858
+ return;
1859
+ }
1860
+ const session = terminalSessionManager.getSession(id);
1861
+ if (!session) {
1862
+ res.status(404).json({ success: false, error: 'Session not found' });
1863
+ return;
1864
+ }
1865
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1866
+ if (!resolved) {
1867
+ res.status(404).json({ success: false, error: 'Repository not found' });
1868
+ return;
1869
+ }
1870
+ const { repo } = resolved;
1871
+ const workingDir = getWorkingDir(session, repo);
1872
+ let commitHash = '';
1873
+ try {
1874
+ // Escape message for shell
1875
+ const escapedMessage = message.replace(/"/g, '\\"');
1876
+ execSync(`git commit -m "${escapedMessage}"`, {
1877
+ cwd: workingDir,
1878
+ encoding: 'utf-8',
1879
+ timeout: 30000,
1880
+ });
1881
+ // Get the commit hash
1882
+ commitHash = execSync('git rev-parse --short HEAD', {
1883
+ cwd: workingDir,
1884
+ encoding: 'utf-8',
1885
+ timeout: 5000,
1886
+ }).trim();
1887
+ }
1888
+ catch (err) {
1889
+ const errorMsg = err instanceof Error ? err.message : String(err);
1890
+ res.status(400).json({ success: false, error: `Failed to commit: ${errorMsg}` });
1891
+ return;
1892
+ }
1893
+ res.json({ success: true, data: { message: 'Committed', commitHash } });
1894
+ }
1895
+ catch (error) {
1896
+ const errorMsg = error instanceof Error ? error.message : String(error);
1897
+ res.status(500).json({ success: false, error: errorMsg });
1898
+ }
1899
+ });
1900
+ // Push to remote
1901
+ terminalRouter.post('/sessions/:id/git-push', (req, res) => {
1902
+ let cleanup = null;
1903
+ try {
1904
+ const { id } = req.params;
1905
+ const { repoId: requestedRepoId } = req.body || {};
1906
+ const session = terminalSessionManager.getSession(id);
1907
+ if (!session) {
1908
+ res.status(404).json({ success: false, error: 'Session not found' });
1909
+ return;
1910
+ }
1911
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1912
+ if (!resolved) {
1913
+ res.status(404).json({ success: false, error: 'Repository not found' });
1914
+ return;
1915
+ }
1916
+ const { repo } = resolved;
1917
+ const workingDir = getWorkingDir(session, repo);
1918
+ // Get exec options with OAuth credentials if available
1919
+ const { options: execOptions, cleanup: cleanupFn } = getGitExecOptions(workingDir, 120000);
1920
+ cleanup = cleanupFn;
1921
+ try {
1922
+ // Get current branch
1923
+ const branch = execSync('git branch --show-current', {
1924
+ cwd: workingDir,
1925
+ encoding: 'utf-8',
1926
+ timeout: 5000,
1927
+ }).trim();
1928
+ // Push with upstream tracking using OAuth credentials
1929
+ execSync(`git push -u origin ${branch}`, execOptions);
1930
+ }
1931
+ catch (err) {
1932
+ const errorMsg = err instanceof Error ? err.message : String(err);
1933
+ res.status(400).json({ success: false, error: `Failed to push: ${errorMsg}` });
1934
+ return;
1935
+ }
1936
+ finally {
1937
+ if (cleanup)
1938
+ cleanup();
1939
+ }
1940
+ res.json({ success: true, data: { message: 'Pushed to remote' } });
1941
+ }
1942
+ catch (error) {
1943
+ if (cleanup)
1944
+ cleanup();
1945
+ const errorMsg = error instanceof Error ? error.message : String(error);
1946
+ res.status(500).json({ success: false, error: errorMsg });
1947
+ }
1948
+ });
1949
+ // Checkout/switch to a different branch
1950
+ terminalRouter.post('/sessions/:id/git-checkout', (req, res) => {
1951
+ try {
1952
+ const { id } = req.params;
1953
+ const { branch, repoId: requestedRepoId } = req.body;
1954
+ if (!branch || typeof branch !== 'string') {
1955
+ res.status(400).json({ success: false, error: 'Branch name is required' });
1956
+ return;
1957
+ }
1958
+ const session = terminalSessionManager.getSession(id);
1959
+ if (!session) {
1960
+ res.status(404).json({ success: false, error: 'Session not found' });
1961
+ return;
1962
+ }
1963
+ const resolved = resolveSessionRepo(session, requestedRepoId);
1964
+ if (!resolved) {
1965
+ res.status(404).json({ success: false, error: 'Repository not found' });
1966
+ return;
1967
+ }
1968
+ const { repo } = resolved;
1969
+ const workingDir = getWorkingDir(session, repo);
1970
+ try {
1971
+ // Check for uncommitted changes first
1972
+ const status = execSync('git status --porcelain', {
1973
+ cwd: workingDir,
1974
+ encoding: 'utf-8',
1975
+ timeout: 5000,
1976
+ });
1977
+ if (status.trim()) {
1978
+ res.status(400).json({
1979
+ success: false,
1980
+ error: 'Cannot switch branches with uncommitted changes. Please commit or stash your changes first.',
1981
+ });
1982
+ return;
1983
+ }
1984
+ // Perform the checkout
1985
+ execSync(`git checkout "${branch}"`, {
1986
+ cwd: workingDir,
1987
+ encoding: 'utf-8',
1988
+ timeout: 30000,
1989
+ });
1990
+ }
1991
+ catch (err) {
1992
+ const errorMsg = err instanceof Error ? err.message : String(err);
1993
+ res.status(400).json({ success: false, error: `Failed to checkout: ${errorMsg}` });
1994
+ return;
1995
+ }
1996
+ res.json({ success: true, data: { message: `Switched to branch ${branch}`, branch } });
1997
+ }
1998
+ catch (error) {
1999
+ const errorMsg = error instanceof Error ? error.message : String(error);
2000
+ res.status(500).json({ success: false, error: errorMsg });
2001
+ }
2002
+ });
2003
+ // Create a pull request (GitHub) or merge request (GitLab)
2004
+ terminalRouter.post('/sessions/:id/create-pr', async (req, res) => {
2005
+ try {
2006
+ const { id } = req.params;
2007
+ const { title, body, repoId: requestedRepoId, targetBranch } = req.body;
2008
+ if (!title || typeof title !== 'string' || !title.trim()) {
2009
+ res.status(400).json({ success: false, error: 'PR title is required' });
2010
+ return;
2011
+ }
2012
+ const session = terminalSessionManager.getSession(id);
2013
+ if (!session) {
2014
+ res.status(404).json({ success: false, error: 'Session not found' });
2015
+ return;
2016
+ }
2017
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2018
+ if (!resolved) {
2019
+ res.status(404).json({ success: false, error: 'Repository not found' });
2020
+ return;
2021
+ }
2022
+ const { repo } = resolved;
2023
+ const workingDir = getWorkingDir(session, repo);
2024
+ // Debug logging for PR creation
2025
+ console.log(`[create-pr] repo.path: ${repo.path}`);
2026
+ console.log(`[create-pr] workingDir: ${workingDir}`);
2027
+ console.log(`[create-pr] session.worktreeMode: ${session.worktreeMode}`);
2028
+ // Get current branch
2029
+ let currentBranch;
2030
+ try {
2031
+ currentBranch = execSync('git branch --show-current', {
2032
+ cwd: workingDir,
2033
+ encoding: 'utf-8',
2034
+ timeout: 5000,
2035
+ }).trim();
2036
+ }
2037
+ catch (err) {
2038
+ res.status(400).json({ success: false, error: 'Failed to get current branch' });
2039
+ return;
2040
+ }
2041
+ // Get remote URL to detect platform (GitHub vs GitLab)
2042
+ let remoteUrl;
2043
+ try {
2044
+ remoteUrl = execSync('git remote get-url origin', {
2045
+ cwd: workingDir,
2046
+ encoding: 'utf-8',
2047
+ timeout: 5000,
2048
+ }).trim();
2049
+ }
2050
+ catch (err) {
2051
+ res.status(400).json({ success: false, error: 'No remote origin configured' });
2052
+ return;
2053
+ }
2054
+ // Detect platform and create PR/MR
2055
+ const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab.');
2056
+ const isGitHub = remoteUrl.includes('github.com') || remoteUrl.includes('github.');
2057
+ // Pass repo.path for workspace lookup (needed for worktree mode where workingDir is a temp path)
2058
+ if (isGitLab) {
2059
+ const result = await gitlabIntegration.createMR(workingDir, currentBranch, title.trim(), body || '', repo.path, targetBranch);
2060
+ if (result.success) {
2061
+ res.json({
2062
+ success: true,
2063
+ data: {
2064
+ url: result.mrUrl,
2065
+ type: 'merge_request',
2066
+ platform: 'gitlab',
2067
+ },
2068
+ });
2069
+ }
2070
+ else {
2071
+ res.status(400).json({ success: false, error: result.error });
2072
+ }
2073
+ }
2074
+ else if (isGitHub) {
2075
+ const result = await githubIntegration.createPR(workingDir, currentBranch, title.trim(), body || '', repo.path, targetBranch);
2076
+ if (result.success) {
2077
+ res.json({
2078
+ success: true,
2079
+ data: {
2080
+ url: result.prUrl,
2081
+ type: 'pull_request',
2082
+ platform: 'github',
2083
+ },
2084
+ });
2085
+ }
2086
+ else {
2087
+ res.status(400).json({ success: false, error: result.error });
2088
+ }
2089
+ }
2090
+ else {
2091
+ res.status(400).json({
2092
+ success: false,
2093
+ error: 'Could not detect platform from remote URL. Only GitHub and GitLab are supported.',
2094
+ });
2095
+ }
2096
+ }
2097
+ catch (error) {
2098
+ const errorMsg = error instanceof Error ? error.message : String(error);
2099
+ res.status(500).json({ success: false, error: errorMsg });
2100
+ }
2101
+ });
2102
+ // Generate PR title and description using AI
2103
+ terminalRouter.post('/sessions/:id/generate-pr-content', async (req, res) => {
2104
+ try {
2105
+ const { id } = req.params;
2106
+ const { repoId: requestedRepoId, targetBranch: requestedTargetBranch } = req.body || {};
2107
+ const session = terminalSessionManager.getSession(id);
2108
+ if (!session) {
2109
+ res.status(404).json({ success: false, error: 'Session not found' });
2110
+ return;
2111
+ }
2112
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2113
+ if (!resolved) {
2114
+ res.status(404).json({ success: false, error: 'Repository not found' });
2115
+ return;
2116
+ }
2117
+ const { repo } = resolved;
2118
+ const workingDir = getWorkingDir(session, repo);
2119
+ // Get current branch and base branch
2120
+ let currentBranch;
2121
+ let baseBranch = requestedTargetBranch || 'main';
2122
+ try {
2123
+ currentBranch = execSync('git branch --show-current', {
2124
+ cwd: workingDir,
2125
+ encoding: 'utf-8',
2126
+ timeout: 5000,
2127
+ }).trim();
2128
+ // Only auto-detect base branch if not provided
2129
+ if (!requestedTargetBranch) {
2130
+ const branches = execSync('git branch -a', {
2131
+ cwd: workingDir,
2132
+ encoding: 'utf-8',
2133
+ timeout: 5000,
2134
+ });
2135
+ baseBranch = branches.includes('remotes/origin/main') ? 'main' : 'master';
2136
+ }
2137
+ }
2138
+ catch {
2139
+ res.status(400).json({ success: false, error: 'Failed to get branch info' });
2140
+ return;
2141
+ }
2142
+ // Get commit messages in this branch
2143
+ let commitMessages = '';
2144
+ try {
2145
+ commitMessages = execSync(`git log --format="%s" ${baseBranch}..HEAD`, {
2146
+ cwd: workingDir,
2147
+ encoding: 'utf-8',
2148
+ timeout: 10000,
2149
+ }).trim();
2150
+ }
2151
+ catch {
2152
+ commitMessages = '';
2153
+ }
2154
+ if (!commitMessages) {
2155
+ res.status(400).json({ success: false, error: 'No commits to summarize' });
2156
+ return;
2157
+ }
2158
+ // Get file changes summary
2159
+ let fileChanges = '';
2160
+ try {
2161
+ const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
2162
+ cwd: workingDir,
2163
+ encoding: 'utf-8',
2164
+ timeout: 5000,
2165
+ }).trim();
2166
+ fileChanges = execSync(`git diff --stat ${mergeBase} HEAD`, {
2167
+ cwd: workingDir,
2168
+ encoding: 'utf-8',
2169
+ timeout: 10000,
2170
+ }).trim();
2171
+ }
2172
+ catch {
2173
+ fileChanges = '';
2174
+ }
2175
+ // Try to read CLAUDE.md for project conventions
2176
+ let claudeMd = '';
2177
+ const claudeMdPath = join(workingDir, 'CLAUDE.md');
2178
+ if (existsSync(claudeMdPath)) {
2179
+ try {
2180
+ claudeMd = readFileSync(claudeMdPath, 'utf-8');
2181
+ // Limit to first 2000 chars to avoid token limits
2182
+ if (claudeMd.length > 2000) {
2183
+ claudeMd = claudeMd.substring(0, 2000) + '\n...(truncated)';
2184
+ }
2185
+ }
2186
+ catch {
2187
+ claudeMd = '';
2188
+ }
2189
+ }
2190
+ // Build prompt
2191
+ const prompt = `Generate a Pull Request title and description for the following changes.
2192
+
2193
+ Branch: ${currentBranch} → ${baseBranch}
2194
+
2195
+ Commits in this branch:
2196
+ ${commitMessages}
2197
+
2198
+ Files changed:
2199
+ ${fileChanges}
2200
+ ${claudeMd ? `
2201
+ Project conventions (from CLAUDE.md):
2202
+ ${claudeMd}
2203
+ ` : ''}
2204
+ Rules:
2205
+ - Title: Max 72 characters, imperative mood (e.g., "Add", "Fix", "Update", "Refactor")
2206
+ - Description: Markdown format with sections: ## Summary (2-3 bullet points), ## Changes (list key changes), ## Test Plan (if applicable)
2207
+ - Follow any PR conventions mentioned in CLAUDE.md
2208
+ - Output format must be exactly:
2209
+ TITLE: <title here>
2210
+ DESCRIPTION:
2211
+ <description here>`;
2212
+ // Call Claude Code CLI
2213
+ const { spawn } = await import('child_process');
2214
+ const claudeProcess = spawn('claude', ['--dangerously-skip-permissions', '-p', '-'], {
2215
+ cwd: workingDir,
2216
+ shell: true,
2217
+ stdio: ['pipe', 'pipe', 'pipe'],
2218
+ });
2219
+ let output = '';
2220
+ let errorOutput = '';
2221
+ claudeProcess.stdout?.on('data', (data) => {
2222
+ output += data.toString();
2223
+ });
2224
+ claudeProcess.stderr?.on('data', (data) => {
2225
+ errorOutput += data.toString();
2226
+ });
2227
+ // Write prompt to stdin
2228
+ claudeProcess.stdin?.write(prompt);
2229
+ claudeProcess.stdin?.end();
2230
+ // Wait for process to complete with timeout
2231
+ const timeoutMs = 60000;
2232
+ const result = await Promise.race([
2233
+ new Promise((resolve) => {
2234
+ claudeProcess.on('close', (code) => {
2235
+ if (code === 0 && output.trim()) {
2236
+ // Parse output - look for TITLE: and DESCRIPTION:
2237
+ const titleMatch = output.match(/TITLE:\s*(.+?)(?:\n|DESCRIPTION:)/s);
2238
+ const descMatch = output.match(/DESCRIPTION:\s*([\s\S]+)$/);
2239
+ const title = titleMatch ? titleMatch[1].trim() : '';
2240
+ const description = descMatch ? descMatch[1].trim() : '';
2241
+ if (title) {
2242
+ resolve({ success: true, title, description });
2243
+ }
2244
+ else {
2245
+ // Fallback: use first line as title, rest as description
2246
+ const lines = output.trim().split('\n');
2247
+ resolve({
2248
+ success: true,
2249
+ title: lines[0].replace(/^(TITLE:|Title:)\s*/i, '').trim(),
2250
+ description: lines.slice(1).join('\n').replace(/^(DESCRIPTION:|Description:)\s*/i, '').trim(),
2251
+ });
2252
+ }
2253
+ }
2254
+ else {
2255
+ resolve({ success: false, error: errorOutput || 'Failed to generate PR content' });
2256
+ }
2257
+ });
2258
+ }),
2259
+ new Promise((resolve) => {
2260
+ setTimeout(() => {
2261
+ claudeProcess.kill();
2262
+ resolve({ success: false, error: 'Timeout generating PR content' });
2263
+ }, timeoutMs);
2264
+ }),
2265
+ ]);
2266
+ if (result.success && 'title' in result) {
2267
+ // Clean up Claude session
2268
+ try {
2269
+ const { homedir } = await import('os');
2270
+ const projectName = workingDir.replace(/:/g, '-').replace(/\\/g, '-').replace(/\//g, '-');
2271
+ const sessionsDir = join(homedir(), '.claude', 'projects', projectName);
2272
+ const now = Date.now();
2273
+ const files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
2274
+ for (const file of files) {
2275
+ const filePath = join(sessionsDir, file);
2276
+ const stat = statSync(filePath);
2277
+ if (now - stat.mtimeMs < 120000) {
2278
+ unlinkSync(filePath);
2279
+ }
2280
+ }
2281
+ }
2282
+ catch {
2283
+ // Ignore cleanup errors
2284
+ }
2285
+ res.json({
2286
+ success: true,
2287
+ data: {
2288
+ title: result.title,
2289
+ description: result.description,
2290
+ },
2291
+ });
2292
+ }
2293
+ else {
2294
+ res.status(500).json({ success: false, error: result.error || 'Failed to generate content' });
2295
+ }
2296
+ }
2297
+ catch (error) {
2298
+ const errorMsg = error instanceof Error ? error.message : String(error);
2299
+ res.status(500).json({ success: false, error: errorMsg });
2300
+ }
2301
+ });
2302
+ // Delete file added in branch (git rm)
2303
+ terminalRouter.post('/sessions/:id/git-rm-branch-file', (req, res) => {
2304
+ try {
2305
+ const { id } = req.params;
2306
+ const { file, baseBranch = 'main', repoId: requestedRepoId } = req.body;
2307
+ if (!file) {
2308
+ res.status(400).json({ success: false, error: 'File path is required' });
2309
+ return;
2310
+ }
2311
+ const session = terminalSessionManager.getSession(id);
2312
+ if (!session) {
2313
+ res.status(404).json({ success: false, error: 'Session not found' });
2314
+ return;
2315
+ }
2316
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2317
+ if (!resolved) {
2318
+ res.status(404).json({ success: false, error: 'Repository not found' });
2319
+ return;
2320
+ }
2321
+ const { repo } = resolved;
2322
+ const workingDir = getWorkingDir(session, repo);
2323
+ try {
2324
+ // Verify file was added in this branch (not in base branch)
2325
+ const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
2326
+ cwd: workingDir,
2327
+ encoding: 'utf-8',
2328
+ timeout: 5000,
2329
+ }).trim();
2330
+ const diffOutput = execSync(`git diff --name-status ${mergeBase} HEAD -- "${file}"`, {
2331
+ cwd: workingDir,
2332
+ encoding: 'utf-8',
2333
+ timeout: 5000,
2334
+ });
2335
+ // Check if file was added (status 'A')
2336
+ if (!diffOutput.trim().startsWith('A')) {
2337
+ res.status(400).json({
2338
+ success: false,
2339
+ error: 'Can only delete files that were added in this branch'
2340
+ });
2341
+ return;
2342
+ }
2343
+ // Remove the file using git rm -f (force needed if file has local modifications)
2344
+ execSync(`git rm -f "${file}"`, {
2345
+ cwd: workingDir,
2346
+ encoding: 'utf-8',
2347
+ timeout: 5000,
2348
+ });
2349
+ }
2350
+ catch (err) {
2351
+ const errorMsg = err instanceof Error ? err.message : String(err);
2352
+ res.status(400).json({ success: false, error: `Failed to delete: ${errorMsg}` });
2353
+ return;
2354
+ }
2355
+ res.json({ success: true, data: { message: 'File removed (staged for deletion)' } });
2356
+ }
2357
+ catch (error) {
2358
+ const errorMsg = error instanceof Error ? error.message : String(error);
2359
+ res.status(500).json({ success: false, error: errorMsg });
2360
+ }
2361
+ });
2362
+ // Generate commit message using Claude
2363
+ terminalRouter.post('/sessions/:id/generate-commit-message', async (req, res) => {
2364
+ try {
2365
+ const { id } = req.params;
2366
+ const { repoId: requestedRepoId } = req.body || {};
2367
+ const session = terminalSessionManager.getSession(id);
2368
+ if (!session) {
2369
+ res.status(404).json({ success: false, error: 'Session not found' });
2370
+ return;
2371
+ }
2372
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2373
+ if (!resolved) {
2374
+ res.status(404).json({ success: false, error: 'Repository not found' });
2375
+ return;
2376
+ }
2377
+ const { repo } = resolved;
2378
+ const workingDir = getWorkingDir(session, repo);
2379
+ // Get staged file statuses (A=added, M=modified, D=deleted, R=renamed)
2380
+ let stagedStatus;
2381
+ try {
2382
+ stagedStatus = execSync('git diff --cached --name-status', {
2383
+ cwd: workingDir,
2384
+ encoding: 'utf-8',
2385
+ timeout: 5000,
2386
+ });
2387
+ }
2388
+ catch {
2389
+ stagedStatus = '';
2390
+ }
2391
+ if (!stagedStatus.trim()) {
2392
+ res.status(400).json({ success: false, error: 'No staged changes to summarize' });
2393
+ return;
2394
+ }
2395
+ // Parse file statuses for summary
2396
+ const lines = stagedStatus.trim().split('\n');
2397
+ const added = lines.filter(l => l.startsWith('A')).length;
2398
+ const modified = lines.filter(l => l.startsWith('M')).length;
2399
+ const deleted = lines.filter(l => l.startsWith('D')).length;
2400
+ const renamed = lines.filter(l => l.startsWith('R')).length;
2401
+ // Build summary
2402
+ const summaryParts = [];
2403
+ if (deleted > 0)
2404
+ summaryParts.push(`${deleted} deleted`);
2405
+ if (added > 0)
2406
+ summaryParts.push(`${added} added`);
2407
+ if (modified > 0)
2408
+ summaryParts.push(`${modified} modified`);
2409
+ if (renamed > 0)
2410
+ summaryParts.push(`${renamed} renamed`);
2411
+ // Pass file list with statuses - simpler and clearer than full diff
2412
+ const prompt = `Generate a commit message for these staged changes:
2413
+
2414
+ Files (${summaryParts.join(', ')}):
2415
+ ${stagedStatus}
2416
+
2417
+ Rules:
2418
+ - Max 72 characters
2419
+ - Imperative mood (e.g., "Remove", "Add", "Update", "Refactor")
2420
+ - If mostly deletions, use "Remove" or "Delete"
2421
+ - Output ONLY the commit message text
2422
+ - No quotes, no explanation`;
2423
+ // Call Claude Code CLI
2424
+ const { spawn } = await import('child_process');
2425
+ // Use --dangerously-skip-permissions to skip prompts, -p for print mode, - for stdin
2426
+ const claudeProcess = spawn('claude', ['--dangerously-skip-permissions', '-p', '-'], {
2427
+ cwd: workingDir,
2428
+ shell: true,
2429
+ stdio: ['pipe', 'pipe', 'pipe'],
2430
+ });
2431
+ let output = '';
2432
+ let errorOutput = '';
2433
+ claudeProcess.stdout?.on('data', (data) => {
2434
+ output += data.toString();
2435
+ });
2436
+ claudeProcess.stderr?.on('data', (data) => {
2437
+ errorOutput += data.toString();
2438
+ });
2439
+ // Write prompt to stdin
2440
+ claudeProcess.stdin?.write(prompt);
2441
+ claudeProcess.stdin?.end();
2442
+ // Wait for process to complete with timeout
2443
+ const timeoutMs = 30000;
2444
+ const result = await Promise.race([
2445
+ new Promise((resolve) => {
2446
+ claudeProcess.on('close', (code) => {
2447
+ if (code === 0 && output.trim()) {
2448
+ // Clean up the output - remove any markdown formatting
2449
+ let message = output.trim();
2450
+ // Remove quotes if wrapped
2451
+ if ((message.startsWith('"') && message.endsWith('"')) ||
2452
+ (message.startsWith("'") && message.endsWith("'"))) {
2453
+ message = message.slice(1, -1);
2454
+ }
2455
+ // Take only the first line if multiple lines
2456
+ message = message.split('\n')[0].trim();
2457
+ resolve({ success: true, message });
2458
+ }
2459
+ else {
2460
+ resolve({ success: false, error: errorOutput || 'Failed to generate commit message' });
2461
+ }
2462
+ });
2463
+ }),
2464
+ new Promise((resolve) => {
2465
+ setTimeout(() => {
2466
+ claudeProcess.kill();
2467
+ resolve({ success: false, error: 'Timeout generating commit message' });
2468
+ }, timeoutMs);
2469
+ }),
2470
+ ]);
2471
+ if (result.success && 'message' in result && result.message) {
2472
+ const generatedMessage = result.message;
2473
+ // Clean up Claude session to avoid polluting /resume history
2474
+ // The session is created in ~/.claude/projects/<project-folder>/
2475
+ try {
2476
+ const { homedir } = await import('os');
2477
+ const { join } = await import('path');
2478
+ const { readdirSync, unlinkSync, statSync } = await import('fs');
2479
+ const projectName = workingDir.replace(/:/g, '-').replace(/\\/g, '-').replace(/\//g, '-');
2480
+ const sessionsDir = join(homedir(), '.claude', 'projects', projectName);
2481
+ // Find and delete the most recent session file (created in last 60 seconds)
2482
+ const now = Date.now();
2483
+ const files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
2484
+ for (const file of files) {
2485
+ const filePath = join(sessionsDir, file);
2486
+ const stat = statSync(filePath);
2487
+ // Delete if created in last 60 seconds (our temp session)
2488
+ if (now - stat.mtimeMs < 60000) {
2489
+ unlinkSync(filePath);
2490
+ }
2491
+ }
2492
+ }
2493
+ catch {
2494
+ // Ignore cleanup errors - session dir might not exist
2495
+ }
2496
+ res.json({ success: true, data: { message: generatedMessage } });
2497
+ }
2498
+ else {
2499
+ res.status(500).json({ success: false, error: result.error || 'Failed to generate message' });
2500
+ }
2501
+ }
2502
+ catch (error) {
2503
+ const errorMsg = error instanceof Error ? error.message : String(error);
2504
+ res.status(500).json({ success: false, error: errorMsg });
2505
+ }
2506
+ });
2507
+ // Get commit log for branch (commits ahead of base branch)
2508
+ terminalRouter.get('/sessions/:id/commit-log', (req, res) => {
2509
+ try {
2510
+ const { id } = req.params;
2511
+ const baseBranch = req.query.base || 'main';
2512
+ const limit = parseInt(req.query.limit) || 20;
2513
+ const before = req.query.before;
2514
+ const requestedRepoId = req.query.repoId;
2515
+ const session = terminalSessionManager.getSession(id);
2516
+ if (!session) {
2517
+ res.status(404).json({ success: false, error: 'Session not found' });
2518
+ return;
2519
+ }
2520
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2521
+ if (!resolved) {
2522
+ res.status(404).json({ success: false, error: 'Repository not found' });
2523
+ return;
2524
+ }
2525
+ const { repo } = resolved;
2526
+ const workingDir = getWorkingDir(session, repo);
2527
+ const commits = [];
2528
+ try {
2529
+ // Get commit log between base branch and HEAD
2530
+ // Format: fullHash|shortHash|subject|authorName|authorEmail|isoDate
2531
+ let logCommand = `git log --format="%H|%h|%s|%an|%ae|%aI" ${baseBranch}..HEAD`;
2532
+ if (before) {
2533
+ logCommand = `git log --format="%H|%h|%s|%an|%ae|%aI" ${baseBranch}..${before}^`;
2534
+ }
2535
+ const logOutput = execSync(logCommand, {
2536
+ cwd: workingDir,
2537
+ encoding: 'utf-8',
2538
+ timeout: 15000,
2539
+ });
2540
+ const lines = logOutput.trim().split('\n').filter(line => line.trim());
2541
+ // Limit the results
2542
+ const limitedLines = lines.slice(0, limit + 1); // Get one extra to check hasMore
2543
+ const hasMore = lines.length > limit;
2544
+ const processLines = limitedLines.slice(0, limit);
2545
+ for (const line of processLines) {
2546
+ const parts = line.split('|');
2547
+ if (parts.length >= 6) {
2548
+ const fullHash = parts[0];
2549
+ const hash = parts[1];
2550
+ const message = parts[2];
2551
+ const author = parts[3];
2552
+ const authorEmail = parts[4];
2553
+ const date = parts[5];
2554
+ // Get file count for this commit
2555
+ let filesCount = 0;
2556
+ try {
2557
+ const countOutput = execSync(`git diff-tree --no-commit-id --name-only -r ${fullHash}`, {
2558
+ cwd: workingDir,
2559
+ encoding: 'utf-8',
2560
+ timeout: 5000,
2561
+ });
2562
+ filesCount = countOutput.trim().split('\n').filter(f => f.trim()).length;
2563
+ }
2564
+ catch {
2565
+ // Ignore errors
2566
+ }
2567
+ commits.push({
2568
+ hash,
2569
+ fullHash,
2570
+ message,
2571
+ author,
2572
+ authorEmail,
2573
+ date,
2574
+ filesCount,
2575
+ });
2576
+ }
2577
+ }
2578
+ res.json({
2579
+ success: true,
2580
+ data: { commits, hasMore },
2581
+ });
2582
+ }
2583
+ catch {
2584
+ // No commits or error - return empty
2585
+ res.json({
2586
+ success: true,
2587
+ data: { commits: [], hasMore: false },
2588
+ });
2589
+ }
2590
+ }
2591
+ catch (error) {
2592
+ const errorMsg = error instanceof Error ? error.message : String(error);
2593
+ res.status(500).json({ success: false, error: errorMsg });
2594
+ }
2595
+ });
2596
+ // Get files changed in a specific commit
2597
+ terminalRouter.get('/sessions/:id/commit-files', (req, res) => {
2598
+ try {
2599
+ const { id } = req.params;
2600
+ const commitHash = req.query.hash;
2601
+ const requestedRepoId = req.query.repoId;
2602
+ if (!commitHash) {
2603
+ res.status(400).json({ success: false, error: 'Commit hash is required' });
2604
+ return;
2605
+ }
2606
+ const session = terminalSessionManager.getSession(id);
2607
+ if (!session) {
2608
+ res.status(404).json({ success: false, error: 'Session not found' });
2609
+ return;
2610
+ }
2611
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2612
+ if (!resolved) {
2613
+ res.status(404).json({ success: false, error: 'Repository not found' });
2614
+ return;
2615
+ }
2616
+ const { repo } = resolved;
2617
+ const workingDir = getWorkingDir(session, repo);
2618
+ const files = [];
2619
+ try {
2620
+ // Get files changed in this commit with rename detection
2621
+ const diffOutput = execSync(`git diff-tree --no-commit-id --name-status -r -M ${commitHash}`, {
2622
+ cwd: workingDir,
2623
+ encoding: 'utf-8',
2624
+ timeout: 10000,
2625
+ });
2626
+ const lines = diffOutput.split('\n').filter(line => line.trim());
2627
+ for (const line of lines) {
2628
+ const parts = line.split('\t');
2629
+ const statusCode = parts[0];
2630
+ let fileStatus = 'modified';
2631
+ if (statusCode === 'A')
2632
+ fileStatus = 'added';
2633
+ else if (statusCode === 'D')
2634
+ fileStatus = 'deleted';
2635
+ else if (statusCode === 'M')
2636
+ fileStatus = 'modified';
2637
+ else if (statusCode.startsWith('R'))
2638
+ fileStatus = 'renamed';
2639
+ // For renames (R###), format is: R###\toldPath\tnewPath
2640
+ if (statusCode.startsWith('R') && parts.length >= 3) {
2641
+ const oldPath = parts[1];
2642
+ const newPath = parts[2];
2643
+ if (newPath) {
2644
+ files.push({ path: newPath, status: fileStatus, oldPath });
2645
+ }
2646
+ }
2647
+ else if (parts[1]) {
2648
+ files.push({ path: parts[1], status: fileStatus });
2649
+ }
2650
+ }
2651
+ }
2652
+ catch {
2653
+ // Ignore errors
2654
+ }
2655
+ res.json({
2656
+ success: true,
2657
+ data: { files },
2658
+ });
2659
+ }
2660
+ catch (error) {
2661
+ const errorMsg = error instanceof Error ? error.message : String(error);
2662
+ res.status(500).json({ success: false, error: errorMsg });
2663
+ }
2664
+ });
2665
+ // Get diff for a specific file in a specific commit
2666
+ terminalRouter.post('/sessions/:id/commit-file-diff', (req, res) => {
2667
+ try {
2668
+ const { id } = req.params;
2669
+ const { commitHash, filePath, repoId: requestedRepoId } = req.body;
2670
+ if (!commitHash || !filePath) {
2671
+ res.status(400).json({ success: false, error: 'commitHash and filePath are required' });
2672
+ return;
2673
+ }
2674
+ const session = terminalSessionManager.getSession(id);
2675
+ if (!session) {
2676
+ res.status(404).json({ success: false, error: 'Session not found' });
2677
+ return;
2678
+ }
2679
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2680
+ if (!resolved) {
2681
+ res.status(404).json({ success: false, error: 'Repository not found' });
2682
+ return;
2683
+ }
2684
+ const { repo } = resolved;
2685
+ const workingDir = getWorkingDir(session, repo);
2686
+ let diff = '';
2687
+ try {
2688
+ // Get the diff for this specific file in this commit
2689
+ // git show shows the diff introduced by a commit
2690
+ diff = execSync(`git show ${commitHash} -- "${filePath}"`, {
2691
+ cwd: workingDir,
2692
+ encoding: 'utf-8',
2693
+ timeout: 10000,
2694
+ });
2695
+ }
2696
+ catch {
2697
+ // No changes or git error
2698
+ diff = '';
2699
+ }
2700
+ res.json({
2701
+ success: true,
2702
+ data: { diff },
2703
+ });
2704
+ }
2705
+ catch (error) {
2706
+ const errorMsg = error instanceof Error ? error.message : String(error);
2707
+ res.status(500).json({ success: false, error: errorMsg });
2708
+ }
2709
+ });
2710
+ // ============================================================================
2711
+ // Ship Changes Endpoints
2712
+ // ============================================================================
2713
+ // Get ship summary (all info needed for the Ship modal)
2714
+ terminalRouter.get('/sessions/:id/ship-summary', (req, res) => {
2715
+ try {
2716
+ const { id } = req.params;
2717
+ const requestedRepoId = req.query.repoId;
2718
+ const session = terminalSessionManager.getSession(id);
2719
+ if (!session) {
2720
+ res.status(404).json({ success: false, error: 'Session not found' });
2721
+ return;
2722
+ }
2723
+ const resolved = resolveSessionRepo(session, requestedRepoId);
2724
+ if (!resolved) {
2725
+ res.status(404).json({ success: false, error: 'Repository not found' });
2726
+ return;
2727
+ }
2728
+ const { repo } = resolved;
2729
+ const workingDir = getWorkingDir(session, repo);
2730
+ // Get current branch
2731
+ let currentBranch = '';
2732
+ let baseBranch = 'main';
2733
+ try {
2734
+ currentBranch = execSync('git branch --show-current', {
2735
+ cwd: workingDir,
2736
+ encoding: 'utf-8',
2737
+ timeout: 5000,
2738
+ }).trim();
2739
+ // Try to find base branch (main or master)
2740
+ const branches = execSync('git branch -a', {
2741
+ cwd: workingDir,
2742
+ encoding: 'utf-8',
2743
+ timeout: 5000,
2744
+ });
2745
+ if (branches.includes('main')) {
2746
+ baseBranch = 'main';
2747
+ }
2748
+ else if (branches.includes('master')) {
2749
+ baseBranch = 'master';
2750
+ }
2751
+ }
2752
+ catch {
2753
+ // Ignore errors
2754
+ }
2755
+ // Check for existing PR/MR for this branch
2756
+ let existingPR = null;
2757
+ if (currentBranch && currentBranch !== baseBranch) {
2758
+ const workspace = workspaceManager.getWorkspaceForRepo(workingDir);
2759
+ const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
2760
+ console.log(`[ship-summary] Checking for existing PR/MR. Branch: ${currentBranch}, baseBranch: ${baseBranch}, platform: ${creds.platform}, hasToken: ${!!creds.token}, workspaceId: ${workspace?.id}`);
2761
+ if (creds.platform === 'github') {
2762
+ // Try gh CLI first
2763
+ try {
2764
+ const prJson = execSync(`gh pr view --json url,number,title,state`, {
2765
+ cwd: workingDir,
2766
+ encoding: 'utf-8',
2767
+ timeout: 10000,
2768
+ stdio: ['pipe', 'pipe', 'pipe'],
2769
+ });
2770
+ const pr = JSON.parse(prJson);
2771
+ if (pr.url) {
2772
+ existingPR = {
2773
+ url: pr.url,
2774
+ number: pr.number,
2775
+ title: pr.title,
2776
+ state: pr.state?.toLowerCase() || 'open',
2777
+ };
2778
+ }
2779
+ }
2780
+ catch {
2781
+ // No PR exists or gh not available
2782
+ }
2783
+ }
2784
+ else if (creds.platform === 'gitlab') {
2785
+ console.log(`[ship-summary] GitLab detected, checking for existing MR on branch: ${currentBranch}`);
2786
+ // Try glab CLI first with correct -F flag
2787
+ let glabWorked = false;
2788
+ try {
2789
+ const mrJson = execSync(`glab mr view -F json`, {
2790
+ cwd: workingDir,
2791
+ encoding: 'utf-8',
2792
+ timeout: 10000,
2793
+ stdio: ['pipe', 'pipe', 'pipe'],
2794
+ });
2795
+ console.log(`[ship-summary] glab mr view output:`, mrJson);
2796
+ const mr = JSON.parse(mrJson);
2797
+ if (mr.web_url) {
2798
+ existingPR = {
2799
+ url: mr.web_url,
2800
+ number: mr.iid,
2801
+ title: mr.title,
2802
+ state: mr.state?.toLowerCase() || 'opened',
2803
+ };
2804
+ glabWorked = true;
2805
+ console.log(`[ship-summary] Found existing MR via glab:`, existingPR);
2806
+ }
2807
+ }
2808
+ catch (glabErr) {
2809
+ // glab not available or no MR - try API fallback
2810
+ const err = glabErr;
2811
+ console.log(`[ship-summary] glab mr view failed:`, err.message || err.stderr || 'unknown error');
2812
+ }
2813
+ // If glab didn't work, try GitLab API with OAuth token
2814
+ if (!glabWorked && creds.token) {
2815
+ console.log(`[ship-summary] Trying GitLab API fallback with token`);
2816
+ // Helper function to make the API call with a given token
2817
+ const checkMRWithToken = (token) => {
2818
+ try {
2819
+ // Get remote URL to determine project path
2820
+ const remoteUrl = execSync('git remote get-url origin', {
2821
+ cwd: workingDir,
2822
+ encoding: 'utf-8',
2823
+ stdio: ['pipe', 'pipe', 'pipe'],
2824
+ }).trim();
2825
+ // Parse GitLab project path from remote URL
2826
+ const match = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
2827
+ if (!match) {
2828
+ return { found: false, error: 'Could not parse GitLab remote URL' };
2829
+ }
2830
+ const projectPath = match[1].replace(/\.git$/, '');
2831
+ const projectPathEncoded = encodeURIComponent(projectPath);
2832
+ const apiUrl = `https://gitlab.com/api/v4/projects/${projectPathEncoded}/merge_requests?source_branch=${encodeURIComponent(currentBranch)}`;
2833
+ console.log(`[ship-summary] API URL: ${apiUrl}`);
2834
+ const tempScriptPath = join(tmpdir(), `gitlab-mr-check-${randomUUID()}.mjs`);
2835
+ const tempResultPath = join(tmpdir(), `gitlab-mr-result-${randomUUID()}.json`);
2836
+ const scriptContent = `
2837
+ import { writeFileSync } from 'fs';
2838
+ try {
2839
+ const response = await fetch(${JSON.stringify(apiUrl)}, {
2840
+ headers: {
2841
+ 'Accept': 'application/json',
2842
+ 'Authorization': 'Bearer ' + ${JSON.stringify(token)},
2843
+ },
2844
+ });
2845
+ const text = await response.text();
2846
+ const status = response.status;
2847
+
2848
+ if (!response.ok) {
2849
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
2850
+ found: false,
2851
+ error: 'HTTP ' + status + ': ' + text.substring(0, 500),
2852
+ status: status
2853
+ }));
2854
+ } else {
2855
+ let mrs;
2856
+ try {
2857
+ mrs = JSON.parse(text);
2858
+ } catch (parseErr) {
2859
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
2860
+ found: false,
2861
+ error: 'Invalid JSON: ' + text.substring(0, 200)
2862
+ }));
2863
+ process.exit(0);
2864
+ }
2865
+
2866
+ if (Array.isArray(mrs) && mrs.length > 0) {
2867
+ const mr = mrs[0];
2868
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
2869
+ found: true,
2870
+ url: mr.web_url,
2871
+ number: mr.iid,
2872
+ title: mr.title,
2873
+ state: mr.state
2874
+ }));
2875
+ } else {
2876
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
2877
+ found: false,
2878
+ debug: 'API returned ' + (Array.isArray(mrs) ? mrs.length + ' results' : typeof mrs),
2879
+ status: status
2880
+ }));
2881
+ }
2882
+ }
2883
+ } catch (e) {
2884
+ writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ found: false, error: e.message }));
2885
+ }
2886
+ `;
2887
+ writeFileSync(tempScriptPath, scriptContent);
2888
+ try {
2889
+ execSync(`node "${tempScriptPath}"`, {
2890
+ encoding: 'utf-8',
2891
+ timeout: 15000,
2892
+ });
2893
+ const resultJson = readFileSync(tempResultPath, 'utf-8');
2894
+ console.log(`[ship-summary] GitLab API result:`, resultJson);
2895
+ return JSON.parse(resultJson);
2896
+ }
2897
+ finally {
2898
+ try {
2899
+ unlinkSync(tempScriptPath);
2900
+ }
2901
+ catch { }
2902
+ try {
2903
+ unlinkSync(tempResultPath);
2904
+ }
2905
+ catch { }
2906
+ }
2907
+ }
2908
+ catch (e) {
2909
+ console.log(`[ship-summary] GitLab API MR check error:`, e);
2910
+ return { found: false, error: String(e) };
2911
+ }
2912
+ };
2913
+ // First attempt with current token
2914
+ let result = checkMRWithToken(creds.token);
2915
+ // If we got a 401, try refreshing the token
2916
+ if (result.status === 401 && workspace) {
2917
+ console.log(`[ship-summary] Got 401, attempting token refresh for workspace ${workspace.id}`);
2918
+ const newToken = tryRefreshGitLabToken(workspace.id);
2919
+ if (newToken) {
2920
+ console.log(`[ship-summary] Token refreshed, retrying API call`);
2921
+ result = checkMRWithToken(newToken);
2922
+ }
2923
+ }
2924
+ if (result.found) {
2925
+ existingPR = {
2926
+ url: result.url,
2927
+ number: result.number,
2928
+ title: result.title,
2929
+ state: result.state?.toLowerCase() || 'opened',
2930
+ };
2931
+ console.log(`[ship-summary] Found existing MR via API:`, existingPR);
2932
+ }
2933
+ else {
2934
+ console.log(`[ship-summary] No MR found via API, error:`, result.error);
2935
+ }
2936
+ }
2937
+ }
2938
+ }
2939
+ // Get unpushed commits count
2940
+ let unpushedCommits = 0;
2941
+ try {
2942
+ const count = execSync(`git rev-list --count origin/${currentBranch}..HEAD`, {
2943
+ cwd: workingDir,
2944
+ encoding: 'utf-8',
2945
+ timeout: 5000,
2946
+ }).trim();
2947
+ unpushedCommits = parseInt(count, 10) || 0;
2948
+ }
2949
+ catch {
2950
+ // Branch may not have upstream or no commits yet
2951
+ try {
2952
+ // Count all commits on current branch
2953
+ unpushedCommits = parseInt(execSync('git rev-list --count HEAD', {
2954
+ cwd: workingDir,
2955
+ encoding: 'utf-8',
2956
+ timeout: 5000,
2957
+ }).trim(), 10) || 0;
2958
+ }
2959
+ catch {
2960
+ unpushedCommits = 0;
2961
+ }
2962
+ }
2963
+ // Get staged and unstaged files
2964
+ let hasStagedChanges = false;
2965
+ let hasUnstagedChanges = false;
2966
+ try {
2967
+ const status = execSync('git status --porcelain', {
2968
+ cwd: workingDir,
2969
+ encoding: 'utf-8',
2970
+ timeout: 5000,
2971
+ });
2972
+ const lines = status.split('\n').filter(line => line.trim());
2973
+ for (const line of lines) {
2974
+ const indexStatus = line[0];
2975
+ const workTreeStatus = line[1];
2976
+ if (indexStatus !== ' ' && indexStatus !== '?') {
2977
+ hasStagedChanges = true;
2978
+ }
2979
+ if (workTreeStatus === 'M' || workTreeStatus === 'D' || indexStatus === '?') {
2980
+ hasUnstagedChanges = true;
2981
+ }
2982
+ }
2983
+ }
2984
+ catch {
2985
+ // Ignore errors
2986
+ }
2987
+ const files = [];
2988
+ let totalInsertions = 0;
2989
+ let totalDeletions = 0;
2990
+ // Show all committed changes on this branch compared to base branch
2991
+ // This includes both pushed and unpushed commits
2992
+ if (currentBranch && currentBranch !== baseBranch) {
2993
+ try {
2994
+ // Get files changed on this branch compared to base branch (main/master)
2995
+ // This shows ALL committed changes, both pushed and unpushed
2996
+ let diffBase = `origin/${baseBranch}`;
2997
+ // Check if origin base branch exists
2998
+ try {
2999
+ execSync(`git rev-parse ${diffBase}`, {
3000
+ cwd: workingDir,
3001
+ encoding: 'utf-8',
3002
+ timeout: 5000,
3003
+ });
3004
+ }
3005
+ catch {
3006
+ // Try local base branch
3007
+ diffBase = baseBranch;
3008
+ try {
3009
+ execSync(`git rev-parse ${diffBase}`, {
3010
+ cwd: workingDir,
3011
+ encoding: 'utf-8',
3012
+ timeout: 5000,
3013
+ });
3014
+ }
3015
+ catch {
3016
+ // No base branch found, skip
3017
+ diffBase = '';
3018
+ }
3019
+ }
3020
+ if (diffBase) {
3021
+ // Get numstat for all commits on this branch vs base
3022
+ const branchNumstat = execSync(`git diff ${diffBase}..HEAD --numstat`, {
3023
+ cwd: workingDir,
3024
+ encoding: 'utf-8',
3025
+ timeout: 10000,
3026
+ });
3027
+ // Get name-status for branch commits
3028
+ const branchStatus = execSync(`git diff ${diffBase}..HEAD --name-status -M`, {
3029
+ cwd: workingDir,
3030
+ encoding: 'utf-8',
3031
+ timeout: 10000,
3032
+ });
3033
+ // Parse numstat
3034
+ const branchNumstatMap = new Map();
3035
+ branchNumstat.split('\n').filter(l => l.trim()).forEach(line => {
3036
+ const parts = line.split('\t');
3037
+ if (parts.length >= 3) {
3038
+ const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
3039
+ const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
3040
+ const path = parts[2];
3041
+ branchNumstatMap.set(path, { insertions: ins, deletions: del });
3042
+ totalInsertions += ins;
3043
+ totalDeletions += del;
3044
+ }
3045
+ });
3046
+ // Parse name-status
3047
+ branchStatus.split('\n').filter(l => l.trim()).forEach(line => {
3048
+ const parts = line.split('\t');
3049
+ const statusCode = parts[0];
3050
+ let status = 'modified';
3051
+ if (statusCode === 'A')
3052
+ status = 'added';
3053
+ else if (statusCode === 'D')
3054
+ status = 'deleted';
3055
+ else if (statusCode.startsWith('R'))
3056
+ status = 'renamed';
3057
+ if (status === 'renamed' && parts.length >= 3) {
3058
+ const oldPath = parts[1];
3059
+ const newPath = parts[2];
3060
+ const stats = branchNumstatMap.get(newPath) || { insertions: 0, deletions: 0 };
3061
+ files.push({
3062
+ path: newPath,
3063
+ insertions: stats.insertions,
3064
+ deletions: stats.deletions,
3065
+ status,
3066
+ oldPath,
3067
+ });
3068
+ }
3069
+ else if (parts[1]) {
3070
+ const stats = branchNumstatMap.get(parts[1]) || { insertions: 0, deletions: 0 };
3071
+ files.push({
3072
+ path: parts[1],
3073
+ insertions: stats.insertions,
3074
+ deletions: stats.deletions,
3075
+ status,
3076
+ });
3077
+ }
3078
+ });
3079
+ }
3080
+ }
3081
+ catch (err) {
3082
+ console.log('[ship-summary] Failed to get branch commits diff:', err);
3083
+ }
3084
+ }
3085
+ else {
3086
+ // No unpushed commits - show uncommitted changes (staged, unstaged, untracked)
3087
+ try {
3088
+ // Get numstat for line counts (staged changes)
3089
+ const stagedNumstat = execSync('git diff --cached --numstat', {
3090
+ cwd: workingDir,
3091
+ encoding: 'utf-8',
3092
+ timeout: 10000,
3093
+ });
3094
+ // Get name-status for status info (staged changes)
3095
+ const stagedStatus = execSync('git diff --cached --name-status -M', {
3096
+ cwd: workingDir,
3097
+ encoding: 'utf-8',
3098
+ timeout: 10000,
3099
+ });
3100
+ // Parse numstat
3101
+ const numstatMap = new Map();
3102
+ stagedNumstat.split('\n').filter(l => l.trim()).forEach(line => {
3103
+ const parts = line.split('\t');
3104
+ if (parts.length >= 3) {
3105
+ const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
3106
+ const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
3107
+ const path = parts[2];
3108
+ numstatMap.set(path, { insertions: ins, deletions: del });
3109
+ totalInsertions += ins;
3110
+ totalDeletions += del;
3111
+ }
3112
+ });
3113
+ // Parse name-status
3114
+ stagedStatus.split('\n').filter(l => l.trim()).forEach(line => {
3115
+ const parts = line.split('\t');
3116
+ const statusCode = parts[0];
3117
+ let status = 'modified';
3118
+ if (statusCode === 'A')
3119
+ status = 'added';
3120
+ else if (statusCode === 'D')
3121
+ status = 'deleted';
3122
+ else if (statusCode.startsWith('R'))
3123
+ status = 'renamed';
3124
+ // For renames, format is: R###\toldPath\tnewPath
3125
+ if (status === 'renamed' && parts.length >= 3) {
3126
+ const oldPath = parts[1];
3127
+ const newPath = parts[2];
3128
+ const stats = numstatMap.get(newPath) || { insertions: 0, deletions: 0 };
3129
+ files.push({
3130
+ path: newPath,
3131
+ insertions: stats.insertions,
3132
+ deletions: stats.deletions,
3133
+ status,
3134
+ oldPath,
3135
+ });
3136
+ }
3137
+ else if (parts[1]) {
3138
+ const stats = numstatMap.get(parts[1]) || { insertions: 0, deletions: 0 };
3139
+ files.push({
3140
+ path: parts[1],
3141
+ insertions: stats.insertions,
3142
+ deletions: stats.deletions,
3143
+ status,
3144
+ });
3145
+ }
3146
+ });
3147
+ // Also add unstaged changes for complete picture
3148
+ const unstagedNumstat = execSync('git diff --numstat', {
3149
+ cwd: workingDir,
3150
+ encoding: 'utf-8',
3151
+ timeout: 10000,
3152
+ });
3153
+ const unstagedStatus = execSync('git diff --name-status -M', {
3154
+ cwd: workingDir,
3155
+ encoding: 'utf-8',
3156
+ timeout: 10000,
3157
+ });
3158
+ // Parse unstaged numstat
3159
+ const unstagedNumstatMap = new Map();
3160
+ unstagedNumstat.split('\n').filter(l => l.trim()).forEach(line => {
3161
+ const parts = line.split('\t');
3162
+ if (parts.length >= 3) {
3163
+ const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
3164
+ const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
3165
+ const path = parts[2];
3166
+ unstagedNumstatMap.set(path, { insertions: ins, deletions: del });
3167
+ totalInsertions += ins;
3168
+ totalDeletions += del;
3169
+ }
3170
+ });
3171
+ // Parse unstaged name-status (only add if not already in staged)
3172
+ const existingPaths = new Set(files.map(f => f.path));
3173
+ unstagedStatus.split('\n').filter(l => l.trim()).forEach(line => {
3174
+ const parts = line.split('\t');
3175
+ const statusCode = parts[0];
3176
+ const path = statusCode.startsWith('R') && parts.length >= 3 ? parts[2] : parts[1];
3177
+ if (path && !existingPaths.has(path)) {
3178
+ let status = 'modified';
3179
+ if (statusCode === 'A')
3180
+ status = 'added';
3181
+ else if (statusCode === 'D')
3182
+ status = 'deleted';
3183
+ else if (statusCode.startsWith('R'))
3184
+ status = 'renamed';
3185
+ const stats = unstagedNumstatMap.get(path) || { insertions: 0, deletions: 0 };
3186
+ if (status === 'renamed' && parts.length >= 3) {
3187
+ files.push({
3188
+ path,
3189
+ insertions: stats.insertions,
3190
+ deletions: stats.deletions,
3191
+ status,
3192
+ oldPath: parts[1],
3193
+ });
3194
+ }
3195
+ else {
3196
+ files.push({
3197
+ path,
3198
+ insertions: stats.insertions,
3199
+ deletions: stats.deletions,
3200
+ status,
3201
+ });
3202
+ }
3203
+ }
3204
+ });
3205
+ // Add untracked files
3206
+ const untracked = execSync('git ls-files --others --exclude-standard', {
3207
+ cwd: workingDir,
3208
+ encoding: 'utf-8',
3209
+ timeout: 5000,
3210
+ });
3211
+ const existingPathsWithUntracked = new Set(files.map(f => f.path));
3212
+ untracked.split('\n').filter(l => l.trim()).forEach(path => {
3213
+ if (!existingPathsWithUntracked.has(path)) {
3214
+ files.push({
3215
+ path,
3216
+ insertions: 0,
3217
+ deletions: 0,
3218
+ status: 'added',
3219
+ });
3220
+ }
3221
+ });
3222
+ }
3223
+ catch {
3224
+ // Ignore errors
3225
+ }
3226
+ }
3227
+ const hasUncommittedChanges = hasStagedChanges || hasUnstagedChanges;
3228
+ const hasChangesToShip = files.length > 0 || unpushedCommits > 0;
3229
+ res.json({
3230
+ success: true,
3231
+ data: {
3232
+ files,
3233
+ totalInsertions,
3234
+ totalDeletions,
3235
+ currentBranch,
3236
+ baseBranch,
3237
+ hasUncommittedChanges,
3238
+ hasChangesToShip,
3239
+ unpushedCommits,
3240
+ hasStagedChanges,
3241
+ hasUnstagedChanges,
3242
+ existingPR,
3243
+ },
3244
+ });
3245
+ }
3246
+ catch (error) {
3247
+ const errorMsg = error instanceof Error ? error.message : String(error);
3248
+ res.status(500).json({ success: false, error: errorMsg });
3249
+ }
3250
+ });
3251
+ // Ship changes (stage, commit, push, create PR in one flow)
3252
+ terminalRouter.post('/sessions/:id/ship', async (req, res) => {
3253
+ let cleanup = null;
3254
+ try {
3255
+ const { id } = req.params;
3256
+ const { commitMessage, push = true, createPR = true, prTitle, prBody, targetBranch, repoId: requestedRepoId, } = req.body;
3257
+ // Commit message is optional - only required if there are uncommitted changes
3258
+ const hasCommitMessage = commitMessage && typeof commitMessage === 'string' && commitMessage.trim();
3259
+ const session = terminalSessionManager.getSession(id);
3260
+ if (!session) {
3261
+ res.status(404).json({ success: false, error: 'Session not found' });
3262
+ return;
3263
+ }
3264
+ const resolved = resolveSessionRepo(session, requestedRepoId);
3265
+ if (!resolved) {
3266
+ res.status(404).json({ success: false, error: 'Repository not found' });
3267
+ return;
3268
+ }
3269
+ const { repo } = resolved;
3270
+ const workingDir = getWorkingDir(session, repo);
3271
+ let committed = false;
3272
+ let pushed = false;
3273
+ let prUrl;
3274
+ let commitHash;
3275
+ // Step 1: Stage all changes (including untracked)
3276
+ try {
3277
+ execSync('git add -A', {
3278
+ cwd: workingDir,
3279
+ encoding: 'utf-8',
3280
+ timeout: 30000,
3281
+ });
3282
+ }
3283
+ catch (err) {
3284
+ const errorMsg = err instanceof Error ? err.message : String(err);
3285
+ res.status(400).json({ success: false, error: `Failed to stage changes: ${errorMsg}` });
3286
+ return;
3287
+ }
3288
+ // Check if there's anything to commit
3289
+ let hasChangesToCommit = false;
3290
+ try {
3291
+ const status = execSync('git status --porcelain', {
3292
+ cwd: workingDir,
3293
+ encoding: 'utf-8',
3294
+ timeout: 5000,
3295
+ });
3296
+ hasChangesToCommit = status.trim().length > 0;
3297
+ }
3298
+ catch {
3299
+ // Ignore
3300
+ }
3301
+ // Step 2: Commit if there are staged changes AND commit message provided
3302
+ if (hasChangesToCommit) {
3303
+ if (!hasCommitMessage) {
3304
+ res.status(400).json({ success: false, error: 'Commit message is required for uncommitted changes' });
3305
+ return;
3306
+ }
3307
+ try {
3308
+ const escapedMessage = commitMessage.replace(/"/g, '\\"');
3309
+ execSync(`git commit -m "${escapedMessage}"`, {
3310
+ cwd: workingDir,
3311
+ encoding: 'utf-8',
3312
+ timeout: 30000,
3313
+ });
3314
+ commitHash = execSync('git rev-parse --short HEAD', {
3315
+ cwd: workingDir,
3316
+ encoding: 'utf-8',
3317
+ timeout: 5000,
3318
+ }).trim();
3319
+ committed = true;
3320
+ }
3321
+ catch (err) {
3322
+ const errorMsg = err instanceof Error ? err.message : String(err);
3323
+ // Check if it's "nothing to commit"
3324
+ if (!errorMsg.includes('nothing to commit')) {
3325
+ res.status(400).json({ success: false, error: `Failed to commit: ${errorMsg}` });
3326
+ return;
3327
+ }
3328
+ }
3329
+ }
3330
+ // Step 3: Push if requested
3331
+ if (push) {
3332
+ const { options: execOptions, cleanup: cleanupFn } = getGitExecOptions(workingDir, 120000);
3333
+ cleanup = cleanupFn;
3334
+ try {
3335
+ const branch = execSync('git branch --show-current', {
3336
+ cwd: workingDir,
3337
+ encoding: 'utf-8',
3338
+ timeout: 5000,
3339
+ }).trim();
3340
+ execSync(`git push -u origin ${branch}`, execOptions);
3341
+ pushed = true;
3342
+ }
3343
+ catch (err) {
3344
+ if (cleanup)
3345
+ cleanup();
3346
+ cleanup = null;
3347
+ const errorMsg = err instanceof Error ? err.message : String(err);
3348
+ res.status(400).json({
3349
+ success: false,
3350
+ error: `Failed to push: ${errorMsg}`,
3351
+ committed,
3352
+ commitHash,
3353
+ });
3354
+ return;
3355
+ }
3356
+ finally {
3357
+ if (cleanup) {
3358
+ cleanup();
3359
+ cleanup = null;
3360
+ }
3361
+ }
3362
+ // Step 4: Create PR if requested and pushed successfully
3363
+ if (createPR && pushed) {
3364
+ try {
3365
+ const currentBranch = execSync('git branch --show-current', {
3366
+ cwd: workingDir,
3367
+ encoding: 'utf-8',
3368
+ timeout: 5000,
3369
+ }).trim();
3370
+ // Determine target branch
3371
+ let target = targetBranch || 'main';
3372
+ if (!targetBranch) {
3373
+ const branches = execSync('git branch -a', {
3374
+ cwd: workingDir,
3375
+ encoding: 'utf-8',
3376
+ timeout: 5000,
3377
+ });
3378
+ if (branches.includes('main')) {
3379
+ target = 'main';
3380
+ }
3381
+ else if (branches.includes('master')) {
3382
+ target = 'master';
3383
+ }
3384
+ }
3385
+ // Detect platform and create PR/MR
3386
+ const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
3387
+ const title = prTitle?.trim() || commitMessage.trim();
3388
+ const body = prBody || '';
3389
+ if (creds.platform === 'github' && creds.token) {
3390
+ const result = await githubIntegration.createPR(workingDir, currentBranch, title, body, undefined, // workspaceLookupPath
3391
+ target);
3392
+ if (result.success && result.prUrl) {
3393
+ prUrl = result.prUrl;
3394
+ }
3395
+ }
3396
+ else if (creds.platform === 'gitlab' && creds.token) {
3397
+ const result = await gitlabIntegration.createMR(workingDir, currentBranch, title, body, undefined, // workspaceLookupPath
3398
+ target);
3399
+ if (result.success && result.mrUrl) {
3400
+ prUrl = result.mrUrl;
3401
+ }
3402
+ }
3403
+ else {
3404
+ // Try using gh CLI as fallback
3405
+ try {
3406
+ const escapedTitle = title.replace(/"/g, '\\"');
3407
+ const escapedBody = body.replace(/"/g, '\\"');
3408
+ const result = execSync(`gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base ${target}`, {
3409
+ cwd: workingDir,
3410
+ encoding: 'utf-8',
3411
+ timeout: 60000,
3412
+ });
3413
+ // Extract URL from result
3414
+ const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
3415
+ if (urlMatch) {
3416
+ prUrl = urlMatch[0];
3417
+ }
3418
+ }
3419
+ catch (ghErr) {
3420
+ // PR creation failed but commit and push succeeded
3421
+ console.error('[ship] Failed to create PR:', ghErr);
3422
+ }
3423
+ }
3424
+ }
3425
+ catch (prErr) {
3426
+ // PR creation failed but commit and push succeeded
3427
+ console.error('[ship] Failed to create PR:', prErr);
3428
+ }
3429
+ }
3430
+ }
3431
+ res.json({
3432
+ success: true,
3433
+ data: {
3434
+ success: true,
3435
+ committed,
3436
+ pushed,
3437
+ prUrl,
3438
+ commitHash,
3439
+ },
3440
+ });
3441
+ }
3442
+ catch (error) {
3443
+ if (cleanup)
3444
+ cleanup();
3445
+ const errorMsg = error instanceof Error ? error.message : String(error);
3446
+ res.status(500).json({ success: false, error: errorMsg });
3447
+ }
3448
+ });
3449
+ // ============================================================================
3450
+ // Usage Tracking Endpoints
3451
+ // ============================================================================
3452
+ // Get usage stats for a specific session
3453
+ terminalRouter.get('/sessions/:sessionId/usage', (req, res) => {
3454
+ try {
3455
+ const { sessionId } = req.params;
3456
+ const session = terminalSessionManager.getSession(sessionId);
3457
+ if (!session) {
3458
+ res.status(404).json({ success: false, error: 'Session not found' });
3459
+ return;
3460
+ }
3461
+ const stats = usageManager.getSessionUsage(sessionId);
3462
+ res.json({ success: true, data: stats });
3463
+ }
3464
+ catch (error) {
3465
+ const errorMsg = error instanceof Error ? error.message : String(error);
3466
+ res.status(500).json({ success: false, error: errorMsg });
3467
+ }
3468
+ });
3469
+ // Get global usage stats for today
3470
+ terminalRouter.get('/usage', (req, res) => {
3471
+ try {
3472
+ const stats = usageManager.getGlobalUsage();
3473
+ res.json({ success: true, data: stats });
3474
+ }
3475
+ catch (error) {
3476
+ const errorMsg = error instanceof Error ? error.message : String(error);
3477
+ res.status(500).json({ success: false, error: errorMsg });
3478
+ }
3479
+ });
3480
+ // Get weekly usage stats (resets on Sunday)
3481
+ terminalRouter.get('/usage/weekly', (_req, res) => {
3482
+ try {
3483
+ const stats = usageManager.getWeeklyUsage();
3484
+ res.json({ success: true, data: stats });
3485
+ }
3486
+ catch (error) {
3487
+ const errorMsg = error instanceof Error ? error.message : String(error);
3488
+ res.status(500).json({ success: false, error: errorMsg });
3489
+ }
3490
+ });
3491
+ // Get Claude subscription quota (from Anthropic OAuth API)
3492
+ terminalRouter.get('/usage/quota', async (req, res) => {
3493
+ try {
3494
+ const forceRefresh = req.query.refresh === 'true';
3495
+ const quota = await queryClaudeQuota(forceRefresh);
3496
+ if (quota) {
3497
+ res.json({ success: true, data: quota });
3498
+ }
3499
+ else {
3500
+ res.json({
3501
+ success: true,
3502
+ data: null,
3503
+ message: 'Claude quota data not available. OAuth token may not be accessible.',
3504
+ });
3505
+ }
3506
+ }
3507
+ catch (error) {
3508
+ const errorMsg = error instanceof Error ? error.message : String(error);
3509
+ res.status(500).json({ success: false, error: errorMsg });
3510
+ }
3511
+ });
3512
+ // Refresh Claude subscription quota
3513
+ terminalRouter.post('/usage/quota/refresh', async (_req, res) => {
3514
+ try {
3515
+ clearQuotaCache();
3516
+ const quota = await queryClaudeQuota(true);
3517
+ res.json({ success: true, data: quota });
3518
+ }
3519
+ catch (error) {
3520
+ const errorMsg = error instanceof Error ? error.message : String(error);
3521
+ res.status(500).json({ success: false, error: errorMsg });
3522
+ }
3523
+ });
3524
+ //# sourceMappingURL=terminal-routes.js.map