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,2340 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, statSync, rmSync, copyFileSync, symlinkSync } from 'fs';
3
+ import { join, dirname, basename } from 'path';
4
+ import treeKill from 'tree-kill';
5
+ import { claudeInvoker } from './claude-invoker.js';
6
+ import { wsManager } from './ws-manager.js';
7
+ import { repoRegistry } from '../config/repos.js';
8
+ import { getClaudeSessions, formatSessionList, getSessionByRef } from './claude-session-reader.js';
9
+ import { skillRegistry } from '../config/skills.js';
10
+ import { skillExecutor } from './skill-executor.js';
11
+ import { gitSandbox } from './git-sandbox.js';
12
+ import { usageManager } from './usage-manager.js';
13
+ import { agentUsageManager } from '../config/agent-usage.js';
14
+ // Helper to generate unique IDs
15
+ function generateActivityId() {
16
+ return Math.random().toString(36).substring(2, 15);
17
+ }
18
+ // Extract meaningful target from tool input for display
19
+ function extractToolTarget(toolName, toolInput) {
20
+ const input = toolInput;
21
+ switch (toolName) {
22
+ case 'Read':
23
+ return typeof input?.file_path === 'string'
24
+ ? input.file_path.split(/[/\\]/).pop() || input.file_path
25
+ : 'file';
26
+ case 'Edit':
27
+ case 'Write':
28
+ return typeof input?.file_path === 'string'
29
+ ? input.file_path.split(/[/\\]/).pop() || input.file_path
30
+ : 'file';
31
+ case 'Bash':
32
+ const cmd = typeof input?.command === 'string' ? input.command : '';
33
+ // Truncate long commands
34
+ return cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd;
35
+ case 'Glob':
36
+ return typeof input?.pattern === 'string' ? input.pattern : 'pattern';
37
+ case 'Grep':
38
+ return typeof input?.pattern === 'string' ? input.pattern : 'search';
39
+ case 'Task':
40
+ // Extract agent type and description for Task tool
41
+ const agentType = typeof input?.subagent_type === 'string' ? input.subagent_type : null;
42
+ const taskDesc = typeof input?.description === 'string' ? input.description : 'task';
43
+ return agentType ? `${agentType}: ${taskDesc}` : taskDesc;
44
+ case 'WebFetch':
45
+ return typeof input?.url === 'string'
46
+ ? new URL(input.url).hostname
47
+ : 'url';
48
+ case 'WebSearch':
49
+ return typeof input?.query === 'string' ? input.query : 'search';
50
+ default:
51
+ return '';
52
+ }
53
+ }
54
+ // Helper to get primary repo ID for backward compatibility
55
+ export function getPrimaryRepoId(session) {
56
+ return session.repoIds[0];
57
+ }
58
+ // Cache for Claude sessions per repo
59
+ const claudeSessionsCache = new Map();
60
+ const SESSIONS_FILE = join(process.cwd(), 'config', 'terminal-sessions.json');
61
+ const TERMINAL_ARTIFACTS_DIR = join(process.cwd(), 'artifacts', 'terminal');
62
+ const ATTACHMENTS_DIR = join(process.cwd(), 'temp', 'terminal-attachments');
63
+ // Helper to generate worktree path
64
+ // Convention: <parent-of-repo>/.claudedesk-terminal-worktrees/<repoId>/<sessionId>/
65
+ function getWorktreePath(repoPath, repoId, sessionId) {
66
+ const parentDir = dirname(repoPath);
67
+ return join(parentDir, '.claudedesk-terminal-worktrees', repoId, sessionId);
68
+ }
69
+ // REL-02: Process limits to prevent resource exhaustion
70
+ const MAX_TOTAL_SESSIONS = 50; // Maximum total sessions
71
+ const MAX_ACTIVE_CLAUDE_PROCESSES = 5; // Maximum concurrent Claude processes
72
+ class TerminalSessionManager {
73
+ sessions = new Map();
74
+ constructor() {
75
+ this.loadSessions();
76
+ this.setupWebSocketHandlers();
77
+ }
78
+ /**
79
+ * REL-02: Get count of sessions with active Claude processes
80
+ */
81
+ getActiveProcessCount() {
82
+ let count = 0;
83
+ for (const session of this.sessions.values()) {
84
+ if (session.status === 'running') {
85
+ count++;
86
+ }
87
+ }
88
+ return count;
89
+ }
90
+ loadSessions() {
91
+ try {
92
+ if (existsSync(SESSIONS_FILE)) {
93
+ const data = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
94
+ let worktreeValidationCount = 0;
95
+ for (const session of data.sessions || []) {
96
+ // Migration: convert legacy repoId to repoIds array
97
+ const repoIds = session.repoIds || (session.repoId ? [session.repoId] : []);
98
+ const isMultiRepo = repoIds.length > 1;
99
+ // Restore worktree fields
100
+ let worktreeMode = session.worktreeMode;
101
+ let worktreePath = session.worktreePath;
102
+ let branch = session.branch;
103
+ let baseBranch = session.baseBranch;
104
+ // Migration: preserve ownsWorktree if set, otherwise leave undefined
105
+ // We don't default to true because we can't be sure if old sessions own their worktrees
106
+ // This is safer - only delete worktrees that are explicitly marked as owned
107
+ let ownsWorktree = session.ownsWorktree;
108
+ // Validate worktree still exists and is valid
109
+ if (worktreeMode && worktreePath) {
110
+ if (!existsSync(worktreePath) || !gitSandbox.isValidWorktree(worktreePath)) {
111
+ console.log(`[TerminalSession] Worktree for session ${session.id} is invalid or missing, clearing worktree fields`);
112
+ worktreeMode = false;
113
+ worktreePath = undefined;
114
+ branch = undefined;
115
+ baseBranch = undefined;
116
+ ownsWorktree = undefined;
117
+ worktreeValidationCount++;
118
+ }
119
+ }
120
+ // Restore dates and reset status
121
+ this.sessions.set(session.id, {
122
+ ...session,
123
+ repoIds,
124
+ isMultiRepo,
125
+ mergedFromSessionIds: session.mergedFromSessionIds,
126
+ claudeSessionId: session.claudeSessionId, // Restore Claude session ID for --resume
127
+ status: 'idle',
128
+ createdAt: new Date(session.createdAt),
129
+ lastActivityAt: new Date(session.lastActivityAt),
130
+ isBookmarked: session.isBookmarked ?? false,
131
+ bookmarkedAt: session.bookmarkedAt ? new Date(session.bookmarkedAt) : undefined,
132
+ name: session.name,
133
+ messages: session.messages.map((m) => ({
134
+ ...m,
135
+ timestamp: new Date(m.timestamp),
136
+ isStreaming: false,
137
+ })),
138
+ messageQueue: (session.messageQueue || []).map((q) => ({
139
+ ...q,
140
+ queuedAt: new Date(q.queuedAt),
141
+ })),
142
+ // Worktree fields (validated)
143
+ worktreeMode,
144
+ worktreePath,
145
+ branch,
146
+ baseBranch,
147
+ ownsWorktree,
148
+ });
149
+ }
150
+ console.log(`[TerminalSession] Loaded ${this.sessions.size} sessions`);
151
+ if (worktreeValidationCount > 0) {
152
+ console.log(`[TerminalSession] Cleared worktree data for ${worktreeValidationCount} sessions with missing/invalid worktrees`);
153
+ this.saveSessions(); // Save to persist the cleared worktree fields
154
+ }
155
+ }
156
+ }
157
+ catch (error) {
158
+ console.error('[TerminalSession] Failed to load sessions:', error);
159
+ }
160
+ }
161
+ saveSessions() {
162
+ try {
163
+ const configDir = join(process.cwd(), 'config');
164
+ if (!existsSync(configDir)) {
165
+ mkdirSync(configDir, { recursive: true });
166
+ }
167
+ const data = {
168
+ sessions: Array.from(this.sessions.values()).map((session) => ({
169
+ id: session.id,
170
+ repoIds: session.repoIds,
171
+ isMultiRepo: session.isMultiRepo,
172
+ mergedFromSessionIds: session.mergedFromSessionIds,
173
+ mode: session.mode,
174
+ claudeSessionId: session.claudeSessionId, // Persist Claude session ID for --resume
175
+ messages: session.messages,
176
+ messageQueue: session.messageQueue, // Persist message queue
177
+ createdAt: session.createdAt,
178
+ lastActivityAt: session.lastActivityAt,
179
+ isBookmarked: session.isBookmarked,
180
+ bookmarkedAt: session.bookmarkedAt,
181
+ name: session.name,
182
+ // Worktree fields
183
+ worktreeMode: session.worktreeMode,
184
+ worktreePath: session.worktreePath,
185
+ branch: session.branch,
186
+ baseBranch: session.baseBranch,
187
+ ownsWorktree: session.ownsWorktree,
188
+ })),
189
+ };
190
+ writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
191
+ }
192
+ catch (error) {
193
+ console.error('[TerminalSession] Failed to save sessions:', error);
194
+ }
195
+ }
196
+ setupWebSocketHandlers() {
197
+ // Subscribe to session
198
+ wsManager.on('subscribe', (client, message) => {
199
+ const { sessionId } = message;
200
+ if (sessionId && this.sessions.has(sessionId)) {
201
+ wsManager.subscribeToSession(client, sessionId);
202
+ // Send current session state
203
+ const session = this.sessions.get(sessionId);
204
+ wsManager.send(client, {
205
+ type: 'session-state',
206
+ sessionId,
207
+ session: {
208
+ id: session.id,
209
+ repoIds: session.repoIds,
210
+ repoId: session.repoIds[0], // Backward compatibility
211
+ isMultiRepo: session.isMultiRepo,
212
+ status: session.status,
213
+ mode: session.mode,
214
+ messages: session.messages,
215
+ messageQueue: session.messageQueue,
216
+ // Worktree fields
217
+ worktreeMode: session.worktreeMode,
218
+ worktreePath: session.worktreePath,
219
+ branch: session.branch,
220
+ baseBranch: session.baseBranch,
221
+ ownsWorktree: session.ownsWorktree,
222
+ },
223
+ });
224
+ }
225
+ });
226
+ // Unsubscribe from session
227
+ wsManager.on('unsubscribe', (client, message) => {
228
+ const { sessionId } = message;
229
+ if (sessionId) {
230
+ wsManager.unsubscribeFromSession(client, sessionId);
231
+ }
232
+ });
233
+ // Send message to Claude
234
+ wsManager.on('message', async (client, message) => {
235
+ const { sessionId, content, attachments, agentId } = message;
236
+ if (sessionId && content && typeof content === 'string') {
237
+ // Validate attachments if provided
238
+ const validAttachments = Array.isArray(attachments) ? attachments : undefined;
239
+ // Validate agentId if provided
240
+ const validAgentId = typeof agentId === 'string' ? agentId : undefined;
241
+ await this.sendMessage(sessionId, content, validAttachments, validAgentId);
242
+ }
243
+ });
244
+ // Set session mode
245
+ wsManager.on('set-mode', (client, message) => {
246
+ const { sessionId, mode } = message;
247
+ if (sessionId && (mode === 'plan' || mode === 'direct')) {
248
+ this.setMode(sessionId, mode);
249
+ }
250
+ });
251
+ // Cancel running process
252
+ wsManager.on('cancel', (client, message) => {
253
+ const { sessionId } = message;
254
+ if (sessionId) {
255
+ this.cancelSession(sessionId);
256
+ }
257
+ });
258
+ // Approve plan and execute with answers
259
+ wsManager.on('approve-plan', async (client, message) => {
260
+ const { sessionId, messageId, answers, additionalContext } = message;
261
+ if (sessionId && messageId) {
262
+ await this.executePlan(sessionId, messageId, answers || {}, additionalContext || '');
263
+ }
264
+ });
265
+ // Queue a message (explicit queue request)
266
+ wsManager.on('queue-message', (client, message) => {
267
+ const { sessionId, content, attachments, mode } = message;
268
+ if (sessionId && content) {
269
+ const session = this.sessions.get(sessionId);
270
+ if (session) {
271
+ this.queueMessage(sessionId, content, mode || session.mode, attachments);
272
+ }
273
+ }
274
+ });
275
+ // Remove a message from queue
276
+ wsManager.on('remove-from-queue', (client, message) => {
277
+ const { sessionId, messageId } = message;
278
+ if (sessionId && messageId) {
279
+ this.removeFromQueue(sessionId, messageId);
280
+ }
281
+ });
282
+ // Clear entire queue
283
+ wsManager.on('clear-queue', (client, message) => {
284
+ const { sessionId } = message;
285
+ if (sessionId) {
286
+ this.clearQueue(sessionId);
287
+ }
288
+ });
289
+ }
290
+ createSession(repoIdOrIds, worktreeOptions) {
291
+ // REL-02: Enforce session limit to prevent resource exhaustion
292
+ if (this.sessions.size >= MAX_TOTAL_SESSIONS) {
293
+ throw new Error(`Maximum number of sessions (${MAX_TOTAL_SESSIONS}) reached. Please delete some sessions to create new ones.`);
294
+ }
295
+ // Normalize to array
296
+ const repoIds = Array.isArray(repoIdOrIds) ? repoIdOrIds : [repoIdOrIds];
297
+ if (repoIds.length === 0) {
298
+ throw new Error('At least one repository is required');
299
+ }
300
+ // Validate all repos exist
301
+ for (const repoId of repoIds) {
302
+ const repo = repoRegistry.get(repoId);
303
+ if (!repo) {
304
+ throw new Error(`Repository not found: ${repoId}`);
305
+ }
306
+ }
307
+ // Validate worktree options
308
+ if (worktreeOptions) {
309
+ // Either need a branch name for new worktree OR an existing worktree path
310
+ const hasExisting = worktreeOptions.existingWorktreePath && typeof worktreeOptions.existingWorktreePath === 'string';
311
+ const hasNewBranch = worktreeOptions.branch && typeof worktreeOptions.branch === 'string';
312
+ if (!hasExisting && !hasNewBranch) {
313
+ throw new Error('Branch name or existingWorktreePath is required when worktreeMode is enabled');
314
+ }
315
+ // Worktree mode only supports single repo
316
+ if (repoIds.length > 1) {
317
+ throw new Error('Worktree mode does not support multi-repo sessions');
318
+ }
319
+ }
320
+ const id = this.generateId();
321
+ const isMultiRepo = repoIds.length > 1;
322
+ const session = {
323
+ id,
324
+ repoIds,
325
+ isMultiRepo,
326
+ messages: [],
327
+ messageQueue: [],
328
+ status: 'idle',
329
+ mode: 'direct',
330
+ createdAt: new Date(),
331
+ lastActivityAt: new Date(),
332
+ isBookmarked: false,
333
+ };
334
+ // Set up worktree if requested
335
+ if (worktreeOptions) {
336
+ const primaryRepoId = repoIds[0];
337
+ const repo = repoRegistry.get(primaryRepoId);
338
+ // Check if using existing worktree
339
+ if (worktreeOptions.existingWorktreePath) {
340
+ const existingPath = worktreeOptions.existingWorktreePath;
341
+ console.log(`[TerminalSession] Using existing worktree for session ${id} at ${existingPath}`);
342
+ // Validate the existing worktree
343
+ if (!existsSync(existingPath)) {
344
+ throw new Error(`Existing worktree path does not exist: ${existingPath}`);
345
+ }
346
+ if (!gitSandbox.isValidWorktree(existingPath)) {
347
+ throw new Error(`Path is not a valid git worktree: ${existingPath}`);
348
+ }
349
+ // Get branch name from the existing worktree
350
+ let branch = '';
351
+ try {
352
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
353
+ cwd: existingPath,
354
+ encoding: 'utf-8',
355
+ timeout: 5000,
356
+ }).trim();
357
+ }
358
+ catch (e) {
359
+ throw new Error(`Could not determine branch for worktree: ${e instanceof Error ? e.message : String(e)}`);
360
+ }
361
+ // Store worktree info in session
362
+ session.worktreeMode = true;
363
+ session.worktreePath = existingPath;
364
+ session.branch = branch;
365
+ session.ownsWorktree = false; // Session is borrowing this worktree, don't delete on close
366
+ // For existing worktrees, we don't track baseBranch
367
+ console.log(`[TerminalSession] Using existing worktree for branch ${branch}`);
368
+ }
369
+ else {
370
+ // Create new worktree
371
+ const worktreePath = getWorktreePath(repo.path, primaryRepoId, id);
372
+ const branch = worktreeOptions.branch;
373
+ const baseBranch = worktreeOptions.baseBranch || gitSandbox.getMainBranch(repo.path);
374
+ console.log(`[TerminalSession] Creating worktree for session ${id} at ${worktreePath}`);
375
+ try {
376
+ // Ensure parent directory exists
377
+ const worktreeParent = dirname(worktreePath);
378
+ if (!existsSync(worktreeParent)) {
379
+ mkdirSync(worktreeParent, { recursive: true });
380
+ }
381
+ // Create the worktree
382
+ gitSandbox.createWorktree(repo.path, worktreePath, branch);
383
+ // Copy .env file if it exists in the main repo
384
+ const envPath = join(repo.path, '.env');
385
+ const worktreeEnvPath = join(worktreePath, '.env');
386
+ if (existsSync(envPath) && !existsSync(worktreeEnvPath)) {
387
+ console.log(`[TerminalSession] Copying .env to worktree`);
388
+ copyFileSync(envPath, worktreeEnvPath);
389
+ }
390
+ // Symlink node_modules if it exists in the main repo
391
+ const nodeModulesPath = join(repo.path, 'node_modules');
392
+ const worktreeNodeModulesPath = join(worktreePath, 'node_modules');
393
+ if (existsSync(nodeModulesPath) && !existsSync(worktreeNodeModulesPath)) {
394
+ console.log(`[TerminalSession] Symlinking node_modules to worktree`);
395
+ try {
396
+ symlinkSync(nodeModulesPath, worktreeNodeModulesPath, 'junction');
397
+ }
398
+ catch (e) {
399
+ // Symlink might fail on some systems, that's okay
400
+ console.warn(`[TerminalSession] Could not symlink node_modules: ${e instanceof Error ? e.message : e}`);
401
+ }
402
+ }
403
+ // Store worktree info in session
404
+ session.worktreeMode = true;
405
+ session.worktreePath = worktreePath;
406
+ session.branch = branch;
407
+ session.baseBranch = baseBranch;
408
+ session.ownsWorktree = true; // Session created this worktree, delete on close
409
+ console.log(`[TerminalSession] Worktree created successfully for branch ${branch}`);
410
+ }
411
+ catch (error) {
412
+ // Clean up on failure
413
+ if (existsSync(worktreePath)) {
414
+ try {
415
+ rmSync(worktreePath, { recursive: true, force: true });
416
+ }
417
+ catch {
418
+ // Ignore cleanup errors
419
+ }
420
+ }
421
+ throw new Error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`);
422
+ }
423
+ }
424
+ }
425
+ this.sessions.set(id, session);
426
+ this.saveSessions();
427
+ const repoLabel = isMultiRepo ? `repos [${repoIds.join(', ')}]` : `repo ${repoIds[0]}`;
428
+ const worktreeLabel = session.worktreeMode ? ` (worktree: ${session.branch})` : '';
429
+ console.log(`[TerminalSession] Created session ${id} for ${repoLabel}${worktreeLabel}`);
430
+ return session;
431
+ }
432
+ getSession(sessionId) {
433
+ return this.sessions.get(sessionId);
434
+ }
435
+ getAllSessions() {
436
+ return Array.from(this.sessions.values()).sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
437
+ }
438
+ // Merge multiple sessions into a single multi-repo session
439
+ mergeSessions(sessionIds) {
440
+ if (sessionIds.length < 2) {
441
+ throw new Error('Need at least 2 sessions to merge');
442
+ }
443
+ // Validate all sessions exist and are not running
444
+ const sessions = [];
445
+ const allRepoIds = new Set();
446
+ for (const sessionId of sessionIds) {
447
+ const session = this.sessions.get(sessionId);
448
+ if (!session) {
449
+ throw new Error(`Session not found: ${sessionId}`);
450
+ }
451
+ if (session.status === 'running') {
452
+ throw new Error(`Cannot merge running session: ${sessionId}`);
453
+ }
454
+ sessions.push(session);
455
+ session.repoIds.forEach(id => allRepoIds.add(id));
456
+ }
457
+ // Create new merged session with fresh history
458
+ const id = this.generateId();
459
+ const repoIds = Array.from(allRepoIds);
460
+ const newSession = {
461
+ id,
462
+ repoIds,
463
+ isMultiRepo: true,
464
+ mergedFromSessionIds: sessionIds,
465
+ messages: [], // Fresh history as per requirement
466
+ messageQueue: [],
467
+ status: 'idle',
468
+ mode: sessions[0].mode, // Inherit mode from first session
469
+ createdAt: new Date(),
470
+ lastActivityAt: new Date(),
471
+ isBookmarked: false,
472
+ };
473
+ // Delete original sessions
474
+ for (const sessionId of sessionIds) {
475
+ this.cancelSession(sessionId); // Cancel any pending processes
476
+ this.sessions.delete(sessionId);
477
+ }
478
+ this.sessions.set(newSession.id, newSession);
479
+ this.saveSessions();
480
+ console.log(`[TerminalSession] Merged sessions [${sessionIds.join(', ')}] into ${id} with repos [${repoIds.join(', ')}]`);
481
+ return newSession;
482
+ }
483
+ // Add a repository to an existing session
484
+ addRepoToSession(sessionId, repoId) {
485
+ const session = this.sessions.get(sessionId);
486
+ if (!session) {
487
+ throw new Error(`Session not found: ${sessionId}`);
488
+ }
489
+ const repo = repoRegistry.get(repoId);
490
+ if (!repo) {
491
+ throw new Error(`Repository not found: ${repoId}`);
492
+ }
493
+ if (session.repoIds.includes(repoId)) {
494
+ throw new Error(`Repository ${repoId} is already in this session`);
495
+ }
496
+ session.repoIds.push(repoId);
497
+ session.isMultiRepo = true;
498
+ session.lastActivityAt = new Date();
499
+ this.saveSessions();
500
+ console.log(`[TerminalSession] Added repo ${repoId} to session ${sessionId}`);
501
+ return session;
502
+ }
503
+ // Remove a repository from a session
504
+ removeRepoFromSession(sessionId, repoId) {
505
+ const session = this.sessions.get(sessionId);
506
+ if (!session) {
507
+ throw new Error(`Session not found: ${sessionId}`);
508
+ }
509
+ if (session.repoIds.length <= 1) {
510
+ throw new Error('Cannot remove the last repository from a session');
511
+ }
512
+ const index = session.repoIds.indexOf(repoId);
513
+ if (index === -1) {
514
+ throw new Error(`Repository ${repoId} is not in this session`);
515
+ }
516
+ session.repoIds.splice(index, 1);
517
+ session.isMultiRepo = session.repoIds.length > 1;
518
+ session.lastActivityAt = new Date();
519
+ this.saveSessions();
520
+ console.log(`[TerminalSession] Removed repo ${repoId} from session ${sessionId}`);
521
+ return session;
522
+ }
523
+ async sendMessage(sessionId, content, attachments, agentId) {
524
+ const session = this.sessions.get(sessionId);
525
+ if (!session) {
526
+ throw new Error(`Session not found: ${sessionId}`);
527
+ }
528
+ // If session is running, queue the message instead of throwing error
529
+ if (session.status === 'running') {
530
+ this.queueMessage(sessionId, content, session.mode, attachments);
531
+ return;
532
+ }
533
+ // REL-02: Check active process limit before starting new Claude process
534
+ const activeCount = this.getActiveProcessCount();
535
+ if (activeCount >= MAX_ACTIVE_CLAUDE_PROCESSES) {
536
+ // Queue the message instead of rejecting
537
+ this.queueMessage(sessionId, content, session.mode, attachments);
538
+ wsManager.broadcastToSession(sessionId, {
539
+ type: 'info',
540
+ message: `Message queued. ${activeCount} Claude processes are currently running (max ${MAX_ACTIVE_CLAUDE_PROCESSES}). Your message will be processed when a slot becomes available.`,
541
+ });
542
+ return;
543
+ }
544
+ // Get primary repo (first in list)
545
+ const primaryRepoId = session.repoIds[0];
546
+ const repo = repoRegistry.get(primaryRepoId);
547
+ if (!repo) {
548
+ throw new Error(`Repository not found: ${primaryRepoId}`);
549
+ }
550
+ // Handle slash commands locally
551
+ const slashCommand = this.handleSlashCommand(session, content);
552
+ if (slashCommand) {
553
+ return; // Command handled, don't invoke Claude
554
+ }
555
+ // Add user message with attachments
556
+ const userMessage = {
557
+ id: this.generateId(),
558
+ role: 'user',
559
+ content,
560
+ timestamp: new Date(),
561
+ attachments,
562
+ };
563
+ session.messages.push(userMessage);
564
+ session.lastActivityAt = new Date();
565
+ session.status = 'running';
566
+ // Broadcast user message
567
+ wsManager.broadcastToSession(sessionId, {
568
+ type: 'message',
569
+ message: userMessage,
570
+ });
571
+ wsManager.broadcastToSession(sessionId, {
572
+ type: 'status',
573
+ status: 'running',
574
+ });
575
+ // Resolve agent name if agentId provided and record usage
576
+ let agentName;
577
+ if (agentId) {
578
+ // Simple name resolution - agent ID is typically the agent name
579
+ agentName = agentId;
580
+ // Record agent usage for recent agents tracking
581
+ agentUsageManager.recordAgentUsage(agentId, agentName);
582
+ }
583
+ // Create assistant message placeholder for streaming
584
+ const assistantMessage = {
585
+ id: this.generateId(),
586
+ role: 'assistant',
587
+ content: '',
588
+ timestamp: new Date(),
589
+ isStreaming: true,
590
+ agentId,
591
+ agentName,
592
+ };
593
+ session.messages.push(assistantMessage);
594
+ wsManager.broadcastToSession(sessionId, {
595
+ type: 'message',
596
+ message: assistantMessage,
597
+ });
598
+ // Build prompt with conversation context
599
+ let prompt = this.buildPromptWithContext(session, content);
600
+ // Add attachment context if files were attached
601
+ if (attachments && attachments.length > 0) {
602
+ prompt = this.buildAttachmentContext(attachments) + '\n\n' + prompt;
603
+ }
604
+ // Add multi-repo context for multi-repo sessions
605
+ if (session.isMultiRepo) {
606
+ prompt = this.buildMultiRepoContext(session) + '\n\n' + prompt;
607
+ }
608
+ // Add safety instructions to prevent killing ClaudeDesk
609
+ prompt = this.getSafetyInstructions() + '\n\n' + prompt;
610
+ if (session.mode === 'plan') {
611
+ prompt = claudeInvoker.generatePlanPrompt(prompt);
612
+ }
613
+ // Create artifacts directory for this session
614
+ const artifactsDir = join(TERMINAL_ARTIFACTS_DIR, sessionId);
615
+ if (!existsSync(artifactsDir)) {
616
+ mkdirSync(artifactsDir, { recursive: true });
617
+ }
618
+ // Determine working directory: use worktree path if available
619
+ const workingDir = session.worktreeMode && session.worktreePath
620
+ ? session.worktreePath
621
+ : repo.path;
622
+ // Add worktree context to prompt if in worktree mode
623
+ if (session.worktreeMode && session.branch) {
624
+ prompt = this.buildWorktreeContext(session) + '\n\n' + prompt;
625
+ }
626
+ // Track current tool activity for completion tracking
627
+ let currentActivityId = null;
628
+ // Track file changes for the current message
629
+ const currentMessageFileChanges = [];
630
+ try {
631
+ // Invoke Claude with streaming
632
+ const result = await claudeInvoker.invoke({
633
+ repoPath: workingDir,
634
+ prompt,
635
+ artifactsDir,
636
+ resumeSessionId: session.claudeSessionId, // Resume Claude session if set
637
+ agentId, // Pass agent ID for --agent flag
638
+ onProcessStart: (proc) => {
639
+ session.claudeProcess = proc;
640
+ },
641
+ onStreamEvent: (event) => {
642
+ // Handle different event types
643
+ if (event.type === 'text' && event.content) {
644
+ // If we have an active tool, complete it before text output
645
+ if (currentActivityId) {
646
+ wsManager.broadcastToSession(sessionId, {
647
+ type: 'tool-complete',
648
+ activityId: currentActivityId,
649
+ });
650
+ currentActivityId = null;
651
+ }
652
+ // Stream text content to the message
653
+ assistantMessage.content += event.content;
654
+ wsManager.broadcastToSession(sessionId, {
655
+ type: 'chunk',
656
+ messageId: assistantMessage.id,
657
+ content: event.content,
658
+ });
659
+ }
660
+ else if (event.type === 'tool_use' && event.toolName) {
661
+ // Complete previous tool if any
662
+ if (currentActivityId) {
663
+ wsManager.broadcastToSession(sessionId, {
664
+ type: 'tool-complete',
665
+ activityId: currentActivityId,
666
+ });
667
+ }
668
+ // Start new tool activity
669
+ currentActivityId = generateActivityId();
670
+ const target = extractToolTarget(event.toolName, event.toolInput);
671
+ wsManager.broadcastToSession(sessionId, {
672
+ type: 'tool-start',
673
+ activityId: currentActivityId,
674
+ tool: event.toolName,
675
+ target,
676
+ });
677
+ // Track file changes for Write and Edit tools
678
+ if ((event.toolName === 'Write' || event.toolName === 'Edit') && event.toolInput) {
679
+ const input = event.toolInput;
680
+ const filePath = input.file_path;
681
+ if (filePath) {
682
+ // Determine if this is a new file or modification
683
+ const fileExists = existsSync(filePath);
684
+ const operation = event.toolName === 'Write' && !fileExists ? 'created' : 'modified';
685
+ const fileChange = {
686
+ id: generateActivityId(),
687
+ filePath,
688
+ fileName: basename(filePath),
689
+ operation,
690
+ toolActivityId: currentActivityId,
691
+ };
692
+ // Avoid duplicate entries for the same file
693
+ const existingIndex = currentMessageFileChanges.findIndex(fc => fc.filePath === filePath);
694
+ if (existingIndex >= 0) {
695
+ currentMessageFileChanges[existingIndex] = fileChange;
696
+ }
697
+ else {
698
+ currentMessageFileChanges.push(fileChange);
699
+ }
700
+ // Broadcast file change event
701
+ wsManager.broadcastToSession(sessionId, {
702
+ type: 'file-change',
703
+ messageId: assistantMessage.id,
704
+ change: fileChange,
705
+ });
706
+ }
707
+ }
708
+ // Also broadcast legacy activity for backward compatibility
709
+ wsManager.broadcastToSession(sessionId, {
710
+ type: 'activity',
711
+ content: event.content,
712
+ toolName: event.toolName,
713
+ });
714
+ }
715
+ else if (event.type === 'error' && event.content) {
716
+ // Mark current tool as error if any
717
+ if (currentActivityId) {
718
+ wsManager.broadcastToSession(sessionId, {
719
+ type: 'tool-error',
720
+ activityId: currentActivityId,
721
+ error: event.content,
722
+ });
723
+ currentActivityId = null;
724
+ }
725
+ // Broadcast error
726
+ wsManager.broadcastToSession(sessionId, {
727
+ type: 'activity',
728
+ content: `Error: ${event.content}`,
729
+ });
730
+ }
731
+ else if (event.type === 'result') {
732
+ // Capture Claude session ID for future --resume
733
+ if (event.sessionId && !session.claudeSessionId) {
734
+ session.claudeSessionId = event.sessionId;
735
+ console.log(`[TerminalSession] Captured Claude session ID: ${event.sessionId}`);
736
+ }
737
+ // Record usage if available
738
+ if (event.usage) {
739
+ const toolCount = currentActivityId ? 1 : 0; // Rough estimate
740
+ usageManager.recordMessageUsage(sessionId, {
741
+ messageId: assistantMessage.id,
742
+ model: event.model,
743
+ usage: event.usage,
744
+ costUsd: event.costUsd,
745
+ durationMs: event.durationMs,
746
+ }, toolCount, currentMessageFileChanges.length);
747
+ // Broadcast usage update to UI
748
+ wsManager.broadcastToSession(sessionId, {
749
+ type: 'usage-update',
750
+ usage: event.usage,
751
+ model: event.model,
752
+ costUsd: event.costUsd,
753
+ durationMs: event.durationMs,
754
+ sessionStats: usageManager.getSessionUsage(sessionId),
755
+ });
756
+ }
757
+ }
758
+ },
759
+ });
760
+ // Complete any remaining tool activity
761
+ if (currentActivityId) {
762
+ wsManager.broadcastToSession(sessionId, {
763
+ type: 'tool-complete',
764
+ activityId: currentActivityId,
765
+ });
766
+ currentActivityId = null;
767
+ }
768
+ // Update message state
769
+ assistantMessage.isStreaming = false;
770
+ if (!result.success) {
771
+ // Check if it's a "Prompt is too long" error - clear session ID so next message starts fresh
772
+ if (result.error?.toLowerCase().includes('prompt is too long') ||
773
+ result.error?.toLowerCase().includes('too long')) {
774
+ console.log(`[TerminalSession] Prompt too long - clearing Claude session ID to start fresh`);
775
+ session.claudeSessionId = undefined;
776
+ assistantMessage.content += `\n\n**Error:** The Claude session has reached its context limit. Your next message will start a fresh conversation. Use \`/resume\` to see and resume other sessions.`;
777
+ }
778
+ else if (session.claudeSessionId && result.error?.includes('exit code 1')) {
779
+ // Session resume failed - clear session ID so next message starts fresh
780
+ console.log(`[TerminalSession] Session resume failed - clearing Claude session ID to start fresh`);
781
+ session.claudeSessionId = undefined;
782
+ assistantMessage.content += `\n\n**Error:** Could not resume the previous session. Your next message will start a fresh conversation.`;
783
+ }
784
+ else {
785
+ assistantMessage.content += `\n\n**Error:** ${result.error}`;
786
+ }
787
+ }
788
+ session.status = 'idle';
789
+ // Broadcast file-changes-complete with all file changes for this message
790
+ if (currentMessageFileChanges.length > 0) {
791
+ wsManager.broadcastToSession(sessionId, {
792
+ type: 'file-changes-complete',
793
+ messageId: assistantMessage.id,
794
+ fileChanges: currentMessageFileChanges,
795
+ });
796
+ }
797
+ wsManager.broadcastToSession(sessionId, {
798
+ type: 'message-complete',
799
+ messageId: assistantMessage.id,
800
+ success: result.success,
801
+ });
802
+ wsManager.broadcastToSession(sessionId, {
803
+ type: 'status',
804
+ status: 'idle',
805
+ });
806
+ }
807
+ catch (error) {
808
+ const errorMsg = error instanceof Error ? error.message : String(error);
809
+ assistantMessage.content += `\n\n**Error:** ${errorMsg}`;
810
+ assistantMessage.isStreaming = false;
811
+ session.status = 'error';
812
+ wsManager.broadcastToSession(sessionId, {
813
+ type: 'error',
814
+ error: errorMsg,
815
+ });
816
+ wsManager.broadcastToSession(sessionId, {
817
+ type: 'status',
818
+ status: 'error',
819
+ });
820
+ }
821
+ finally {
822
+ session.claudeProcess = undefined;
823
+ this.saveSessions();
824
+ // Process next queued message if any (only on success/idle)
825
+ if (session.status === 'idle' && session.messageQueue.length > 0) {
826
+ setTimeout(() => this.processNextInQueue(sessionId), 100);
827
+ }
828
+ }
829
+ }
830
+ setMode(sessionId, mode) {
831
+ const session = this.sessions.get(sessionId);
832
+ if (session) {
833
+ session.mode = mode;
834
+ this.saveSessions();
835
+ wsManager.broadcastToSession(sessionId, {
836
+ type: 'mode-changed',
837
+ mode,
838
+ });
839
+ console.log(`[TerminalSession] Session ${sessionId} mode set to ${mode}`);
840
+ }
841
+ }
842
+ setBookmark(sessionId, isBookmarked) {
843
+ const session = this.sessions.get(sessionId);
844
+ if (session) {
845
+ session.isBookmarked = isBookmarked;
846
+ session.bookmarkedAt = isBookmarked ? new Date() : undefined;
847
+ this.saveSessions();
848
+ wsManager.broadcastToSession(sessionId, {
849
+ type: 'bookmark-changed',
850
+ isBookmarked,
851
+ });
852
+ console.log(`[TerminalSession] Session ${sessionId} bookmark set to ${isBookmarked}`);
853
+ return session;
854
+ }
855
+ return undefined;
856
+ }
857
+ setSessionName(sessionId, name) {
858
+ const session = this.sessions.get(sessionId);
859
+ if (session) {
860
+ session.name = name;
861
+ this.saveSessions();
862
+ console.log(`[TerminalSession] Session ${sessionId} name set to ${name || '(cleared)'}`);
863
+ return session;
864
+ }
865
+ return undefined;
866
+ }
867
+ exportSession(sessionId, format) {
868
+ const session = this.sessions.get(sessionId);
869
+ if (!session) {
870
+ return undefined;
871
+ }
872
+ if (format === 'json') {
873
+ return JSON.stringify({
874
+ id: session.id,
875
+ repoIds: session.repoIds,
876
+ isMultiRepo: session.isMultiRepo,
877
+ mode: session.mode,
878
+ isBookmarked: session.isBookmarked,
879
+ name: session.name,
880
+ createdAt: session.createdAt,
881
+ lastActivityAt: session.lastActivityAt,
882
+ messages: session.messages.map(m => ({
883
+ id: m.id,
884
+ role: m.role,
885
+ content: m.content,
886
+ timestamp: m.timestamp,
887
+ attachments: m.attachments,
888
+ })),
889
+ }, null, 2);
890
+ }
891
+ // Markdown format
892
+ const lines = [];
893
+ const repoLabel = session.isMultiRepo
894
+ ? session.repoIds.join(', ')
895
+ : session.repoIds[0];
896
+ lines.push(`# Terminal Session: ${session.name || repoLabel}`);
897
+ lines.push('');
898
+ lines.push(`**Session ID:** \`${session.id}\``);
899
+ lines.push(`**Repositories:** ${repoLabel}`);
900
+ lines.push(`**Mode:** ${session.mode}`);
901
+ lines.push(`**Created:** ${session.createdAt.toISOString()}`);
902
+ lines.push(`**Last Activity:** ${session.lastActivityAt.toISOString()}`);
903
+ if (session.isBookmarked) {
904
+ lines.push(`**Bookmarked:** Yes`);
905
+ }
906
+ lines.push('');
907
+ lines.push('---');
908
+ lines.push('');
909
+ for (const message of session.messages) {
910
+ const role = message.role === 'user' ? '**User**' : '**Assistant**';
911
+ const timestamp = new Date(message.timestamp).toLocaleString();
912
+ lines.push(`### ${role}`);
913
+ lines.push(`*${timestamp}*`);
914
+ lines.push('');
915
+ lines.push(message.content);
916
+ lines.push('');
917
+ if (message.attachments && message.attachments.length > 0) {
918
+ lines.push('**Attachments:**');
919
+ for (const att of message.attachments) {
920
+ lines.push(`- ${att.originalName} (${att.mimeType})`);
921
+ }
922
+ lines.push('');
923
+ }
924
+ lines.push('---');
925
+ lines.push('');
926
+ }
927
+ return lines.join('\n');
928
+ }
929
+ searchMessages(query, limit = 50) {
930
+ if (!query || query.trim().length === 0) {
931
+ return [];
932
+ }
933
+ const searchTerm = query.toLowerCase().trim();
934
+ const results = [];
935
+ const SNIPPET_RADIUS = 100; // Characters around match
936
+ for (const session of this.sessions.values()) {
937
+ for (const message of session.messages) {
938
+ const content = message.content;
939
+ const lowerContent = content.toLowerCase();
940
+ const matchIndex = lowerContent.indexOf(searchTerm);
941
+ if (matchIndex !== -1) {
942
+ // Create snippet around the match
943
+ const snippetStart = Math.max(0, matchIndex - SNIPPET_RADIUS);
944
+ const snippetEnd = Math.min(content.length, matchIndex + searchTerm.length + SNIPPET_RADIUS);
945
+ let snippet = content.slice(snippetStart, snippetEnd);
946
+ // Add ellipsis if truncated
947
+ if (snippetStart > 0) {
948
+ snippet = '...' + snippet;
949
+ }
950
+ if (snippetEnd < content.length) {
951
+ snippet = snippet + '...';
952
+ }
953
+ results.push({
954
+ sessionId: session.id,
955
+ messageId: message.id,
956
+ role: message.role,
957
+ content: snippet,
958
+ timestamp: message.timestamp,
959
+ repoIds: session.repoIds,
960
+ isBookmarked: session.isBookmarked,
961
+ sessionName: session.name,
962
+ matchIndex: snippetStart > 0 ? matchIndex - snippetStart + 3 : matchIndex, // Adjust for ellipsis
963
+ });
964
+ // Stop if we've hit the limit
965
+ if (results.length >= limit) {
966
+ break;
967
+ }
968
+ }
969
+ }
970
+ if (results.length >= limit) {
971
+ break;
972
+ }
973
+ }
974
+ // Sort by timestamp (most recent first)
975
+ results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
976
+ return results.slice(0, limit);
977
+ }
978
+ cancelSession(sessionId) {
979
+ const session = this.sessions.get(sessionId);
980
+ if (session && session.claudeProcess) {
981
+ console.log(`[TerminalSession] Cancelling session ${sessionId}`);
982
+ if (session.claudeProcess.pid) {
983
+ treeKill(session.claudeProcess.pid);
984
+ }
985
+ session.claudeProcess = undefined;
986
+ session.status = 'idle';
987
+ // Mark last message as cancelled
988
+ const lastMessage = session.messages[session.messages.length - 1];
989
+ if (lastMessage && lastMessage.role === 'assistant' && lastMessage.isStreaming) {
990
+ lastMessage.content += '\n\n*[Cancelled by user]*';
991
+ lastMessage.isStreaming = false;
992
+ }
993
+ wsManager.broadcastToSession(sessionId, {
994
+ type: 'cancelled',
995
+ });
996
+ wsManager.broadcastToSession(sessionId, {
997
+ type: 'status',
998
+ status: 'idle',
999
+ });
1000
+ this.saveSessions();
1001
+ }
1002
+ }
1003
+ // Queue a message for later processing
1004
+ queueMessage(sessionId, content, mode, attachments) {
1005
+ const session = this.sessions.get(sessionId);
1006
+ if (!session)
1007
+ return;
1008
+ // Cap queue at 10 messages
1009
+ if (session.messageQueue.length >= 10) {
1010
+ wsManager.broadcastToSession(sessionId, {
1011
+ type: 'error',
1012
+ error: 'Message queue is full (max 10 messages). Please wait for current operation to complete.',
1013
+ });
1014
+ return;
1015
+ }
1016
+ const queuedMessage = {
1017
+ id: this.generateId(),
1018
+ content,
1019
+ attachments,
1020
+ mode,
1021
+ queuedAt: new Date(),
1022
+ };
1023
+ session.messageQueue.push(queuedMessage);
1024
+ this.saveSessions();
1025
+ console.log(`[TerminalSession] Queued message ${queuedMessage.id} for session ${sessionId} (queue size: ${session.messageQueue.length})`);
1026
+ // Broadcast queue update to clients
1027
+ wsManager.broadcastToSession(sessionId, {
1028
+ type: 'queue-updated',
1029
+ queue: session.messageQueue,
1030
+ });
1031
+ }
1032
+ // Process the next message in the queue
1033
+ async processNextInQueue(sessionId) {
1034
+ const session = this.sessions.get(sessionId);
1035
+ if (!session || session.messageQueue.length === 0 || session.status === 'running') {
1036
+ return;
1037
+ }
1038
+ const nextMessage = session.messageQueue.shift();
1039
+ this.saveSessions();
1040
+ console.log(`[TerminalSession] Processing queued message ${nextMessage.id} for session ${sessionId}`);
1041
+ // Broadcast queue update
1042
+ wsManager.broadcastToSession(sessionId, {
1043
+ type: 'queue-updated',
1044
+ queue: session.messageQueue,
1045
+ });
1046
+ // Set mode if different from current
1047
+ if (session.mode !== nextMessage.mode) {
1048
+ this.setMode(sessionId, nextMessage.mode);
1049
+ }
1050
+ // Send the message
1051
+ await this.sendMessage(sessionId, nextMessage.content, nextMessage.attachments);
1052
+ }
1053
+ // Remove a specific message from the queue
1054
+ removeFromQueue(sessionId, messageId) {
1055
+ const session = this.sessions.get(sessionId);
1056
+ if (!session)
1057
+ return;
1058
+ const initialLength = session.messageQueue.length;
1059
+ session.messageQueue = session.messageQueue.filter(m => m.id !== messageId);
1060
+ if (session.messageQueue.length !== initialLength) {
1061
+ this.saveSessions();
1062
+ console.log(`[TerminalSession] Removed message ${messageId} from queue for session ${sessionId}`);
1063
+ wsManager.broadcastToSession(sessionId, {
1064
+ type: 'queue-updated',
1065
+ queue: session.messageQueue,
1066
+ });
1067
+ }
1068
+ }
1069
+ // Clear the entire message queue
1070
+ clearQueue(sessionId) {
1071
+ const session = this.sessions.get(sessionId);
1072
+ if (!session || session.messageQueue.length === 0)
1073
+ return;
1074
+ session.messageQueue = [];
1075
+ this.saveSessions();
1076
+ console.log(`[TerminalSession] Cleared queue for session ${sessionId}`);
1077
+ wsManager.broadcastToSession(sessionId, {
1078
+ type: 'queue-updated',
1079
+ queue: [],
1080
+ });
1081
+ }
1082
+ // Execute approved plan with user answers
1083
+ async executePlan(sessionId, planMessageId, answers, additionalContext) {
1084
+ const session = this.sessions.get(sessionId);
1085
+ if (!session) {
1086
+ console.error(`[TerminalSession] Session not found: ${sessionId}`);
1087
+ return;
1088
+ }
1089
+ // Find the plan message
1090
+ const planMessage = session.messages.find(m => m.id === planMessageId);
1091
+ if (!planMessage || planMessage.role !== 'assistant') {
1092
+ console.error(`[TerminalSession] Plan message not found: ${planMessageId}`);
1093
+ return;
1094
+ }
1095
+ // Find the original user prompt (the message before the plan)
1096
+ const planIndex = session.messages.findIndex(m => m.id === planMessageId);
1097
+ const originalUserMessage = planIndex > 0 ? session.messages[planIndex - 1] : null;
1098
+ const originalPrompt = originalUserMessage?.role === 'user' ? originalUserMessage.content : '';
1099
+ console.log(`[TerminalSession] Executing plan for session ${sessionId} with ${Object.keys(answers).length} answers`);
1100
+ // Switch to direct mode for execution
1101
+ session.mode = 'direct';
1102
+ wsManager.broadcastToSession(sessionId, {
1103
+ type: 'mode-changed',
1104
+ mode: 'direct',
1105
+ });
1106
+ // Generate execution prompt with answers
1107
+ const executionPrompt = claudeInvoker.generateExecutionPrompt(originalPrompt, planMessage.content, answers, additionalContext);
1108
+ // Add a user message indicating plan execution
1109
+ const userMessage = {
1110
+ id: this.generateId(),
1111
+ role: 'user',
1112
+ content: `**Executing approved plan**${additionalContext ? `\n\nAdditional context: ${additionalContext}` : ''}${Object.keys(answers).length > 0 ? `\n\n**Answers:**\n${Object.entries(answers).map(([q, a]) => `- ${q}: ${a}`).join('\n')}` : ''}`,
1113
+ timestamp: new Date(),
1114
+ };
1115
+ session.messages.push(userMessage);
1116
+ session.lastActivityAt = new Date();
1117
+ wsManager.broadcastToSession(sessionId, {
1118
+ type: 'message',
1119
+ message: userMessage,
1120
+ });
1121
+ // Send to Claude (reusing sendMessage logic but with custom prompt)
1122
+ await this.sendMessageWithPrompt(sessionId, executionPrompt);
1123
+ }
1124
+ // Internal method to send a specific prompt to Claude
1125
+ async sendMessageWithPrompt(sessionId, prompt) {
1126
+ const session = this.sessions.get(sessionId);
1127
+ if (!session)
1128
+ return;
1129
+ const repoId = session.repoIds[0];
1130
+ const repo = repoRegistry.get(repoId);
1131
+ if (!repo) {
1132
+ console.error(`[TerminalSession] Repository not found: ${repoId}`);
1133
+ return;
1134
+ }
1135
+ session.status = 'running';
1136
+ wsManager.broadcastToSession(sessionId, {
1137
+ type: 'status',
1138
+ status: 'running',
1139
+ });
1140
+ // Create assistant message
1141
+ const assistantMessage = {
1142
+ id: this.generateId(),
1143
+ role: 'assistant',
1144
+ content: '',
1145
+ timestamp: new Date(),
1146
+ isStreaming: true,
1147
+ };
1148
+ session.messages.push(assistantMessage);
1149
+ wsManager.broadcastToSession(sessionId, {
1150
+ type: 'message',
1151
+ message: assistantMessage,
1152
+ });
1153
+ // Create artifacts directory
1154
+ const artifactsDir = join(TERMINAL_ARTIFACTS_DIR, sessionId);
1155
+ if (!existsSync(artifactsDir)) {
1156
+ mkdirSync(artifactsDir, { recursive: true });
1157
+ }
1158
+ // Determine working directory: use worktree path if available
1159
+ const workingDir = session.worktreeMode && session.worktreePath
1160
+ ? session.worktreePath
1161
+ : repo.path;
1162
+ // Track current tool activity for completion tracking
1163
+ let currentActivityId = null;
1164
+ // Track file changes for the current message
1165
+ const currentMessageFileChanges = [];
1166
+ try {
1167
+ const result = await claudeInvoker.invoke({
1168
+ repoPath: workingDir,
1169
+ prompt,
1170
+ artifactsDir,
1171
+ resumeSessionId: session.claudeSessionId,
1172
+ onProcessStart: (proc) => {
1173
+ session.claudeProcess = proc;
1174
+ },
1175
+ onStreamEvent: (event) => {
1176
+ if (event.type === 'text' && event.content) {
1177
+ // If we have an active tool, complete it before text output
1178
+ if (currentActivityId) {
1179
+ wsManager.broadcastToSession(sessionId, {
1180
+ type: 'tool-complete',
1181
+ activityId: currentActivityId,
1182
+ });
1183
+ currentActivityId = null;
1184
+ }
1185
+ assistantMessage.content += event.content;
1186
+ wsManager.broadcastToSession(sessionId, {
1187
+ type: 'chunk',
1188
+ messageId: assistantMessage.id,
1189
+ content: event.content,
1190
+ });
1191
+ }
1192
+ else if (event.type === 'tool_use' && event.toolName) {
1193
+ // Complete previous tool if any
1194
+ if (currentActivityId) {
1195
+ wsManager.broadcastToSession(sessionId, {
1196
+ type: 'tool-complete',
1197
+ activityId: currentActivityId,
1198
+ });
1199
+ }
1200
+ // Start new tool activity
1201
+ currentActivityId = generateActivityId();
1202
+ const target = extractToolTarget(event.toolName, event.toolInput);
1203
+ wsManager.broadcastToSession(sessionId, {
1204
+ type: 'tool-start',
1205
+ activityId: currentActivityId,
1206
+ tool: event.toolName,
1207
+ target,
1208
+ });
1209
+ // Track file changes for Write and Edit tools
1210
+ if ((event.toolName === 'Write' || event.toolName === 'Edit') && event.toolInput) {
1211
+ const input = event.toolInput;
1212
+ const filePath = input.file_path;
1213
+ if (filePath) {
1214
+ // Determine if this is a new file or modification
1215
+ const fileExists = existsSync(filePath);
1216
+ const operation = event.toolName === 'Write' && !fileExists ? 'created' : 'modified';
1217
+ const fileChange = {
1218
+ id: generateActivityId(),
1219
+ filePath,
1220
+ fileName: basename(filePath),
1221
+ operation,
1222
+ toolActivityId: currentActivityId,
1223
+ };
1224
+ // Avoid duplicate entries for the same file
1225
+ const existingIndex = currentMessageFileChanges.findIndex(fc => fc.filePath === filePath);
1226
+ if (existingIndex >= 0) {
1227
+ currentMessageFileChanges[existingIndex] = fileChange;
1228
+ }
1229
+ else {
1230
+ currentMessageFileChanges.push(fileChange);
1231
+ }
1232
+ // Broadcast file change event
1233
+ wsManager.broadcastToSession(sessionId, {
1234
+ type: 'file-change',
1235
+ messageId: assistantMessage.id,
1236
+ change: fileChange,
1237
+ });
1238
+ }
1239
+ }
1240
+ // Also broadcast legacy activity for backward compatibility
1241
+ wsManager.broadcastToSession(sessionId, {
1242
+ type: 'activity',
1243
+ content: event.content,
1244
+ toolName: event.toolName,
1245
+ });
1246
+ }
1247
+ else if (event.type === 'error' && event.content) {
1248
+ // Mark current tool as error if any
1249
+ if (currentActivityId) {
1250
+ wsManager.broadcastToSession(sessionId, {
1251
+ type: 'tool-error',
1252
+ activityId: currentActivityId,
1253
+ error: event.content,
1254
+ });
1255
+ currentActivityId = null;
1256
+ }
1257
+ }
1258
+ else if (event.type === 'result') {
1259
+ // Capture Claude session ID for future --resume
1260
+ if (event.sessionId && !session.claudeSessionId) {
1261
+ session.claudeSessionId = event.sessionId;
1262
+ console.log(`[TerminalSession] Captured Claude session ID: ${event.sessionId}`);
1263
+ }
1264
+ // Record usage if available
1265
+ if (event.usage) {
1266
+ const toolCount = currentActivityId ? 1 : 0;
1267
+ usageManager.recordMessageUsage(sessionId, {
1268
+ messageId: assistantMessage.id,
1269
+ model: event.model,
1270
+ usage: event.usage,
1271
+ costUsd: event.costUsd,
1272
+ durationMs: event.durationMs,
1273
+ }, toolCount, currentMessageFileChanges.length);
1274
+ // Broadcast usage update to UI
1275
+ wsManager.broadcastToSession(sessionId, {
1276
+ type: 'usage-update',
1277
+ usage: event.usage,
1278
+ model: event.model,
1279
+ costUsd: event.costUsd,
1280
+ durationMs: event.durationMs,
1281
+ sessionStats: usageManager.getSessionUsage(sessionId),
1282
+ });
1283
+ }
1284
+ }
1285
+ },
1286
+ });
1287
+ // Complete any remaining tool activity
1288
+ if (currentActivityId) {
1289
+ wsManager.broadcastToSession(sessionId, {
1290
+ type: 'tool-complete',
1291
+ activityId: currentActivityId,
1292
+ });
1293
+ currentActivityId = null;
1294
+ }
1295
+ assistantMessage.isStreaming = false;
1296
+ if (!result.success) {
1297
+ // Check if it's a "Prompt is too long" error - clear session ID so next message starts fresh
1298
+ if (result.error?.toLowerCase().includes('prompt is too long') ||
1299
+ result.error?.toLowerCase().includes('too long')) {
1300
+ console.log(`[TerminalSession] Prompt too long - clearing Claude session ID to start fresh`);
1301
+ session.claudeSessionId = undefined;
1302
+ assistantMessage.content += `\n\n**Error:** The Claude session has reached its context limit. Your next message will start a fresh conversation.`;
1303
+ }
1304
+ else if (session.claudeSessionId && result.error?.includes('exit code 1')) {
1305
+ // Session resume failed - clear session ID so next message starts fresh
1306
+ console.log(`[TerminalSession] Session resume failed - clearing Claude session ID to start fresh`);
1307
+ session.claudeSessionId = undefined;
1308
+ assistantMessage.content += `\n\n**Error:** Could not resume the previous session. Your next message will start a fresh conversation.`;
1309
+ }
1310
+ else {
1311
+ assistantMessage.content += `\n\n**Error:** ${result.error}`;
1312
+ }
1313
+ }
1314
+ session.status = 'idle';
1315
+ // Broadcast file-changes-complete with all file changes for this message
1316
+ if (currentMessageFileChanges.length > 0) {
1317
+ wsManager.broadcastToSession(sessionId, {
1318
+ type: 'file-changes-complete',
1319
+ messageId: assistantMessage.id,
1320
+ fileChanges: currentMessageFileChanges,
1321
+ });
1322
+ }
1323
+ wsManager.broadcastToSession(sessionId, {
1324
+ type: 'message-complete',
1325
+ messageId: assistantMessage.id,
1326
+ success: result.success,
1327
+ });
1328
+ wsManager.broadcastToSession(sessionId, {
1329
+ type: 'status',
1330
+ status: 'idle',
1331
+ });
1332
+ }
1333
+ catch (error) {
1334
+ const errorMsg = error instanceof Error ? error.message : String(error);
1335
+ assistantMessage.content += `\n\n**Error:** ${errorMsg}`;
1336
+ assistantMessage.isStreaming = false;
1337
+ session.status = 'error';
1338
+ wsManager.broadcastToSession(sessionId, {
1339
+ type: 'error',
1340
+ error: errorMsg,
1341
+ });
1342
+ wsManager.broadcastToSession(sessionId, {
1343
+ type: 'status',
1344
+ status: 'error',
1345
+ });
1346
+ }
1347
+ this.saveSessions();
1348
+ // Process next queued message if any (only on success/idle)
1349
+ if (session.status === 'idle' && session.messageQueue.length > 0) {
1350
+ setTimeout(() => this.processNextInQueue(sessionId), 100);
1351
+ }
1352
+ }
1353
+ /**
1354
+ * Delete a session. For worktree sessions, optionally delete the branch and/or worktree.
1355
+ * @param sessionId - The session ID to delete
1356
+ * @param deleteBranch - Whether to delete the git branch (only applies to worktree sessions)
1357
+ * @param deleteWorktree - Whether to delete the worktree directory (defaults to true for backwards compatibility)
1358
+ * @returns Object with info about what was deleted
1359
+ */
1360
+ deleteSession(sessionId, deleteBranch = false, deleteWorktree = true) {
1361
+ const session = this.sessions.get(sessionId);
1362
+ if (!session) {
1363
+ return { deleted: false };
1364
+ }
1365
+ // Cancel any running process
1366
+ if (session.claudeProcess?.pid) {
1367
+ treeKill(session.claudeProcess.pid);
1368
+ }
1369
+ // Clean up session attachments
1370
+ this.cleanupSessionAttachments(sessionId);
1371
+ // Clean up worktree if this is a worktree session that explicitly owns its worktree
1372
+ // Only delete if deleteWorktree is true (default behavior for backwards compatibility)
1373
+ let worktreeDeleted = false;
1374
+ let branchDeleted = false;
1375
+ if (session.worktreeMode && session.worktreePath && session.ownsWorktree === true && deleteWorktree) {
1376
+ // Only delete worktrees that were created by this session
1377
+ const primaryRepoId = session.repoIds[0];
1378
+ const repo = repoRegistry.get(primaryRepoId);
1379
+ if (repo) {
1380
+ try {
1381
+ // Remove worktree and optionally delete branch
1382
+ gitSandbox.removeWorktree(repo.path, session.worktreePath, deleteBranch ? session.branch : undefined);
1383
+ worktreeDeleted = true;
1384
+ branchDeleted = deleteBranch;
1385
+ console.log(`[TerminalSession] Removed worktree for session ${sessionId}${deleteBranch ? ' and deleted branch ' + session.branch : ''}`);
1386
+ }
1387
+ catch (e) {
1388
+ console.warn(`[TerminalSession] Failed to remove worktree: ${e instanceof Error ? e.message : e}`);
1389
+ // Try manual cleanup if git worktree remove fails
1390
+ if (existsSync(session.worktreePath)) {
1391
+ try {
1392
+ rmSync(session.worktreePath, { recursive: true, force: true });
1393
+ worktreeDeleted = true;
1394
+ console.log(`[TerminalSession] Manually removed worktree directory for session ${sessionId}`);
1395
+ }
1396
+ catch (e2) {
1397
+ console.warn(`[TerminalSession] Manual worktree cleanup also failed: ${e2}`);
1398
+ }
1399
+ }
1400
+ }
1401
+ }
1402
+ }
1403
+ else if (session.worktreeMode && session.worktreePath && session.ownsWorktree !== true) {
1404
+ // Session either borrowed an existing worktree or ownership is unknown - don't delete it
1405
+ console.log(`[TerminalSession] Session ${sessionId} does not own its worktree (ownsWorktree=${session.ownsWorktree}), not deleting it`);
1406
+ }
1407
+ this.sessions.delete(sessionId);
1408
+ this.saveSessions();
1409
+ console.log(`[TerminalSession] Deleted session ${sessionId}`);
1410
+ return { deleted: true, worktreeDeleted, branchDeleted };
1411
+ }
1412
+ /**
1413
+ * Check if a session is a worktree session (for UI to decide whether to ask about branch deletion)
1414
+ */
1415
+ isWorktreeSession(sessionId) {
1416
+ const session = this.sessions.get(sessionId);
1417
+ return session?.worktreeMode === true;
1418
+ }
1419
+ /**
1420
+ * Get worktree info for a session
1421
+ */
1422
+ getWorktreeInfo(sessionId) {
1423
+ const session = this.sessions.get(sessionId);
1424
+ if (!session)
1425
+ return null;
1426
+ return {
1427
+ worktreeMode: session.worktreeMode || false,
1428
+ branch: session.branch,
1429
+ worktreePath: session.worktreePath,
1430
+ baseBranch: session.baseBranch,
1431
+ ownsWorktree: session.ownsWorktree,
1432
+ };
1433
+ }
1434
+ /**
1435
+ * Get all worktree sessions
1436
+ */
1437
+ getWorktreeSessions() {
1438
+ return Array.from(this.sessions.values())
1439
+ .filter(session => session.worktreeMode === true)
1440
+ .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
1441
+ }
1442
+ /**
1443
+ * Clean up orphaned terminal worktrees (worktrees without matching sessions)
1444
+ * Called on startup to clean up after crashes/restarts
1445
+ */
1446
+ cleanupOrphanedWorktrees() {
1447
+ let cleanedCount = 0;
1448
+ const repos = repoRegistry.getAll();
1449
+ for (const repo of repos) {
1450
+ // Check for .claudedesk-terminal-worktrees directory in repo parent
1451
+ const worktreesBaseDir = join(dirname(repo.path), '.claudedesk-terminal-worktrees', repo.id);
1452
+ if (!existsSync(worktreesBaseDir))
1453
+ continue;
1454
+ try {
1455
+ const sessionDirs = readdirSync(worktreesBaseDir, { withFileTypes: true })
1456
+ .filter(d => d.isDirectory())
1457
+ .map(d => d.name);
1458
+ for (const sessionId of sessionDirs) {
1459
+ const session = this.sessions.get(sessionId);
1460
+ const worktreePath = join(worktreesBaseDir, sessionId);
1461
+ // If no matching session or session doesn't have worktree mode, clean up
1462
+ if (!session || !session.worktreeMode) {
1463
+ console.log(`[TerminalSession] Cleaning up orphaned worktree: ${worktreePath}`);
1464
+ try {
1465
+ gitSandbox.removeWorktree(repo.path, worktreePath);
1466
+ }
1467
+ catch {
1468
+ // Git removal might fail, try manual cleanup
1469
+ if (existsSync(worktreePath)) {
1470
+ rmSync(worktreePath, { recursive: true, force: true });
1471
+ }
1472
+ }
1473
+ cleanedCount++;
1474
+ }
1475
+ }
1476
+ // Remove empty repo worktree directory
1477
+ if (existsSync(worktreesBaseDir)) {
1478
+ const remaining = readdirSync(worktreesBaseDir);
1479
+ if (remaining.length === 0) {
1480
+ rmSync(worktreesBaseDir, { recursive: true, force: true });
1481
+ }
1482
+ }
1483
+ }
1484
+ catch (e) {
1485
+ console.warn(`[TerminalSession] Error cleaning up worktrees for ${repo.id}: ${e}`);
1486
+ }
1487
+ }
1488
+ return cleanedCount;
1489
+ }
1490
+ // Clean up attachment files for a session
1491
+ cleanupSessionAttachments(sessionId) {
1492
+ if (!existsSync(ATTACHMENTS_DIR))
1493
+ return;
1494
+ try {
1495
+ const files = readdirSync(ATTACHMENTS_DIR);
1496
+ let cleanedCount = 0;
1497
+ for (const file of files) {
1498
+ if (file.startsWith(`${sessionId}_`)) {
1499
+ const filePath = join(ATTACHMENTS_DIR, file);
1500
+ unlinkSync(filePath);
1501
+ cleanedCount++;
1502
+ }
1503
+ }
1504
+ if (cleanedCount > 0) {
1505
+ console.log(`[TerminalSession] Cleaned up ${cleanedCount} attachments for session ${sessionId}`);
1506
+ }
1507
+ }
1508
+ catch (error) {
1509
+ console.error('[TerminalSession] Failed to cleanup attachments:', error);
1510
+ }
1511
+ }
1512
+ clearMessages(sessionId) {
1513
+ const session = this.sessions.get(sessionId);
1514
+ if (session) {
1515
+ session.messages = [];
1516
+ session.lastActivityAt = new Date();
1517
+ this.saveSessions();
1518
+ wsManager.broadcastToSession(sessionId, {
1519
+ type: 'messages-cleared',
1520
+ });
1521
+ }
1522
+ }
1523
+ // Cleanup old sessions (called periodically) - skips bookmarked sessions
1524
+ cleanupOldSessions(maxAgeMs = 24 * 60 * 60 * 1000) {
1525
+ const now = Date.now();
1526
+ let cleaned = 0;
1527
+ for (const [id, session] of this.sessions) {
1528
+ // Skip bookmarked sessions - they should never be auto-cleaned
1529
+ if (session.isBookmarked) {
1530
+ continue;
1531
+ }
1532
+ if (now - session.lastActivityAt.getTime() > maxAgeMs) {
1533
+ this.deleteSession(id);
1534
+ cleaned++;
1535
+ }
1536
+ }
1537
+ if (cleaned > 0) {
1538
+ console.log(`[TerminalSession] Cleaned up ${cleaned} inactive sessions`);
1539
+ }
1540
+ }
1541
+ // Handle slash commands locally (returns true if handled)
1542
+ handleSlashCommand(session, content) {
1543
+ const trimmed = content.trim().toLowerCase();
1544
+ // /help - Show available commands
1545
+ if (trimmed === '/help') {
1546
+ this.sendSystemMessage(session, `**Available Commands:**
1547
+
1548
+ - \`/help\` - Show this help message
1549
+ - \`/resume\` - List Claude Code sessions for this repo
1550
+ - \`/resume <number>\` - Resume a specific Claude session
1551
+ - \`/clear\` - Clear conversation history
1552
+ - \`/sessions\` - List ClaudeDesk terminal sessions
1553
+ - \`/status\` - Show current session status
1554
+ - \`/mode plan\` - Switch to plan mode
1555
+ - \`/mode direct\` - Switch to direct mode
1556
+ - \`/new\` - Start a new conversation (clear Claude session)
1557
+ - \`/skills\` - List available skills for this repo
1558
+ - \`/skill <name>\` - Execute a skill
1559
+ - \`/skill <name> --help\` - Show skill details and inputs
1560
+ - \`/skill create <name> <description>\` - Create a new repo skill
1561
+ - \`/skill create <name> --global <description>\` - Create a global skill
1562
+
1563
+ **Tips:**
1564
+ - Use **\`/resume\`** to continue previous Claude Code conversations
1565
+ - Use **Plan Mode** when you want Claude to outline changes before implementing
1566
+ - Use **Direct Mode** for quick changes and immediate execution
1567
+ - Press **Esc** to cancel a running operation
1568
+ - Use **Ctrl+1-9** to switch between tabs`);
1569
+ return true;
1570
+ }
1571
+ // /clear - Clear messages
1572
+ if (trimmed === '/clear') {
1573
+ this.clearMessages(session.id);
1574
+ this.sendSystemMessage(session, '*Conversation cleared.*');
1575
+ return true;
1576
+ }
1577
+ // /sessions - List ClaudeDesk terminal sessions
1578
+ if (trimmed === '/sessions') {
1579
+ const sessions = this.getAllSessions();
1580
+ if (sessions.length === 0) {
1581
+ this.sendSystemMessage(session, '*No ClaudeDesk terminal sessions found.*');
1582
+ }
1583
+ else {
1584
+ const sessionList = sessions.map((s, i) => {
1585
+ const msgCount = s.messages.length;
1586
+ const lastMsg = s.messages[s.messages.length - 1];
1587
+ const preview = lastMsg ? lastMsg.content.slice(0, 50).replace(/\n/g, ' ') + '...' : 'No messages';
1588
+ const isActive = s.id === session.id ? ' **(current)**' : '';
1589
+ const repoLabel = s.isMultiRepo
1590
+ ? `${s.repoIds[0]} (+${s.repoIds.length - 1} more)`
1591
+ : s.repoIds[0];
1592
+ return `${i + 1}. **${repoLabel}**${isActive} - ${msgCount} messages\n _${preview}_`;
1593
+ }).join('\n\n');
1594
+ this.sendSystemMessage(session, `**Your ClaudeDesk Terminal Sessions:**\n\n${sessionList}\n\n_Switch sessions using the tabs above or Ctrl+1-9._`);
1595
+ }
1596
+ return true;
1597
+ }
1598
+ // /resume - Show Claude Code sessions for this repo (uses primary repo for multi-repo sessions)
1599
+ if (trimmed === '/resume' || trimmed.startsWith('/resume ')) {
1600
+ const primaryRepoId = session.repoIds[0];
1601
+ const repo = repoRegistry.get(primaryRepoId);
1602
+ if (!repo) {
1603
+ this.sendSystemMessage(session, '*Error: Repository not found.*');
1604
+ return true;
1605
+ }
1606
+ // Get Claude sessions for this repo
1607
+ const claudeSessions = getClaudeSessions(repo.path);
1608
+ // Cache the sessions for resuming
1609
+ claudeSessionsCache.set(session.id, { sessions: claudeSessions, fetchedAt: Date.now() });
1610
+ // Check if user wants to resume a specific session
1611
+ const parts = trimmed.split(' ');
1612
+ if (parts.length > 1) {
1613
+ const ref = parts.slice(1).join(' ');
1614
+ const targetSession = getSessionByRef(claudeSessions, ref);
1615
+ if (targetSession) {
1616
+ // Set the Claude session ID for this terminal session
1617
+ session.claudeSessionId = targetSession.id;
1618
+ this.saveSessions();
1619
+ this.sendSystemMessage(session, `**Resuming Claude session:**\n\n_${targetSession.summary}_\n\nYour next message will continue this conversation. Claude will have full context from the previous session.`);
1620
+ }
1621
+ else {
1622
+ this.sendSystemMessage(session, `*Session "${ref}" not found. Type \`/resume\` to see available sessions.*`);
1623
+ }
1624
+ return true;
1625
+ }
1626
+ // Show list of Claude sessions
1627
+ const formattedList = formatSessionList(claudeSessions);
1628
+ this.sendSystemMessage(session, formattedList);
1629
+ return true;
1630
+ }
1631
+ // /new - Start a new conversation (clear Claude session and messages)
1632
+ if (trimmed === '/new') {
1633
+ session.claudeSessionId = undefined;
1634
+ session.messages = [];
1635
+ session.lastActivityAt = new Date();
1636
+ this.saveSessions();
1637
+ // Broadcast messages cleared to UI
1638
+ wsManager.broadcastToSession(session.id, {
1639
+ type: 'messages-cleared',
1640
+ });
1641
+ this.sendSystemMessage(session, '*Starting fresh conversation. Your next message will begin a new Claude session.*');
1642
+ return true;
1643
+ }
1644
+ // /status - Show session status
1645
+ if (trimmed === '/status') {
1646
+ const primaryRepoId = session.repoIds[0];
1647
+ const repo = repoRegistry.get(primaryRepoId);
1648
+ const claudeSessionInfo = session.claudeSessionId
1649
+ ? `\n- **Claude Session:** \`${session.claudeSessionId}\` _(resuming)_`
1650
+ : '\n- **Claude Session:** _New conversation_';
1651
+ // Build repo info string
1652
+ let repoInfo;
1653
+ if (session.isMultiRepo) {
1654
+ const repoList = session.repoIds.map(id => {
1655
+ const r = repoRegistry.get(id);
1656
+ return ` - **${id}** - \`${r?.path || 'unknown'}\``;
1657
+ }).join('\n');
1658
+ repoInfo = `\n- **Repositories (${session.repoIds.length}):**\n${repoList}`;
1659
+ }
1660
+ else {
1661
+ repoInfo = `\n- **Repository:** ${primaryRepoId}\n- **Path:** \`${repo?.path || 'unknown'}\``;
1662
+ }
1663
+ this.sendSystemMessage(session, `**Session Status:**
1664
+
1665
+ - **Session ID:** \`${session.id}\`
1666
+ - **Multi-Repo:** ${session.isMultiRepo ? 'Yes' : 'No'}${repoInfo}
1667
+ - **Mode:** ${session.mode === 'plan' ? 'Plan Mode' : 'Direct Mode'}${claudeSessionInfo}
1668
+ - **Messages:** ${session.messages.length}
1669
+ - **Created:** ${session.createdAt.toLocaleString()}
1670
+ - **Last Activity:** ${session.lastActivityAt.toLocaleString()}`);
1671
+ return true;
1672
+ }
1673
+ // /mode - Switch mode
1674
+ if (trimmed.startsWith('/mode ')) {
1675
+ const mode = trimmed.split(' ')[1];
1676
+ if (mode === 'plan') {
1677
+ this.setMode(session.id, 'plan');
1678
+ this.sendSystemMessage(session, '*Switched to **Plan Mode**. Claude will outline changes before implementing.*');
1679
+ return true;
1680
+ }
1681
+ else if (mode === 'direct') {
1682
+ this.setMode(session.id, 'direct');
1683
+ this.sendSystemMessage(session, '*Switched to **Direct Mode**. Claude will implement changes immediately.*');
1684
+ return true;
1685
+ }
1686
+ }
1687
+ // /skills - List available skills
1688
+ if (trimmed === '/skills') {
1689
+ this.handleSkillsListCommand(session);
1690
+ return true;
1691
+ }
1692
+ // /skill create <name> [--global] <description> - Create a new skill
1693
+ if (trimmed.startsWith('/skill create ')) {
1694
+ this.handleSkillCreateCommand(session, content);
1695
+ return true;
1696
+ }
1697
+ // /skill <name> [args] - Execute or show help for a skill
1698
+ if (trimmed.startsWith('/skill ')) {
1699
+ this.handleSkillCommand(session, content);
1700
+ return true;
1701
+ }
1702
+ return false; // Not a slash command
1703
+ }
1704
+ // Send a system message (appears as assistant message)
1705
+ sendSystemMessage(session, content) {
1706
+ const systemMessage = {
1707
+ id: this.generateId(),
1708
+ role: 'assistant',
1709
+ content,
1710
+ timestamp: new Date(),
1711
+ isStreaming: false,
1712
+ };
1713
+ session.messages.push(systemMessage);
1714
+ session.lastActivityAt = new Date();
1715
+ this.saveSessions();
1716
+ wsManager.broadcastToSession(session.id, {
1717
+ type: 'message',
1718
+ message: systemMessage,
1719
+ });
1720
+ }
1721
+ // Handle /skills command - list available skills
1722
+ handleSkillsListCommand(session) {
1723
+ const primaryRepoId = session.repoIds[0];
1724
+ const repo = repoRegistry.get(primaryRepoId);
1725
+ if (!repo) {
1726
+ this.sendSystemMessage(session, '*Error: Repository not found.*');
1727
+ return;
1728
+ }
1729
+ // Load repo skills if not already loaded
1730
+ skillRegistry.loadRepoSkills(primaryRepoId, repo.path);
1731
+ const skills = skillRegistry.getAll(primaryRepoId);
1732
+ if (skills.length === 0) {
1733
+ this.sendSystemMessage(session, `**No skills available.**
1734
+
1735
+ Create a skill with:
1736
+ \`/skill create <name> <description>\`
1737
+
1738
+ Or add skills manually:
1739
+ - **Global skills:** Add \`.md\` files to \`config/skills/\`
1740
+ - **Repo skills:** Add \`.md\` files to \`<repo>/.claude/skills/\` or \`<repo>/.claudedesk/skills/\``);
1741
+ return;
1742
+ }
1743
+ // Group by source
1744
+ const globalSkills = skills.filter(s => s.source === 'global');
1745
+ const repoSkills = skills.filter(s => s.source === 'repo');
1746
+ let message = '**Available Skills:**\n\n';
1747
+ if (repoSkills.length > 0) {
1748
+ message += `**Repository Skills** _(${primaryRepoId})_\n`;
1749
+ for (const skill of repoSkills) {
1750
+ const typeIcon = skill.type === 'prompt' ? '💬' : skill.type === 'command' ? '⚡' : '🔄';
1751
+ message += `- \`${skill.id}\` ${typeIcon} - ${skill.description || 'No description'}\n`;
1752
+ }
1753
+ message += '\n';
1754
+ }
1755
+ if (globalSkills.length > 0) {
1756
+ message += '**Global Skills**\n';
1757
+ for (const skill of globalSkills) {
1758
+ const typeIcon = skill.type === 'prompt' ? '💬' : skill.type === 'command' ? '⚡' : '🔄';
1759
+ message += `- \`${skill.id}\` ${typeIcon} - ${skill.description || 'No description'}\n`;
1760
+ }
1761
+ message += '\n';
1762
+ }
1763
+ message += `_Run \`/skill <name> --help\` for details, or \`/skill <name>\` to execute._
1764
+ _Create new skills with \`/skill create <name> <description>\`_`;
1765
+ this.sendSystemMessage(session, message);
1766
+ }
1767
+ // Handle /skill <name> [args] command
1768
+ async handleSkillCommand(session, content) {
1769
+ const primaryRepoId = session.repoIds[0];
1770
+ const repo = repoRegistry.get(primaryRepoId);
1771
+ if (!repo) {
1772
+ this.sendSystemMessage(session, '*Error: Repository not found.*');
1773
+ return;
1774
+ }
1775
+ // Parse command: /skill <name> [--help] [key=value ...]
1776
+ const parts = content.trim().slice(7).trim().split(/\s+/); // Remove "/skill "
1777
+ if (parts.length === 0 || !parts[0]) {
1778
+ this.sendSystemMessage(session, '*Usage:* `/skill <name> [--help] [key=value ...]`');
1779
+ return;
1780
+ }
1781
+ const skillName = parts[0];
1782
+ const isHelp = parts.includes('--help');
1783
+ // Load repo skills if not already loaded
1784
+ skillRegistry.loadRepoSkills(primaryRepoId, repo.path);
1785
+ const skill = skillRegistry.get(skillName, primaryRepoId);
1786
+ if (!skill) {
1787
+ this.sendSystemMessage(session, `*Skill not found:* \`${skillName}\`\n\nRun \`/skills\` to see available skills.`);
1788
+ return;
1789
+ }
1790
+ // --help: Show skill details
1791
+ if (isHelp) {
1792
+ let helpMessage = `**Skill: ${skill.id}**\n\n`;
1793
+ helpMessage += `- **Description:** ${skill.description || 'No description'}\n`;
1794
+ helpMessage += `- **Type:** ${skill.type}\n`;
1795
+ helpMessage += `- **Source:** ${skill.source === 'repo' ? `Repository (${primaryRepoId})` : 'Global'}\n`;
1796
+ if (skill.inputs && skill.inputs.length > 0) {
1797
+ helpMessage += '\n**Inputs:**\n';
1798
+ for (const input of skill.inputs) {
1799
+ const required = input.required ? '*(required)*' : `*(default: ${input.default ?? 'none'})*`;
1800
+ helpMessage += `- \`${input.name}\` (${input.type}) ${required}\n`;
1801
+ if (input.description) {
1802
+ helpMessage += ` ${input.description}\n`;
1803
+ }
1804
+ }
1805
+ }
1806
+ else {
1807
+ helpMessage += '\n_No inputs required._\n';
1808
+ }
1809
+ helpMessage += `\n**Usage:** \`/skill ${skill.id}${skill.inputs?.some(i => i.required) ? ' key=value' : ''}\``;
1810
+ this.sendSystemMessage(session, helpMessage);
1811
+ return;
1812
+ }
1813
+ // Parse inputs: key=value pairs
1814
+ const inputs = {};
1815
+ for (let i = 1; i < parts.length; i++) {
1816
+ const part = parts[i];
1817
+ if (part === '--help')
1818
+ continue;
1819
+ const eqIndex = part.indexOf('=');
1820
+ if (eqIndex > 0) {
1821
+ const key = part.slice(0, eqIndex);
1822
+ let value = part.slice(eqIndex + 1);
1823
+ // Try to parse as number or boolean
1824
+ if (value === 'true')
1825
+ value = true;
1826
+ else if (value === 'false')
1827
+ value = false;
1828
+ else if (!isNaN(Number(value)) && value !== '')
1829
+ value = Number(value);
1830
+ inputs[key] = value;
1831
+ }
1832
+ }
1833
+ // Add a user message showing the skill execution
1834
+ const userMessage = {
1835
+ id: this.generateId(),
1836
+ role: 'user',
1837
+ content: `/skill ${skillName}${Object.keys(inputs).length > 0 ? ' ' + Object.entries(inputs).map(([k, v]) => `${k}=${v}`).join(' ') : ''}`,
1838
+ timestamp: new Date(),
1839
+ };
1840
+ session.messages.push(userMessage);
1841
+ session.lastActivityAt = new Date();
1842
+ wsManager.broadcastToSession(session.id, {
1843
+ type: 'message',
1844
+ message: userMessage,
1845
+ });
1846
+ // Set session to running
1847
+ session.status = 'running';
1848
+ wsManager.broadcastToSession(session.id, {
1849
+ type: 'status',
1850
+ status: 'running',
1851
+ });
1852
+ // Create assistant message for output
1853
+ const assistantMessage = {
1854
+ id: this.generateId(),
1855
+ role: 'assistant',
1856
+ content: `**Executing skill:** \`${skill.id}\` (${skill.type})\n\n`,
1857
+ timestamp: new Date(),
1858
+ isStreaming: true,
1859
+ };
1860
+ session.messages.push(assistantMessage);
1861
+ wsManager.broadcastToSession(session.id, {
1862
+ type: 'message',
1863
+ message: assistantMessage,
1864
+ });
1865
+ try {
1866
+ // Create artifacts directory
1867
+ const artifactsDir = join(TERMINAL_ARTIFACTS_DIR, session.id, 'skills');
1868
+ if (!existsSync(artifactsDir)) {
1869
+ mkdirSync(artifactsDir, { recursive: true });
1870
+ }
1871
+ // Execute the skill
1872
+ const result = await skillExecutor.execute({ skillId: skill.id, repoId: primaryRepoId, inputs }, repo, repo.path, artifactsDir);
1873
+ // Update message with result
1874
+ if (result.success) {
1875
+ assistantMessage.content += `✅ **Skill completed successfully**\n\n`;
1876
+ if (result.output) {
1877
+ assistantMessage.content += result.output;
1878
+ }
1879
+ }
1880
+ else {
1881
+ assistantMessage.content += `❌ **Skill failed**\n\n${result.error || 'Unknown error'}`;
1882
+ if (result.output) {
1883
+ assistantMessage.content += `\n\n**Output:**\n${result.output}`;
1884
+ }
1885
+ }
1886
+ }
1887
+ catch (error) {
1888
+ assistantMessage.content += `❌ **Error executing skill:**\n${error instanceof Error ? error.message : String(error)}`;
1889
+ }
1890
+ assistantMessage.isStreaming = false;
1891
+ session.status = 'idle';
1892
+ wsManager.broadcastToSession(session.id, {
1893
+ type: 'message-complete',
1894
+ messageId: assistantMessage.id,
1895
+ success: true,
1896
+ });
1897
+ wsManager.broadcastToSession(session.id, {
1898
+ type: 'status',
1899
+ status: 'idle',
1900
+ });
1901
+ this.saveSessions();
1902
+ }
1903
+ // Handle /skill create <name> [--global] <description> command
1904
+ async handleSkillCreateCommand(session, content) {
1905
+ const primaryRepoId = session.repoIds[0];
1906
+ const repo = repoRegistry.get(primaryRepoId);
1907
+ if (!repo) {
1908
+ this.sendSystemMessage(session, '*Error: Repository not found.*');
1909
+ return;
1910
+ }
1911
+ // Parse command: /skill create <name> [--global] <description...>
1912
+ const afterCreate = content.trim().slice(14).trim(); // Remove "/skill create "
1913
+ const parts = afterCreate.split(/\s+/);
1914
+ if (parts.length === 0 || !parts[0]) {
1915
+ this.sendSystemMessage(session, `**Usage:** \`/skill create <name> [--global] <description>\`
1916
+
1917
+ **Examples:**
1918
+ - \`/skill create code-review Review code for security and best practices\`
1919
+ - \`/skill create deploy --global Deploy the application to production\`
1920
+
1921
+ **Flags:**
1922
+ - \`--global\` - Create as a global skill (available to all repos)
1923
+ - Without flag - Create as a repo-specific skill`);
1924
+ return;
1925
+ }
1926
+ const skillName = parts[0];
1927
+ const isGlobal = parts.includes('--global');
1928
+ // Extract description (everything after name and flags)
1929
+ const descriptionParts = parts.slice(1).filter(p => p !== '--global');
1930
+ const description = descriptionParts.join(' ');
1931
+ if (!description) {
1932
+ this.sendSystemMessage(session, `*Please provide a description for the skill.*
1933
+
1934
+ **Example:** \`/skill create ${skillName} Review code changes for security issues\``);
1935
+ return;
1936
+ }
1937
+ // Validate skill name (alphanumeric, hyphens, underscores only)
1938
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(skillName)) {
1939
+ this.sendSystemMessage(session, `*Invalid skill name:* \`${skillName}\`
1940
+
1941
+ Skill names must:
1942
+ - Start with a letter
1943
+ - Contain only letters, numbers, hyphens, and underscores`);
1944
+ return;
1945
+ }
1946
+ // Check if skill already exists
1947
+ skillRegistry.loadRepoSkills(primaryRepoId, repo.path);
1948
+ const existingSkill = skillRegistry.get(skillName, isGlobal ? undefined : primaryRepoId);
1949
+ if (existingSkill) {
1950
+ this.sendSystemMessage(session, `*Skill \`${skillName}\` already exists.* Use a different name or delete the existing skill first.`);
1951
+ return;
1952
+ }
1953
+ // Add user message showing the create command
1954
+ const userMessage = {
1955
+ id: this.generateId(),
1956
+ role: 'user',
1957
+ content: `/skill create ${skillName}${isGlobal ? ' --global' : ''} ${description}`,
1958
+ timestamp: new Date(),
1959
+ };
1960
+ session.messages.push(userMessage);
1961
+ session.lastActivityAt = new Date();
1962
+ wsManager.broadcastToSession(session.id, {
1963
+ type: 'message',
1964
+ message: userMessage,
1965
+ });
1966
+ // Set session to running
1967
+ session.status = 'running';
1968
+ wsManager.broadcastToSession(session.id, {
1969
+ type: 'status',
1970
+ status: 'running',
1971
+ });
1972
+ // Create assistant message for streaming output
1973
+ const assistantMessage = {
1974
+ id: this.generateId(),
1975
+ role: 'assistant',
1976
+ content: `**Creating ${isGlobal ? 'global' : 'repository'} skill:** \`${skillName}\`\n\n`,
1977
+ timestamp: new Date(),
1978
+ isStreaming: true,
1979
+ };
1980
+ session.messages.push(assistantMessage);
1981
+ wsManager.broadcastToSession(session.id, {
1982
+ type: 'message',
1983
+ message: assistantMessage,
1984
+ });
1985
+ try {
1986
+ // Build the prompt for Claude to generate the skill
1987
+ const skillPrompt = this.buildSkillCreationPrompt(skillName, description, isGlobal, repo);
1988
+ // Create artifacts directory
1989
+ const artifactsDir = join(TERMINAL_ARTIFACTS_DIR, session.id, 'skill-create');
1990
+ if (!existsSync(artifactsDir)) {
1991
+ mkdirSync(artifactsDir, { recursive: true });
1992
+ }
1993
+ // Invoke Claude to generate the skill content
1994
+ let claudeOutput = '';
1995
+ const result = await claudeInvoker.invoke({
1996
+ repoPath: repo.path,
1997
+ prompt: skillPrompt,
1998
+ artifactsDir,
1999
+ onStreamEvent: (event) => {
2000
+ if (event.type === 'text' && event.content) {
2001
+ claudeOutput += event.content;
2002
+ // Stream progress to user
2003
+ wsManager.broadcastToSession(session.id, {
2004
+ type: 'chunk',
2005
+ messageId: assistantMessage.id,
2006
+ content: event.content,
2007
+ });
2008
+ assistantMessage.content += event.content;
2009
+ }
2010
+ },
2011
+ });
2012
+ if (!result.success) {
2013
+ throw new Error(result.error || 'Failed to generate skill content');
2014
+ }
2015
+ // Extract the skill file content from Claude's output
2016
+ const skillFileContent = this.extractSkillFileContent(claudeOutput, skillName, description);
2017
+ if (!skillFileContent) {
2018
+ throw new Error('Could not extract valid skill content from Claude response');
2019
+ }
2020
+ // Determine the save path
2021
+ let skillPath;
2022
+ if (isGlobal) {
2023
+ const globalSkillsDir = join(process.cwd(), 'config', 'skills');
2024
+ if (!existsSync(globalSkillsDir)) {
2025
+ mkdirSync(globalSkillsDir, { recursive: true });
2026
+ }
2027
+ skillPath = join(globalSkillsDir, `${skillName}.md`);
2028
+ }
2029
+ else {
2030
+ // Save to .claude/skills/ (Claude Code convention, takes priority over .claudedesk/skills/)
2031
+ const repoSkillsDir = join(repo.path, '.claude', 'skills');
2032
+ if (!existsSync(repoSkillsDir)) {
2033
+ mkdirSync(repoSkillsDir, { recursive: true });
2034
+ }
2035
+ skillPath = join(repoSkillsDir, `${skillName}.md`);
2036
+ }
2037
+ // Save the skill file
2038
+ writeFileSync(skillPath, skillFileContent, 'utf-8');
2039
+ // Reload skills to pick up the new one
2040
+ if (isGlobal) {
2041
+ skillRegistry.reload();
2042
+ }
2043
+ else {
2044
+ skillRegistry.reloadRepo(primaryRepoId, repo.path);
2045
+ }
2046
+ // Update message with success
2047
+ assistantMessage.content += `\n\n---\n\n✅ **Skill created successfully!**
2048
+
2049
+ - **Location:** \`${skillPath}\`
2050
+ - **Type:** ${isGlobal ? 'Global' : 'Repository'} skill
2051
+
2052
+ Run \`/skill ${skillName} --help\` to see details, or \`/skill ${skillName}\` to execute.`;
2053
+ }
2054
+ catch (error) {
2055
+ assistantMessage.content += `\n\n❌ **Error creating skill:**\n${error instanceof Error ? error.message : String(error)}`;
2056
+ }
2057
+ assistantMessage.isStreaming = false;
2058
+ session.status = 'idle';
2059
+ wsManager.broadcastToSession(session.id, {
2060
+ type: 'message-complete',
2061
+ messageId: assistantMessage.id,
2062
+ success: true,
2063
+ });
2064
+ wsManager.broadcastToSession(session.id, {
2065
+ type: 'status',
2066
+ status: 'idle',
2067
+ });
2068
+ this.saveSessions();
2069
+ }
2070
+ // Build prompt for Claude to generate a skill file
2071
+ buildSkillCreationPrompt(skillName, description, isGlobal, repo) {
2072
+ const repoContext = isGlobal ? '' : `
2073
+ ## Repository Context
2074
+ This skill is being created for the repository: ${repo.id}
2075
+ Repository path: ${repo.path}
2076
+
2077
+ Please explore the repository structure to understand:
2078
+ - What kind of project this is (language, framework, etc.)
2079
+ - Common patterns and conventions used
2080
+ - How to tailor the skill prompt to this specific codebase
2081
+ `;
2082
+ return `You are creating a ClaudeDesk skill file. The user wants to create a skill with:
2083
+
2084
+ - **Name:** ${skillName}
2085
+ - **Description:** ${description}
2086
+ - **Scope:** ${isGlobal ? 'Global (available to all repositories)' : `Repository-specific (for ${repo.id})`}
2087
+ ${repoContext}
2088
+ ## Your Task
2089
+
2090
+ Generate a complete skill file in Markdown format with YAML frontmatter. The skill should be a "prompt" type that instructs Claude to perform the described task.
2091
+
2092
+ ## Skill File Format
2093
+
2094
+ \`\`\`markdown
2095
+ ---
2096
+ id: ${skillName}
2097
+ name: Human Readable Name
2098
+ description: Short description of what the skill does
2099
+ type: prompt
2100
+ inputs:
2101
+ - name: input_name
2102
+ type: string
2103
+ description: What this input is for
2104
+ required: false
2105
+ default: default_value
2106
+ ---
2107
+
2108
+ The prompt content goes here. This is what Claude will receive when the skill is executed.
2109
+
2110
+ You can use template variables:
2111
+ - {{repo.id}} - Repository ID
2112
+ - {{repo.path}} - Repository path
2113
+ - {{inputs.input_name}} - User-provided input value
2114
+
2115
+ Write clear, actionable instructions for Claude to follow.
2116
+ \`\`\`
2117
+
2118
+ ## Guidelines
2119
+
2120
+ 1. Create a descriptive, actionable prompt that Claude can follow
2121
+ 2. Include relevant inputs that make the skill flexible
2122
+ 3. For repo-specific skills, tailor the prompt to the codebase
2123
+ 4. Keep the prompt focused and clear
2124
+ 5. Use markdown formatting in the prompt for readability
2125
+
2126
+ ## Output
2127
+
2128
+ Generate ONLY the skill file content (starting with \`---\` and ending after the prompt). Do not include any explanation before or after the skill file content.`;
2129
+ }
2130
+ // Extract skill file content from Claude's response
2131
+ extractSkillFileContent(claudeOutput, skillName, description) {
2132
+ // Try to find content between --- markers (YAML frontmatter format)
2133
+ const frontmatterMatch = claudeOutput.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n[\s\S]+/m);
2134
+ if (frontmatterMatch) {
2135
+ return frontmatterMatch[0].trim();
2136
+ }
2137
+ // Try to find a markdown code block with the skill content
2138
+ const codeBlockMatch = claudeOutput.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---\r?\n[\s\S]*?)```/);
2139
+ if (codeBlockMatch) {
2140
+ return codeBlockMatch[1].trim();
2141
+ }
2142
+ // If we can't extract, try to find any --- block
2143
+ const anyFrontmatter = claudeOutput.match(/---[\s\S]*?---[\s\S]*/);
2144
+ if (anyFrontmatter) {
2145
+ return anyFrontmatter[0].trim();
2146
+ }
2147
+ // Last resort: create a basic skill with the description as the prompt
2148
+ console.warn('[TerminalSession] Could not extract skill content, creating basic skill');
2149
+ return `---
2150
+ id: ${skillName}
2151
+ name: ${skillName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
2152
+ description: ${description}
2153
+ type: prompt
2154
+ inputs: []
2155
+ ---
2156
+
2157
+ ${description}
2158
+
2159
+ Please help the user accomplish this task in the current repository.`;
2160
+ }
2161
+ // Safety instructions for process termination
2162
+ getSafetyInstructions() {
2163
+ return `## CRITICAL SAFETY RULES - READ FIRST
2164
+
2165
+ **⚠️ PORTS 8787 AND 5173 ARE FORBIDDEN - CLAUDEDESK RUNS HERE ⚠️**
2166
+
2167
+ You are running inside ClaudeDesk, which uses ports 8787 (API) and 5173 (UI). If you kill these ports, you will crash the system and lose this conversation.
2168
+
2169
+ **ABSOLUTELY FORBIDDEN COMMANDS:**
2170
+ - \`npx kill-port 8787\` - WILL CRASH CLAUDEDESK
2171
+ - \`npx kill-port 5173\` - WILL CRASH CLAUDEDESK
2172
+ - \`taskkill /IM node.exe /F\` - WILL CRASH CLAUDEDESK (kills ALL Node)
2173
+ - \`pkill node\` or \`killall node\` - WILL CRASH CLAUDEDESK
2174
+ - Any command that kills processes on port 8787 or 5173
2175
+ - Any command that kills all Node.js processes
2176
+
2177
+ **SAFE ALTERNATIVES for stopping apps:**
2178
+ - Kill by specific PID: \`taskkill /PID 12345 /F\`
2179
+ - Kill specific port (NOT 8787 or 5173): \`npx kill-port 3000\`
2180
+ - Stop Docker: \`docker stop container-name\`
2181
+ - Stop npm process: Find its PID first, then kill that specific PID
2182
+
2183
+ **Before killing ANY port, verify it is NOT 8787 or 5173.**`;
2184
+ }
2185
+ // Build worktree context for Claude prompt
2186
+ buildWorktreeContext(session) {
2187
+ if (!session.worktreeMode || !session.branch) {
2188
+ return '';
2189
+ }
2190
+ return `## Git Branch Context
2191
+
2192
+ You are working in an isolated worktree on branch **\`${session.branch}\`**.
2193
+
2194
+ **Key Information:**
2195
+ - **Current Branch:** \`${session.branch}\`
2196
+ - **Base Branch:** \`${session.baseBranch || 'main'}\`
2197
+ - **Worktree Path:** \`${session.worktreePath}\`
2198
+
2199
+ This is a feature branch isolated from the main repository. Changes you make here will not affect other branches or the main repository until merged.
2200
+
2201
+ **Git Workflow Tips:**
2202
+ - All your commits will be on the \`${session.branch}\` branch
2203
+ - You can freely experiment and make changes
2204
+ - When done, the branch can be pushed for PR review`;
2205
+ }
2206
+ // Build multi-repo context for Claude prompt
2207
+ buildMultiRepoContext(session) {
2208
+ if (!session.isMultiRepo) {
2209
+ return '';
2210
+ }
2211
+ const repos = session.repoIds
2212
+ .map(id => repoRegistry.get(id))
2213
+ .filter((repo) => repo !== undefined);
2214
+ const repoList = repos.map((repo, i) => `${i + 1}. **${repo.id}** - \`${repo.path}\``).join('\n');
2215
+ const primaryRepo = repos[0];
2216
+ return `## Multi-Repository Context
2217
+
2218
+ You are working across ${repos.length} repositories:
2219
+
2220
+ ${repoList}
2221
+
2222
+ ### CRITICAL: Explicit Repo Targeting Required
2223
+
2224
+ For ANY write operation (file edits, git commits, running commands), you MUST:
2225
+ 1. Explicitly state which repository you are targeting
2226
+ 2. Use the full path or clearly identify the repo by name
2227
+ 3. Never assume which repo the user means for writes
2228
+
2229
+ **Example - CORRECT:**
2230
+ - "I'll update the API endpoint in **${repos[1]?.id || 'repo2'}**/src/routes.ts"
2231
+ - "Committing changes to **${repos[0]?.id || 'repo1'}**"
2232
+
2233
+ **Example - WRONG:**
2234
+ - "I'll update the file src/routes.ts" (ambiguous - which repo?)
2235
+
2236
+ ### Working Directory
2237
+ Primary: \`${primaryRepo?.path || 'unknown'}\` (${primaryRepo?.id || 'unknown'})
2238
+ Access other repos via their absolute paths.`;
2239
+ }
2240
+ // Build prompt with conversation context
2241
+ buildPromptWithContext(session, currentMessage) {
2242
+ // Truncate very long messages to avoid "Prompt is too long" errors
2243
+ const MAX_MESSAGE_LENGTH = 50000; // ~50k chars is reasonable
2244
+ let message = currentMessage;
2245
+ if (message.length > MAX_MESSAGE_LENGTH) {
2246
+ message = message.slice(0, MAX_MESSAGE_LENGTH) + '\n\n... [Message truncated - was ' + currentMessage.length + ' chars]';
2247
+ console.log(`[TerminalSession] Truncated message from ${currentMessage.length} to ${MAX_MESSAGE_LENGTH} chars`);
2248
+ }
2249
+ // If we're resuming a Claude session, don't add conversation history
2250
+ // Claude Code's --resume flag already provides full context
2251
+ if (session.claudeSessionId) {
2252
+ return message;
2253
+ }
2254
+ // Get recent messages for context (limit to last 10 exchanges to avoid token limits)
2255
+ const contextMessages = session.messages
2256
+ .filter(m => !m.isStreaming) // Exclude streaming messages
2257
+ .slice(-20); // Last 20 messages (10 exchanges)
2258
+ // If no previous context, just return the current message
2259
+ if (contextMessages.length <= 1) {
2260
+ return currentMessage;
2261
+ }
2262
+ // Build conversation context
2263
+ const context = contextMessages
2264
+ .slice(0, -1) // Exclude the current user message we just added
2265
+ .map(m => {
2266
+ const role = m.role === 'user' ? 'User' : 'Assistant';
2267
+ // Truncate long messages in context
2268
+ const content = m.content.length > 2000
2269
+ ? m.content.slice(0, 2000) + '... [truncated]'
2270
+ : m.content;
2271
+ return `**${role}:** ${content}`;
2272
+ })
2273
+ .join('\n\n');
2274
+ return `## Previous Conversation Context
2275
+ The following is our conversation history in this session. Use this context to understand what we've been working on.
2276
+
2277
+ ${context}
2278
+
2279
+ ---
2280
+
2281
+ ## Current Request
2282
+ ${message}`;
2283
+ }
2284
+ // Build attachment context for Claude to read attached files
2285
+ buildAttachmentContext(attachments) {
2286
+ const fileList = attachments.map(a => {
2287
+ const isImage = a.mimeType.startsWith('image/');
2288
+ const isPdf = a.mimeType === 'application/pdf';
2289
+ const type = isImage ? 'image' : isPdf ? 'PDF document' : 'text/code file';
2290
+ return `- **${a.originalName}** (${type}): \`${a.path}\``;
2291
+ }).join('\n');
2292
+ return `## Attached Files for Analysis
2293
+
2294
+ The user has attached the following files for you to analyze. Use your Read tool to access them:
2295
+
2296
+ ${fileList}
2297
+
2298
+ **Instructions:**
2299
+ - For images: Read them to see their visual contents
2300
+ - For PDFs: Read them to extract and analyze the content
2301
+ - For text/code files: Read them to see the contents
2302
+ - Reference specific file contents in your response as needed`;
2303
+ }
2304
+ generateId() {
2305
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
2306
+ }
2307
+ }
2308
+ // Singleton instance
2309
+ export const terminalSessionManager = new TerminalSessionManager();
2310
+ // Cleanup old sessions every hour
2311
+ setInterval(() => {
2312
+ terminalSessionManager.cleanupOldSessions();
2313
+ }, 60 * 60 * 1000);
2314
+ // Cleanup orphaned attachments (files older than 24 hours) every hour
2315
+ function cleanupOrphanedAttachments() {
2316
+ if (!existsSync(ATTACHMENTS_DIR))
2317
+ return;
2318
+ const maxAgeMs = 24 * 60 * 60 * 1000; // 24 hours
2319
+ const now = Date.now();
2320
+ try {
2321
+ const files = readdirSync(ATTACHMENTS_DIR);
2322
+ let cleanedCount = 0;
2323
+ for (const file of files) {
2324
+ const filePath = join(ATTACHMENTS_DIR, file);
2325
+ const stat = statSync(filePath);
2326
+ if (now - stat.mtimeMs > maxAgeMs) {
2327
+ unlinkSync(filePath);
2328
+ cleanedCount++;
2329
+ }
2330
+ }
2331
+ if (cleanedCount > 0) {
2332
+ console.log(`[TerminalSession] Cleaned up ${cleanedCount} orphaned attachments`);
2333
+ }
2334
+ }
2335
+ catch (error) {
2336
+ console.error('[TerminalSession] Failed to cleanup orphaned attachments:', error);
2337
+ }
2338
+ }
2339
+ setInterval(cleanupOrphanedAttachments, 60 * 60 * 1000);
2340
+ //# sourceMappingURL=terminal-session.js.map