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.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/config/repos.example.json +128 -0
- package/config/settings.example.json +64 -0
- package/config/skills/code-review.md +76 -0
- package/config/skills/full-check.md +26 -0
- package/config/skills/lint-fix.md +23 -0
- package/dist/api/agent-routes.d.ts +2 -0
- package/dist/api/agent-routes.d.ts.map +1 -0
- package/dist/api/agent-routes.js +251 -0
- package/dist/api/agent-routes.js.map +1 -0
- package/dist/api/app-routes.d.ts +2 -0
- package/dist/api/app-routes.d.ts.map +1 -0
- package/dist/api/app-routes.js +150 -0
- package/dist/api/app-routes.js.map +1 -0
- package/dist/api/docker-routes.d.ts +2 -0
- package/dist/api/docker-routes.d.ts.map +1 -0
- package/dist/api/docker-routes.js +167 -0
- package/dist/api/docker-routes.js.map +1 -0
- package/dist/api/middleware.d.ts +6 -0
- package/dist/api/middleware.d.ts.map +1 -0
- package/dist/api/middleware.js +293 -0
- package/dist/api/middleware.js.map +1 -0
- package/dist/api/pin-auth.d.ts +65 -0
- package/dist/api/pin-auth.d.ts.map +1 -0
- package/dist/api/pin-auth.js +218 -0
- package/dist/api/pin-auth.js.map +1 -0
- package/dist/api/routes.d.ts +2 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +473 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/settings-routes.d.ts +2 -0
- package/dist/api/settings-routes.d.ts.map +1 -0
- package/dist/api/settings-routes.js +570 -0
- package/dist/api/settings-routes.js.map +1 -0
- package/dist/api/skill-routes.d.ts +2 -0
- package/dist/api/skill-routes.d.ts.map +1 -0
- package/dist/api/skill-routes.js +88 -0
- package/dist/api/skill-routes.js.map +1 -0
- package/dist/api/terminal-routes.d.ts +2 -0
- package/dist/api/terminal-routes.d.ts.map +1 -0
- package/dist/api/terminal-routes.js +3524 -0
- package/dist/api/terminal-routes.js.map +1 -0
- package/dist/api/tunnel-routes.d.ts +2 -0
- package/dist/api/tunnel-routes.d.ts.map +1 -0
- package/dist/api/tunnel-routes.js +196 -0
- package/dist/api/tunnel-routes.js.map +1 -0
- package/dist/api/workspace-routes.d.ts +3 -0
- package/dist/api/workspace-routes.d.ts.map +1 -0
- package/dist/api/workspace-routes.js +649 -0
- package/dist/api/workspace-routes.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/assets/index-B4r0njGe.js +780 -0
- package/dist/client/assets/index-CY_9MyE0.css +1 -0
- package/dist/client/favicon.svg +5 -0
- package/dist/client/icons/icon-192.svg +5 -0
- package/dist/client/icons/icon-512.svg +5 -0
- package/dist/client/icons/logo-with-message.png +0 -0
- package/dist/client/icons/logo.png +0 -0
- package/dist/client/index.html +25 -0
- package/dist/client/manifest.json +62 -0
- package/dist/client/sw.js +243 -0
- package/dist/config/agent-usage.d.ts +34 -0
- package/dist/config/agent-usage.d.ts.map +1 -0
- package/dist/config/agent-usage.js +87 -0
- package/dist/config/agent-usage.js.map +1 -0
- package/dist/config/repos.d.ts +34 -0
- package/dist/config/repos.d.ts.map +1 -0
- package/dist/config/repos.js +412 -0
- package/dist/config/repos.js.map +1 -0
- package/dist/config/settings.d.ts +634 -0
- package/dist/config/settings.d.ts.map +1 -0
- package/dist/config/settings.js +459 -0
- package/dist/config/settings.js.map +1 -0
- package/dist/config/skills.d.ts +18 -0
- package/dist/config/skills.d.ts.map +1 -0
- package/dist/config/skills.js +174 -0
- package/dist/config/skills.js.map +1 -0
- package/dist/config/workspaces.d.ts +961 -0
- package/dist/config/workspaces.d.ts.map +1 -0
- package/dist/config/workspaces.js +482 -0
- package/dist/config/workspaces.js.map +1 -0
- package/dist/core/app-manager.d.ts +85 -0
- package/dist/core/app-manager.d.ts.map +1 -0
- package/dist/core/app-manager.js +447 -0
- package/dist/core/app-manager.js.map +1 -0
- package/dist/core/claude-invoker.d.ts +49 -0
- package/dist/core/claude-invoker.d.ts.map +1 -0
- package/dist/core/claude-invoker.js +583 -0
- package/dist/core/claude-invoker.js.map +1 -0
- package/dist/core/claude-session-reader.d.ts +25 -0
- package/dist/core/claude-session-reader.d.ts.map +1 -0
- package/dist/core/claude-session-reader.js +184 -0
- package/dist/core/claude-session-reader.js.map +1 -0
- package/dist/core/claude-usage-query.d.ts +78 -0
- package/dist/core/claude-usage-query.d.ts.map +1 -0
- package/dist/core/claude-usage-query.js +294 -0
- package/dist/core/claude-usage-query.js.map +1 -0
- package/dist/core/git-credential-helper.d.ts +57 -0
- package/dist/core/git-credential-helper.d.ts.map +1 -0
- package/dist/core/git-credential-helper.js +176 -0
- package/dist/core/git-credential-helper.js.map +1 -0
- package/dist/core/git-sandbox.d.ts +135 -0
- package/dist/core/git-sandbox.d.ts.map +1 -0
- package/dist/core/git-sandbox.js +907 -0
- package/dist/core/git-sandbox.js.map +1 -0
- package/dist/core/github-integration.d.ts +66 -0
- package/dist/core/github-integration.d.ts.map +1 -0
- package/dist/core/github-integration.js +350 -0
- package/dist/core/github-integration.js.map +1 -0
- package/dist/core/github-oauth.d.ts +88 -0
- package/dist/core/github-oauth.d.ts.map +1 -0
- package/dist/core/github-oauth.js +244 -0
- package/dist/core/github-oauth.js.map +1 -0
- package/dist/core/gitlab-integration.d.ts +66 -0
- package/dist/core/gitlab-integration.d.ts.map +1 -0
- package/dist/core/gitlab-integration.js +353 -0
- package/dist/core/gitlab-integration.js.map +1 -0
- package/dist/core/gitlab-oauth.d.ts +100 -0
- package/dist/core/gitlab-oauth.d.ts.map +1 -0
- package/dist/core/gitlab-oauth.js +366 -0
- package/dist/core/gitlab-oauth.js.map +1 -0
- package/dist/core/insights-extractor.d.ts +68 -0
- package/dist/core/insights-extractor.d.ts.map +1 -0
- package/dist/core/insights-extractor.js +402 -0
- package/dist/core/insights-extractor.js.map +1 -0
- package/dist/core/logger.d.ts +27 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +70 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/process-runner.d.ts +27 -0
- package/dist/core/process-runner.d.ts.map +1 -0
- package/dist/core/process-runner.js +147 -0
- package/dist/core/process-runner.js.map +1 -0
- package/dist/core/project-detector.d.ts +30 -0
- package/dist/core/project-detector.d.ts.map +1 -0
- package/dist/core/project-detector.js +482 -0
- package/dist/core/project-detector.js.map +1 -0
- package/dist/core/qr-generator.d.ts +18 -0
- package/dist/core/qr-generator.d.ts.map +1 -0
- package/dist/core/qr-generator.js +61 -0
- package/dist/core/qr-generator.js.map +1 -0
- package/dist/core/remote-tunnel-manager.d.ts +59 -0
- package/dist/core/remote-tunnel-manager.d.ts.map +1 -0
- package/dist/core/remote-tunnel-manager.js +235 -0
- package/dist/core/remote-tunnel-manager.js.map +1 -0
- package/dist/core/shared-docker-manager.d.ts +41 -0
- package/dist/core/shared-docker-manager.d.ts.map +1 -0
- package/dist/core/shared-docker-manager.js +409 -0
- package/dist/core/shared-docker-manager.js.map +1 -0
- package/dist/core/skill-executor.d.ts +25 -0
- package/dist/core/skill-executor.d.ts.map +1 -0
- package/dist/core/skill-executor.js +171 -0
- package/dist/core/skill-executor.js.map +1 -0
- package/dist/core/terminal-session.d.ts +149 -0
- package/dist/core/terminal-session.d.ts.map +1 -0
- package/dist/core/terminal-session.js +2340 -0
- package/dist/core/terminal-session.js.map +1 -0
- package/dist/core/tunnel-manager.d.ts +35 -0
- package/dist/core/tunnel-manager.d.ts.map +1 -0
- package/dist/core/tunnel-manager.js +137 -0
- package/dist/core/tunnel-manager.js.map +1 -0
- package/dist/core/usage-manager.d.ts +57 -0
- package/dist/core/usage-manager.d.ts.map +1 -0
- package/dist/core/usage-manager.js +363 -0
- package/dist/core/usage-manager.js.map +1 -0
- package/dist/core/ws-manager.d.ts +39 -0
- package/dist/core/ws-manager.d.ts.map +1 -0
- package/dist/core/ws-manager.js +190 -0
- package/dist/core/ws-manager.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +868 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +119 -0
- package/dist/types.js.map +1 -0
- 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
|