@vibecompany/247-cli 0.1.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/dist/config.d.ts +29 -0
- package/agent/dist/config.d.ts.map +1 -0
- package/agent/dist/config.js +56 -0
- package/agent/dist/config.js.map +1 -0
- package/agent/dist/db/environments.d.ts +65 -0
- package/agent/dist/db/environments.d.ts.map +1 -0
- package/agent/dist/db/environments.js +243 -0
- package/agent/dist/db/environments.js.map +1 -0
- package/agent/dist/db/history.d.ts +37 -0
- package/agent/dist/db/history.d.ts.map +1 -0
- package/agent/dist/db/history.js +98 -0
- package/agent/dist/db/history.js.map +1 -0
- package/agent/dist/db/index.d.ts +37 -0
- package/agent/dist/db/index.d.ts.map +1 -0
- package/agent/dist/db/index.js +225 -0
- package/agent/dist/db/index.js.map +1 -0
- package/agent/dist/db/schema.d.ts +70 -0
- package/agent/dist/db/schema.d.ts.map +1 -0
- package/agent/dist/db/schema.js +79 -0
- package/agent/dist/db/schema.js.map +1 -0
- package/agent/dist/db/sessions.d.ts +75 -0
- package/agent/dist/db/sessions.d.ts.map +1 -0
- package/agent/dist/db/sessions.js +244 -0
- package/agent/dist/db/sessions.js.map +1 -0
- package/agent/dist/editor.d.ts +18 -0
- package/agent/dist/editor.d.ts.map +1 -0
- package/agent/dist/editor.js +220 -0
- package/agent/dist/editor.js.map +1 -0
- package/agent/dist/environments.d.ts +59 -0
- package/agent/dist/environments.d.ts.map +1 -0
- package/agent/dist/environments.js +229 -0
- package/agent/dist/environments.js.map +1 -0
- package/agent/dist/git.d.ts +39 -0
- package/agent/dist/git.d.ts.map +1 -0
- package/agent/dist/git.js +436 -0
- package/agent/dist/git.js.map +1 -0
- package/agent/dist/index.d.ts +2 -0
- package/agent/dist/index.d.ts.map +1 -0
- package/agent/dist/index.js +17 -0
- package/agent/dist/index.js.map +1 -0
- package/agent/dist/server.d.ts +2 -0
- package/agent/dist/server.d.ts.map +1 -0
- package/agent/dist/server.js +1062 -0
- package/agent/dist/server.js.map +1 -0
- package/agent/dist/terminal.d.ts +14 -0
- package/agent/dist/terminal.d.ts.map +1 -0
- package/agent/dist/terminal.js +115 -0
- package/agent/dist/terminal.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +25 -14
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/profile.d.ts +3 -0
- package/dist/commands/profile.d.ts.map +1 -0
- package/dist/commands/profile.js +156 -0
- package/dist/commands/profile.js.map +1 -0
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +21 -10
- package/dist/commands/start.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +30 -5
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +85 -12
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/process.d.ts +2 -1
- package/dist/lib/process.d.ts.map +1 -1
- package/dist/lib/process.js +7 -3
- package/dist/lib/process.js.map +1 -1
- package/hooks/.claude-plugin/plugin.json +5 -0
- package/hooks/hooks/hooks.json +61 -0
- package/hooks/scripts/notify-status.sh +89 -0
- package/package.json +22 -5
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { createServer as createHttpServer } from 'http';
|
|
5
|
+
import httpProxy from 'http-proxy';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { createTerminal } from './terminal.js';
|
|
8
|
+
import { initEditor, getOrStartEditor, stopEditor, getEditorStatus, getAllEditors, updateEditorActivity, shutdownAllEditors, } from './editor.js';
|
|
9
|
+
// Database imports
|
|
10
|
+
import { initDatabase, closeDatabase, migrateEnvironmentsFromJson, RETENTION_CONFIG, } from './db/index.js';
|
|
11
|
+
import { getEnvironmentsMetadata, getEnvironmentMetadata, getEnvironment, createEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentVariables, setSessionEnvironment, getSessionEnvironment, clearSessionEnvironment, ensureDefaultEnvironment, } from './db/environments.js';
|
|
12
|
+
import * as sessionsDb from './db/sessions.js';
|
|
13
|
+
import * as historyDb from './db/history.js';
|
|
14
|
+
import { cloneRepo, extractProjectName, listFiles, getFileContent, openFileInEditor, getChangesSummary, } from './git.js';
|
|
15
|
+
import { config } from './config.js';
|
|
16
|
+
// Store by tmux session name - single source of truth for status
|
|
17
|
+
const tmuxSessionStatus = new Map();
|
|
18
|
+
// Track active WebSocket connections per session
|
|
19
|
+
const activeConnections = new Map();
|
|
20
|
+
// Track WebSocket subscribers for status updates (real-time push)
|
|
21
|
+
const statusSubscribers = new Set();
|
|
22
|
+
// Broadcast status update to all subscribers
|
|
23
|
+
function broadcastStatusUpdate(session) {
|
|
24
|
+
if (statusSubscribers.size === 0)
|
|
25
|
+
return;
|
|
26
|
+
const message = { type: 'status-update', session };
|
|
27
|
+
const messageStr = JSON.stringify(message);
|
|
28
|
+
for (const ws of statusSubscribers) {
|
|
29
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
30
|
+
ws.send(messageStr);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
console.log(`[Status WS] Broadcast status update for ${session.name}: ${session.status} to ${statusSubscribers.size} subscribers`);
|
|
34
|
+
}
|
|
35
|
+
// Broadcast session removed to all subscribers
|
|
36
|
+
function broadcastSessionRemoved(sessionName) {
|
|
37
|
+
if (statusSubscribers.size === 0)
|
|
38
|
+
return;
|
|
39
|
+
const message = { type: 'session-removed', sessionName };
|
|
40
|
+
const messageStr = JSON.stringify(message);
|
|
41
|
+
for (const ws of statusSubscribers) {
|
|
42
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
43
|
+
ws.send(messageStr);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(`[Status WS] Broadcast session removed: ${sessionName}`);
|
|
47
|
+
}
|
|
48
|
+
// Broadcast session archived to all subscribers
|
|
49
|
+
function broadcastSessionArchived(sessionName, session) {
|
|
50
|
+
if (statusSubscribers.size === 0)
|
|
51
|
+
return;
|
|
52
|
+
const message = { type: 'session-archived', sessionName, session };
|
|
53
|
+
const messageStr = JSON.stringify(message);
|
|
54
|
+
for (const ws of statusSubscribers) {
|
|
55
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
56
|
+
ws.send(messageStr);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
console.log(`[Status WS] Broadcast session archived: ${sessionName}`);
|
|
60
|
+
}
|
|
61
|
+
// Generate human-readable session names with project prefix
|
|
62
|
+
function generateSessionName(project) {
|
|
63
|
+
const adjectives = ['brave', 'swift', 'calm', 'bold', 'wise', 'keen', 'fair', 'wild', 'bright', 'cool'];
|
|
64
|
+
const nouns = ['lion', 'hawk', 'wolf', 'bear', 'fox', 'owl', 'deer', 'lynx', 'eagle', 'tiger'];
|
|
65
|
+
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
66
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
67
|
+
const num = Math.floor(Math.random() * 100);
|
|
68
|
+
return `${project}--${adj}-${noun}-${num}`;
|
|
69
|
+
}
|
|
70
|
+
// Clean up stale status entries (called periodically)
|
|
71
|
+
function cleanupStatusMaps() {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const STALE_THRESHOLD = RETENTION_CONFIG.sessionMaxAge;
|
|
74
|
+
let cleanedTmux = 0;
|
|
75
|
+
// Get active tmux sessions
|
|
76
|
+
let activeSessions = new Set();
|
|
77
|
+
try {
|
|
78
|
+
const output = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
|
|
79
|
+
activeSessions = new Set(output.trim().split('\n').filter(Boolean));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// No tmux sessions exist
|
|
83
|
+
}
|
|
84
|
+
// Clean tmuxSessionStatus - remove if session doesn't exist OR is stale
|
|
85
|
+
for (const [sessionName, status] of tmuxSessionStatus) {
|
|
86
|
+
const sessionExists = activeSessions.has(sessionName);
|
|
87
|
+
const isStale = (now - status.lastActivity) > STALE_THRESHOLD;
|
|
88
|
+
if (!sessionExists || isStale) {
|
|
89
|
+
tmuxSessionStatus.delete(sessionName);
|
|
90
|
+
cleanedTmux++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (cleanedTmux > 0) {
|
|
94
|
+
console.log(`[Status Cleanup] Removed ${cleanedTmux} stale status entries from memory`);
|
|
95
|
+
}
|
|
96
|
+
// Also cleanup SQLite database (pass archivedMaxAge for archived session cleanup)
|
|
97
|
+
const dbSessionsCleaned = sessionsDb.cleanupStaleSessions(STALE_THRESHOLD, RETENTION_CONFIG.archivedMaxAge);
|
|
98
|
+
const dbHistoryCleaned = historyDb.cleanupOldHistory(RETENTION_CONFIG.historyMaxAge);
|
|
99
|
+
if (dbSessionsCleaned > 0 || dbHistoryCleaned > 0) {
|
|
100
|
+
console.log(`[DB Cleanup] Sessions: ${dbSessionsCleaned}, History: ${dbHistoryCleaned}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Run cleanup every hour
|
|
104
|
+
setInterval(cleanupStatusMaps, 60 * 60 * 1000);
|
|
105
|
+
export async function createServer() {
|
|
106
|
+
const app = express();
|
|
107
|
+
app.use(cors());
|
|
108
|
+
app.use(express.json());
|
|
109
|
+
const server = createHttpServer(app);
|
|
110
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
111
|
+
// Initialize editor manager (cleans up orphan code-server processes)
|
|
112
|
+
const typedConfig = config;
|
|
113
|
+
await initEditor(typedConfig.editor, config.projects.basePath);
|
|
114
|
+
// Initialize SQLite database
|
|
115
|
+
const db = initDatabase();
|
|
116
|
+
// Migrate environments from JSON if this is a fresh database
|
|
117
|
+
migrateEnvironmentsFromJson(db);
|
|
118
|
+
// Ensure default environment exists
|
|
119
|
+
ensureDefaultEnvironment();
|
|
120
|
+
// Reconcile sessions with active tmux sessions
|
|
121
|
+
let activeTmuxSessions = new Set();
|
|
122
|
+
try {
|
|
123
|
+
const output = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
|
|
124
|
+
activeTmuxSessions = new Set(output.trim().split('\n').filter(Boolean));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// No tmux sessions exist
|
|
128
|
+
}
|
|
129
|
+
sessionsDb.reconcileWithTmux(activeTmuxSessions);
|
|
130
|
+
// Populate in-memory Map from database (for backward compatibility during transition)
|
|
131
|
+
const dbSessions = sessionsDb.getAllSessions();
|
|
132
|
+
for (const session of dbSessions) {
|
|
133
|
+
tmuxSessionStatus.set(session.name, sessionsDb.toHookStatus(session));
|
|
134
|
+
}
|
|
135
|
+
console.log(`[DB] Loaded ${dbSessions.length} sessions from database`);
|
|
136
|
+
// Create proxy for code-server
|
|
137
|
+
const editorProxy = httpProxy.createProxyServer({
|
|
138
|
+
ws: true,
|
|
139
|
+
changeOrigin: true,
|
|
140
|
+
});
|
|
141
|
+
editorProxy.on('error', (err, _req, res) => {
|
|
142
|
+
console.error('[Editor Proxy] HTTP Error:', err.message);
|
|
143
|
+
if (res && 'writeHead' in res) {
|
|
144
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ error: 'Editor proxy error', message: err.message }));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// WebSocket proxy events for debugging
|
|
149
|
+
editorProxy.on('proxyReqWs', (proxyReq, _req, _socket) => {
|
|
150
|
+
console.log('[Editor Proxy] WS request to:', proxyReq.path);
|
|
151
|
+
});
|
|
152
|
+
editorProxy.on('open', (_proxySocket) => {
|
|
153
|
+
console.log('[Editor Proxy] WS connection opened');
|
|
154
|
+
});
|
|
155
|
+
editorProxy.on('close', (_res, _socket, _head) => {
|
|
156
|
+
console.log('[Editor Proxy] WS connection closed');
|
|
157
|
+
});
|
|
158
|
+
// WebSocket terminal handler
|
|
159
|
+
wss.on('connection', async (ws, req) => {
|
|
160
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
161
|
+
const project = url.searchParams.get('project');
|
|
162
|
+
const urlSessionName = url.searchParams.get('session');
|
|
163
|
+
const environmentId = url.searchParams.get('environment'); // Environment to use
|
|
164
|
+
const sessionName = urlSessionName || generateSessionName(project || 'unknown');
|
|
165
|
+
// Validate project - if whitelist is empty, allow any project
|
|
166
|
+
const whitelist = config.projects.whitelist;
|
|
167
|
+
const hasWhitelist = whitelist && whitelist.length > 0;
|
|
168
|
+
const isAllowed = hasWhitelist ? whitelist.includes(project) : true;
|
|
169
|
+
if (!project || !isAllowed) {
|
|
170
|
+
ws.close(1008, 'Project not allowed');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
|
|
174
|
+
console.log(`New terminal connection for project: ${project}`);
|
|
175
|
+
console.log(`Project path: ${projectPath}`);
|
|
176
|
+
if (environmentId) {
|
|
177
|
+
const env = getEnvironment(environmentId);
|
|
178
|
+
console.log(`Using environment: ${env?.name || environmentId}`);
|
|
179
|
+
}
|
|
180
|
+
// Verify path exists
|
|
181
|
+
const fs = await import('fs');
|
|
182
|
+
if (!fs.existsSync(projectPath)) {
|
|
183
|
+
console.error(`Path does not exist: ${projectPath}`);
|
|
184
|
+
ws.close(1008, 'Project path not found');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Get environment variables for this session
|
|
188
|
+
const envVars = getEnvironmentVariables(environmentId || undefined);
|
|
189
|
+
let terminal;
|
|
190
|
+
try {
|
|
191
|
+
terminal = createTerminal(projectPath, sessionName, envVars);
|
|
192
|
+
// Track which environment this session uses
|
|
193
|
+
if (environmentId) {
|
|
194
|
+
setSessionEnvironment(sessionName, environmentId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error('Failed to create terminal:', err);
|
|
199
|
+
// Clean up any partial state
|
|
200
|
+
clearSessionEnvironment(sessionName);
|
|
201
|
+
ws.close(1011, 'Failed to create terminal');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Track this connection
|
|
205
|
+
if (!activeConnections.has(sessionName)) {
|
|
206
|
+
activeConnections.set(sessionName, new Set());
|
|
207
|
+
}
|
|
208
|
+
activeConnections.get(sessionName).add(ws);
|
|
209
|
+
console.log(`[Connections] Added connection to '${sessionName}' (total: ${activeConnections.get(sessionName).size})`);
|
|
210
|
+
// If reconnecting to an existing session, send the scrollback history
|
|
211
|
+
if (terminal.isExistingSession()) {
|
|
212
|
+
console.log(`Reconnecting to existing session '${sessionName}', sending history...`);
|
|
213
|
+
terminal.captureHistory(10000)
|
|
214
|
+
.then((history) => {
|
|
215
|
+
if (history && ws.readyState === WebSocket.OPEN) {
|
|
216
|
+
ws.send(JSON.stringify({
|
|
217
|
+
type: 'history',
|
|
218
|
+
data: history,
|
|
219
|
+
lines: history.split('\n').length
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.catch((err) => {
|
|
224
|
+
console.error(`[Terminal] Failed to capture initial history for '${sessionName}':`, err);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// New session created - register in DB and broadcast to subscribers
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
const sessionProject = project;
|
|
231
|
+
// Store in database (may be undefined in test environments)
|
|
232
|
+
let createdAt = now;
|
|
233
|
+
try {
|
|
234
|
+
const dbSession = sessionsDb.upsertSession(sessionName, {
|
|
235
|
+
project: sessionProject,
|
|
236
|
+
status: 'init',
|
|
237
|
+
attentionReason: undefined,
|
|
238
|
+
lastEvent: 'SessionCreated',
|
|
239
|
+
lastActivity: now,
|
|
240
|
+
lastStatusChange: now,
|
|
241
|
+
environmentId: environmentId || undefined,
|
|
242
|
+
});
|
|
243
|
+
if (dbSession?.created_at) {
|
|
244
|
+
createdAt = dbSession.created_at;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
console.error(`[Session] Failed to persist session '${sessionName}':`, err);
|
|
249
|
+
}
|
|
250
|
+
// Update in-memory map
|
|
251
|
+
tmuxSessionStatus.set(sessionName, {
|
|
252
|
+
status: 'init',
|
|
253
|
+
lastEvent: 'SessionCreated',
|
|
254
|
+
lastActivity: now,
|
|
255
|
+
lastStatusChange: now,
|
|
256
|
+
project: sessionProject,
|
|
257
|
+
});
|
|
258
|
+
// Get environment info for the broadcast
|
|
259
|
+
const envMeta = environmentId ? getEnvironmentMetadata(environmentId) : undefined;
|
|
260
|
+
// Broadcast new session to all subscribers
|
|
261
|
+
broadcastStatusUpdate({
|
|
262
|
+
name: sessionName,
|
|
263
|
+
project: sessionProject,
|
|
264
|
+
status: 'init',
|
|
265
|
+
statusSource: 'hook',
|
|
266
|
+
lastEvent: 'SessionCreated',
|
|
267
|
+
lastStatusChange: now,
|
|
268
|
+
createdAt,
|
|
269
|
+
lastActivity: undefined,
|
|
270
|
+
environmentId: environmentId || undefined,
|
|
271
|
+
environment: envMeta ? {
|
|
272
|
+
id: envMeta.id,
|
|
273
|
+
name: envMeta.name,
|
|
274
|
+
provider: envMeta.provider,
|
|
275
|
+
icon: envMeta.icon,
|
|
276
|
+
isDefault: envMeta.isDefault,
|
|
277
|
+
} : undefined,
|
|
278
|
+
});
|
|
279
|
+
console.log(`[Session] New session '${sessionName}' created and broadcast to ${statusSubscribers.size} subscribers`);
|
|
280
|
+
}
|
|
281
|
+
// Forward terminal output to WebSocket - store handler for cleanup
|
|
282
|
+
const dataHandler = (data) => {
|
|
283
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
284
|
+
ws.send(data);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
terminal.onData(dataHandler);
|
|
288
|
+
const exitHandler = ({ exitCode }) => {
|
|
289
|
+
console.log(`Terminal exited with code ${exitCode}`);
|
|
290
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
291
|
+
ws.close(1000, 'Terminal closed');
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
terminal.onExit(exitHandler);
|
|
295
|
+
// Handle incoming messages
|
|
296
|
+
ws.on('message', (data) => {
|
|
297
|
+
try {
|
|
298
|
+
const msg = JSON.parse(data.toString());
|
|
299
|
+
switch (msg.type) {
|
|
300
|
+
case 'input':
|
|
301
|
+
terminal.write(msg.data);
|
|
302
|
+
// Update status to 'working' when user sends input
|
|
303
|
+
// This helps track when the user provides input that might resume Claude
|
|
304
|
+
if (msg.data.includes('\r') || msg.data.includes('\n')) {
|
|
305
|
+
const existing = tmuxSessionStatus.get(sessionName);
|
|
306
|
+
const currentStatus = existing?.status;
|
|
307
|
+
// Only set to working if Claude was waiting for attention (input)
|
|
308
|
+
// Don't overwrite: working (already active), idle (no session)
|
|
309
|
+
if (currentStatus === 'needs_attention' && existing?.attentionReason === 'input') {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
tmuxSessionStatus.set(sessionName, {
|
|
312
|
+
status: 'working',
|
|
313
|
+
lastEvent: 'UserInput',
|
|
314
|
+
lastActivity: now,
|
|
315
|
+
lastStatusChange: now,
|
|
316
|
+
project,
|
|
317
|
+
});
|
|
318
|
+
console.log(`[Status] Updated '${sessionName}' to 'working' (user input)`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
case 'resize':
|
|
323
|
+
terminal.resize(msg.cols, msg.rows);
|
|
324
|
+
break;
|
|
325
|
+
case 'start-claude':
|
|
326
|
+
terminal.write('claude\r');
|
|
327
|
+
break;
|
|
328
|
+
case 'ping':
|
|
329
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
330
|
+
break;
|
|
331
|
+
case 'request-history':
|
|
332
|
+
terminal.captureHistory(msg.lines || 10000)
|
|
333
|
+
.then((history) => {
|
|
334
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
335
|
+
ws.send(JSON.stringify({
|
|
336
|
+
type: 'history',
|
|
337
|
+
data: history,
|
|
338
|
+
lines: history.split('\n').length
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
.catch((err) => {
|
|
343
|
+
console.error(`[Terminal] Failed to capture history for '${sessionName}':`, err);
|
|
344
|
+
});
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
console.error('Failed to parse message:', err);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
ws.on('close', () => {
|
|
353
|
+
console.log(`Client disconnected, tmux session '${sessionName}' preserved`);
|
|
354
|
+
// Remove terminal event listeners to prevent memory leaks
|
|
355
|
+
try {
|
|
356
|
+
terminal.removeAllListeners?.('data');
|
|
357
|
+
terminal.removeAllListeners?.('exit');
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Terminal may not support removeAllListeners, ignore
|
|
361
|
+
}
|
|
362
|
+
// Detach from tmux instead of killing - session stays alive
|
|
363
|
+
terminal.detach();
|
|
364
|
+
// Remove from active connections
|
|
365
|
+
const connections = activeConnections.get(sessionName);
|
|
366
|
+
if (connections) {
|
|
367
|
+
connections.delete(ws);
|
|
368
|
+
console.log(`[Connections] Removed connection from '${sessionName}' (remaining: ${connections.size})`);
|
|
369
|
+
if (connections.size === 0) {
|
|
370
|
+
activeConnections.delete(sessionName);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
ws.on('error', (err) => {
|
|
375
|
+
console.error('WebSocket error:', err);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
// REST API endpoints
|
|
379
|
+
app.get('/api/projects', (_req, res) => {
|
|
380
|
+
res.json(config.projects.whitelist);
|
|
381
|
+
});
|
|
382
|
+
// Dynamic folder listing - scans basePath for directories
|
|
383
|
+
app.get('/api/folders', async (_req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
const fs = await import('fs/promises');
|
|
386
|
+
const basePath = config.projects.basePath.replace('~', process.env.HOME);
|
|
387
|
+
const entries = await fs.readdir(basePath, { withFileTypes: true });
|
|
388
|
+
const folders = entries
|
|
389
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
390
|
+
.map(entry => entry.name)
|
|
391
|
+
.sort();
|
|
392
|
+
res.json(folders);
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
console.error('Failed to list folders:', err);
|
|
396
|
+
res.status(500).json({ error: 'Failed to list folders' });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
// Clone a git repository
|
|
400
|
+
app.post('/api/clone', async (req, res) => {
|
|
401
|
+
const { repoUrl, projectName } = req.body;
|
|
402
|
+
if (!repoUrl) {
|
|
403
|
+
return res.status(400).json({ error: 'Missing repoUrl' });
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const result = await cloneRepo(repoUrl, config.projects.basePath, projectName);
|
|
407
|
+
if (result.success) {
|
|
408
|
+
res.json({
|
|
409
|
+
success: true,
|
|
410
|
+
projectName: result.projectName,
|
|
411
|
+
path: result.path,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
res.status(400).json({
|
|
416
|
+
success: false,
|
|
417
|
+
error: result.error,
|
|
418
|
+
projectName: result.projectName,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
console.error('Clone error:', err);
|
|
424
|
+
res.status(500).json({ error: 'Internal server error during clone' });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// Preview what project name would be extracted from a URL
|
|
428
|
+
app.get('/api/clone/preview', (req, res) => {
|
|
429
|
+
const url = req.query.url;
|
|
430
|
+
if (!url) {
|
|
431
|
+
return res.status(400).json({ error: 'Missing url parameter' });
|
|
432
|
+
}
|
|
433
|
+
const projectName = extractProjectName(url);
|
|
434
|
+
res.json({ projectName });
|
|
435
|
+
});
|
|
436
|
+
// ========== Environment API Endpoints ==========
|
|
437
|
+
// List all environments (metadata only - no secret values sent to dashboard)
|
|
438
|
+
app.get('/api/environments', (_req, res) => {
|
|
439
|
+
res.json(getEnvironmentsMetadata());
|
|
440
|
+
});
|
|
441
|
+
// Get single environment metadata
|
|
442
|
+
app.get('/api/environments/:id', (req, res) => {
|
|
443
|
+
const metadata = getEnvironmentMetadata(req.params.id);
|
|
444
|
+
if (!metadata) {
|
|
445
|
+
return res.status(404).json({ error: 'Environment not found' });
|
|
446
|
+
}
|
|
447
|
+
res.json(metadata);
|
|
448
|
+
});
|
|
449
|
+
// Get full environment data (including secret values) - for local editing only
|
|
450
|
+
app.get('/api/environments/:id/full', (req, res) => {
|
|
451
|
+
const env = getEnvironment(req.params.id);
|
|
452
|
+
if (!env) {
|
|
453
|
+
return res.status(404).json({ error: 'Environment not found' });
|
|
454
|
+
}
|
|
455
|
+
res.json(env);
|
|
456
|
+
});
|
|
457
|
+
// Create environment
|
|
458
|
+
app.post('/api/environments', (req, res) => {
|
|
459
|
+
const { name, provider, icon, isDefault, variables } = req.body;
|
|
460
|
+
if (!name || !provider) {
|
|
461
|
+
return res.status(400).json({ error: 'Missing required fields: name, provider' });
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const env = createEnvironment({ name, provider, icon, isDefault, variables: variables ?? {} });
|
|
465
|
+
// Return metadata only (not the actual secrets)
|
|
466
|
+
res.status(201).json({
|
|
467
|
+
id: env.id,
|
|
468
|
+
name: env.name,
|
|
469
|
+
provider: env.provider,
|
|
470
|
+
icon: env.icon,
|
|
471
|
+
isDefault: env.isDefault,
|
|
472
|
+
variableKeys: Object.keys(env.variables),
|
|
473
|
+
createdAt: env.createdAt,
|
|
474
|
+
updatedAt: env.updatedAt,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
console.error('[Environments] Create error:', err);
|
|
479
|
+
res.status(500).json({ error: 'Failed to create environment' });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// Update environment
|
|
483
|
+
app.put('/api/environments/:id', (req, res) => {
|
|
484
|
+
const { name, provider, icon, isDefault, variables } = req.body;
|
|
485
|
+
const updated = updateEnvironment(req.params.id, { name, provider, icon, isDefault, variables });
|
|
486
|
+
if (!updated) {
|
|
487
|
+
return res.status(404).json({ error: 'Environment not found' });
|
|
488
|
+
}
|
|
489
|
+
// Return metadata only
|
|
490
|
+
res.json({
|
|
491
|
+
id: updated.id,
|
|
492
|
+
name: updated.name,
|
|
493
|
+
provider: updated.provider,
|
|
494
|
+
icon: updated.icon,
|
|
495
|
+
isDefault: updated.isDefault,
|
|
496
|
+
variableKeys: Object.keys(updated.variables),
|
|
497
|
+
createdAt: updated.createdAt,
|
|
498
|
+
updatedAt: updated.updatedAt,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
// Delete environment
|
|
502
|
+
app.delete('/api/environments/:id', (req, res) => {
|
|
503
|
+
const deleted = deleteEnvironment(req.params.id);
|
|
504
|
+
if (!deleted) {
|
|
505
|
+
return res.status(404).json({ error: 'Environment not found' });
|
|
506
|
+
}
|
|
507
|
+
res.json({ success: true });
|
|
508
|
+
});
|
|
509
|
+
// Receive status updates from Claude Code hooks
|
|
510
|
+
// The hook script now sends pre-mapped status, making this endpoint simpler
|
|
511
|
+
app.post('/api/hooks/status', (req, res) => {
|
|
512
|
+
const { event, status, attention_reason, session_id, tmux_session, project, timestamp } = req.body;
|
|
513
|
+
if (!event) {
|
|
514
|
+
return res.status(400).json({ error: 'Missing event' });
|
|
515
|
+
}
|
|
516
|
+
// Validate status
|
|
517
|
+
const validStatuses = ['init', 'working', 'needs_attention', 'idle'];
|
|
518
|
+
const receivedStatus = validStatuses.includes(status) ? status : 'working';
|
|
519
|
+
// Validate attention_reason if provided
|
|
520
|
+
const validReasons = ['permission', 'input', 'plan_approval', 'task_complete'];
|
|
521
|
+
const receivedReason = attention_reason && validReasons.includes(attention_reason) ? attention_reason : undefined;
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
// Store by tmux session name (REQUIRED for per-session status)
|
|
524
|
+
if (tmux_session) {
|
|
525
|
+
const existing = tmuxSessionStatus.get(tmux_session);
|
|
526
|
+
const statusChanged = !existing || existing.status !== receivedStatus;
|
|
527
|
+
const hookData = {
|
|
528
|
+
status: receivedStatus,
|
|
529
|
+
attentionReason: receivedReason,
|
|
530
|
+
lastEvent: event,
|
|
531
|
+
lastActivity: timestamp || now,
|
|
532
|
+
lastStatusChange: statusChanged ? now : existing.lastStatusChange,
|
|
533
|
+
project,
|
|
534
|
+
};
|
|
535
|
+
// Write to SQLite database first (persistent storage)
|
|
536
|
+
// The DB preserves the original createdAt from when the session was first seen
|
|
537
|
+
const sessionProject = tmux_session.split('--')[0] || project;
|
|
538
|
+
const dbSession = sessionsDb.upsertSession(tmux_session, {
|
|
539
|
+
project: sessionProject,
|
|
540
|
+
status: receivedStatus,
|
|
541
|
+
attentionReason: receivedReason,
|
|
542
|
+
lastEvent: event,
|
|
543
|
+
lastActivity: timestamp || now,
|
|
544
|
+
lastStatusChange: statusChanged ? now : existing?.lastStatusChange ?? now,
|
|
545
|
+
environmentId: getSessionEnvironment(tmux_session),
|
|
546
|
+
});
|
|
547
|
+
// Then update in-memory Map (for real-time performance)
|
|
548
|
+
tmuxSessionStatus.set(tmux_session, hookData);
|
|
549
|
+
// Broadcast status update to WebSocket subscribers
|
|
550
|
+
// Use createdAt from DB to ensure stable sorting on the frontend
|
|
551
|
+
const envId = getSessionEnvironment(tmux_session);
|
|
552
|
+
const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
|
|
553
|
+
broadcastStatusUpdate({
|
|
554
|
+
name: tmux_session,
|
|
555
|
+
project: sessionProject || project,
|
|
556
|
+
status: hookData.status,
|
|
557
|
+
attentionReason: hookData.attentionReason,
|
|
558
|
+
statusSource: 'hook',
|
|
559
|
+
lastEvent: hookData.lastEvent,
|
|
560
|
+
lastStatusChange: hookData.lastStatusChange,
|
|
561
|
+
createdAt: dbSession.created_at, // Use stable createdAt from DB, not current timestamp
|
|
562
|
+
lastActivity: undefined,
|
|
563
|
+
environmentId: envId,
|
|
564
|
+
environment: envMeta ? {
|
|
565
|
+
id: envMeta.id,
|
|
566
|
+
name: envMeta.name,
|
|
567
|
+
provider: envMeta.provider,
|
|
568
|
+
icon: envMeta.icon,
|
|
569
|
+
isDefault: envMeta.isDefault,
|
|
570
|
+
} : undefined,
|
|
571
|
+
});
|
|
572
|
+
console.log(`[Hook] ${tmux_session}: ${event} → ${receivedStatus}${receivedReason ? ` (${receivedReason})` : ''}`);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
// Warning: tmux_session is required for proper per-session status tracking
|
|
576
|
+
console.warn(`[Hook] WARNING: Missing tmux_session for ${event} (session_id=${session_id}, project=${project})`);
|
|
577
|
+
}
|
|
578
|
+
res.json({ received: true });
|
|
579
|
+
});
|
|
580
|
+
// Enhanced sessions endpoint with detailed info
|
|
581
|
+
// Combines Claude Code hooks (reliable) with tmux heuristics (instant fallback)
|
|
582
|
+
app.get('/api/sessions', async (_req, res) => {
|
|
583
|
+
const { exec } = await import('child_process');
|
|
584
|
+
const { promisify } = await import('util');
|
|
585
|
+
const execAsync = promisify(exec);
|
|
586
|
+
try {
|
|
587
|
+
// Get session list with creation time
|
|
588
|
+
const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}|#{session_created}" 2>/dev/null');
|
|
589
|
+
const sessions = [];
|
|
590
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
591
|
+
const [name, created] = line.split('|');
|
|
592
|
+
// Extract project from session name (format: project--adjective-noun-number)
|
|
593
|
+
const [project] = name.split('--');
|
|
594
|
+
let status = 'init';
|
|
595
|
+
let attentionReason;
|
|
596
|
+
let statusSource = 'tmux';
|
|
597
|
+
let lastEvent;
|
|
598
|
+
let lastStatusChange;
|
|
599
|
+
// Use hook status if available, otherwise idle
|
|
600
|
+
const hookData = tmuxSessionStatus.get(name);
|
|
601
|
+
if (hookData) {
|
|
602
|
+
status = hookData.status;
|
|
603
|
+
attentionReason = hookData.attentionReason;
|
|
604
|
+
statusSource = 'hook';
|
|
605
|
+
lastEvent = hookData.lastEvent;
|
|
606
|
+
lastStatusChange = hookData.lastStatusChange;
|
|
607
|
+
}
|
|
608
|
+
// Get environment info for this session
|
|
609
|
+
const envId = getSessionEnvironment(name);
|
|
610
|
+
const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
|
|
611
|
+
sessions.push({
|
|
612
|
+
name,
|
|
613
|
+
project,
|
|
614
|
+
createdAt: parseInt(created) * 1000,
|
|
615
|
+
status,
|
|
616
|
+
attentionReason,
|
|
617
|
+
statusSource,
|
|
618
|
+
lastActivity: '',
|
|
619
|
+
lastEvent,
|
|
620
|
+
lastStatusChange,
|
|
621
|
+
environmentId: envId,
|
|
622
|
+
environment: envMeta ? {
|
|
623
|
+
id: envMeta.id,
|
|
624
|
+
name: envMeta.name,
|
|
625
|
+
provider: envMeta.provider,
|
|
626
|
+
icon: envMeta.icon,
|
|
627
|
+
isDefault: envMeta.isDefault,
|
|
628
|
+
} : undefined,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
res.json(sessions);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
res.json([]);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
// Get terminal preview (last N lines from tmux pane)
|
|
638
|
+
app.get('/api/sessions/:sessionName/preview', async (req, res) => {
|
|
639
|
+
const { sessionName } = req.params;
|
|
640
|
+
const { exec } = await import('child_process');
|
|
641
|
+
const { promisify } = await import('util');
|
|
642
|
+
const execAsync = promisify(exec);
|
|
643
|
+
// Validate session name format to prevent injection
|
|
644
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
645
|
+
return res.status(400).json({ error: 'Invalid session name' });
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
// Capture last 20 lines from the tmux pane
|
|
649
|
+
const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p -S -20 2>/dev/null`);
|
|
650
|
+
// Split into lines and take last 15 non-empty lines for display
|
|
651
|
+
const allLines = stdout.split('\n');
|
|
652
|
+
const lines = allLines.slice(-16, -1).filter(line => line.trim() !== '' || allLines.indexOf(line) > allLines.length - 5);
|
|
653
|
+
res.json({
|
|
654
|
+
lines: lines.length > 0 ? lines : ['(empty terminal)'],
|
|
655
|
+
timestamp: Date.now()
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
res.status(404).json({ error: 'Session not found' });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
// Kill a tmux session
|
|
663
|
+
app.delete('/api/sessions/:sessionName', async (req, res) => {
|
|
664
|
+
const { sessionName } = req.params;
|
|
665
|
+
const { exec } = await import('child_process');
|
|
666
|
+
const { promisify } = await import('util');
|
|
667
|
+
const execAsync = promisify(exec);
|
|
668
|
+
// Validate session name format to prevent injection
|
|
669
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
670
|
+
return res.status(400).json({ error: 'Invalid session name' });
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
|
|
674
|
+
console.log(`Killed tmux session: ${sessionName}`);
|
|
675
|
+
// Clean up from SQLite database
|
|
676
|
+
sessionsDb.deleteSession(sessionName);
|
|
677
|
+
sessionsDb.clearSessionEnvironmentId(sessionName);
|
|
678
|
+
// Clean up status tracking (in-memory) and broadcast removal
|
|
679
|
+
tmuxSessionStatus.delete(sessionName);
|
|
680
|
+
clearSessionEnvironment(sessionName); // Clean up environment tracking
|
|
681
|
+
broadcastSessionRemoved(sessionName);
|
|
682
|
+
res.json({ success: true, message: `Session ${sessionName} killed` });
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
res.status(404).json({ error: 'Session not found or already killed' });
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
// Archive a session (mark as done and keep in history)
|
|
689
|
+
app.post('/api/sessions/:sessionName/archive', async (req, res) => {
|
|
690
|
+
const { sessionName } = req.params;
|
|
691
|
+
const { exec } = await import('child_process');
|
|
692
|
+
const { promisify } = await import('util');
|
|
693
|
+
const execAsync = promisify(exec);
|
|
694
|
+
// Validate session name format to prevent injection
|
|
695
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
696
|
+
return res.status(400).json({ error: 'Invalid session name' });
|
|
697
|
+
}
|
|
698
|
+
// Archive the session in SQLite
|
|
699
|
+
const archivedSession = sessionsDb.archiveSession(sessionName);
|
|
700
|
+
if (!archivedSession) {
|
|
701
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
702
|
+
}
|
|
703
|
+
// Kill the tmux session (terminal no longer needed)
|
|
704
|
+
try {
|
|
705
|
+
await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
|
|
706
|
+
console.log(`[Archive] Killed tmux session: ${sessionName}`);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// Session might already be dead, that's fine
|
|
710
|
+
console.log(`[Archive] Tmux session ${sessionName} was already gone`);
|
|
711
|
+
}
|
|
712
|
+
// Clean up in-memory status tracking
|
|
713
|
+
tmuxSessionStatus.delete(sessionName);
|
|
714
|
+
// Get environment info for the response
|
|
715
|
+
const envId = getSessionEnvironment(sessionName);
|
|
716
|
+
const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
|
|
717
|
+
// Broadcast session-archived event
|
|
718
|
+
const archivedInfo = {
|
|
719
|
+
name: sessionName,
|
|
720
|
+
project: archivedSession.project,
|
|
721
|
+
createdAt: archivedSession.created_at,
|
|
722
|
+
status: archivedSession.status,
|
|
723
|
+
attentionReason: archivedSession.attention_reason ?? undefined,
|
|
724
|
+
statusSource: 'hook',
|
|
725
|
+
lastEvent: archivedSession.last_event ?? undefined,
|
|
726
|
+
lastStatusChange: archivedSession.last_status_change,
|
|
727
|
+
archivedAt: archivedSession.archived_at ?? undefined,
|
|
728
|
+
environmentId: envId,
|
|
729
|
+
environment: envMeta ? {
|
|
730
|
+
id: envMeta.id,
|
|
731
|
+
name: envMeta.name,
|
|
732
|
+
provider: envMeta.provider,
|
|
733
|
+
icon: envMeta.icon,
|
|
734
|
+
isDefault: envMeta.isDefault,
|
|
735
|
+
} : undefined,
|
|
736
|
+
};
|
|
737
|
+
broadcastSessionArchived(sessionName, archivedInfo);
|
|
738
|
+
res.json({
|
|
739
|
+
success: true,
|
|
740
|
+
message: `Session ${sessionName} archived`,
|
|
741
|
+
session: archivedInfo,
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
// Get archived sessions
|
|
745
|
+
app.get('/api/sessions/archived', (_req, res) => {
|
|
746
|
+
const archivedSessions = sessionsDb.getArchivedSessions();
|
|
747
|
+
const sessions = archivedSessions.map((session) => {
|
|
748
|
+
const envId = getSessionEnvironment(session.name);
|
|
749
|
+
const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
|
|
750
|
+
return {
|
|
751
|
+
name: session.name,
|
|
752
|
+
project: session.project,
|
|
753
|
+
createdAt: session.created_at,
|
|
754
|
+
status: session.status,
|
|
755
|
+
attentionReason: session.attention_reason ?? undefined,
|
|
756
|
+
statusSource: 'hook',
|
|
757
|
+
lastEvent: session.last_event ?? undefined,
|
|
758
|
+
lastStatusChange: session.last_status_change,
|
|
759
|
+
archivedAt: session.archived_at ?? undefined,
|
|
760
|
+
environmentId: envId,
|
|
761
|
+
environment: envMeta ? {
|
|
762
|
+
id: envMeta.id,
|
|
763
|
+
name: envMeta.name,
|
|
764
|
+
provider: envMeta.provider,
|
|
765
|
+
icon: envMeta.icon,
|
|
766
|
+
isDefault: envMeta.isDefault,
|
|
767
|
+
} : undefined,
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
res.json(sessions);
|
|
771
|
+
});
|
|
772
|
+
// ========== Editor API Endpoints ==========
|
|
773
|
+
// Helper to check if project is allowed (whitelist empty = allow any)
|
|
774
|
+
const isProjectAllowed = (project) => {
|
|
775
|
+
const whitelist = config.projects.whitelist;
|
|
776
|
+
const hasWhitelist = whitelist && whitelist.length > 0;
|
|
777
|
+
return hasWhitelist ? whitelist.includes(project) : true;
|
|
778
|
+
};
|
|
779
|
+
// Get editor status for a project
|
|
780
|
+
app.get('/api/editor/:project/status', (req, res) => {
|
|
781
|
+
const { project } = req.params;
|
|
782
|
+
if (!isProjectAllowed(project)) {
|
|
783
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
784
|
+
}
|
|
785
|
+
res.json(getEditorStatus(project));
|
|
786
|
+
});
|
|
787
|
+
// Start editor for a project
|
|
788
|
+
app.post('/api/editor/:project/start', async (req, res) => {
|
|
789
|
+
const { project } = req.params;
|
|
790
|
+
if (!isProjectAllowed(project)) {
|
|
791
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
792
|
+
}
|
|
793
|
+
// Check if editor is enabled
|
|
794
|
+
if (!typedConfig.editor?.enabled) {
|
|
795
|
+
return res.status(400).json({ error: 'Editor is disabled in config' });
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const editor = await getOrStartEditor(project);
|
|
799
|
+
res.json({
|
|
800
|
+
success: true,
|
|
801
|
+
port: editor.port,
|
|
802
|
+
startedAt: editor.startedAt,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
console.error('[Editor] Failed to start:', err);
|
|
807
|
+
res.status(500).json({ error: 'Failed to start editor', message: err.message });
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
// Stop editor for a project
|
|
811
|
+
app.post('/api/editor/:project/stop', (req, res) => {
|
|
812
|
+
const { project } = req.params;
|
|
813
|
+
if (!isProjectAllowed(project)) {
|
|
814
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
815
|
+
}
|
|
816
|
+
const stopped = stopEditor(project);
|
|
817
|
+
res.json({ success: stopped });
|
|
818
|
+
});
|
|
819
|
+
// List all running editors
|
|
820
|
+
app.get('/api/editors', (_req, res) => {
|
|
821
|
+
res.json(getAllEditors());
|
|
822
|
+
});
|
|
823
|
+
// ========== File Explorer API Endpoints ==========
|
|
824
|
+
// Get files tree with git status for a project
|
|
825
|
+
app.get('/api/files/:project', async (req, res) => {
|
|
826
|
+
const { project } = req.params;
|
|
827
|
+
if (!isProjectAllowed(project)) {
|
|
828
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
|
|
832
|
+
// Check if project exists
|
|
833
|
+
const fs = await import('fs');
|
|
834
|
+
if (!fs.existsSync(projectPath)) {
|
|
835
|
+
return res.status(404).json({ error: 'Project not found' });
|
|
836
|
+
}
|
|
837
|
+
// Get file tree
|
|
838
|
+
const files = await listFiles(projectPath);
|
|
839
|
+
// Get changes summary
|
|
840
|
+
const summary = await getChangesSummary(projectPath);
|
|
841
|
+
res.json({
|
|
842
|
+
files,
|
|
843
|
+
summary,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
console.error('[Files] Failed to list files:', err);
|
|
848
|
+
res.status(500).json({ error: 'Failed to list files', message: err.message });
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
// Get content of a specific file
|
|
852
|
+
app.get('/api/files/:project/content', async (req, res) => {
|
|
853
|
+
const { project } = req.params;
|
|
854
|
+
const { path: filePath } = req.query;
|
|
855
|
+
if (!isProjectAllowed(project)) {
|
|
856
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
857
|
+
}
|
|
858
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
859
|
+
return res.status(400).json({ error: 'Missing path parameter' });
|
|
860
|
+
}
|
|
861
|
+
// Validate path to prevent directory traversal
|
|
862
|
+
if (filePath.includes('..') || filePath.startsWith('/')) {
|
|
863
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
|
|
867
|
+
const content = await getFileContent(projectPath, filePath);
|
|
868
|
+
res.json(content);
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
console.error('[Files] Failed to get file content:', err);
|
|
872
|
+
res.status(500).json({ error: 'Failed to read file', message: err.message });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
// Open a file in the local editor
|
|
876
|
+
app.post('/api/files/:project/open', async (req, res) => {
|
|
877
|
+
const { project } = req.params;
|
|
878
|
+
const { path: filePath } = req.body;
|
|
879
|
+
if (!isProjectAllowed(project)) {
|
|
880
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
881
|
+
}
|
|
882
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
883
|
+
return res.status(400).json({ error: 'Missing path in body' });
|
|
884
|
+
}
|
|
885
|
+
// Validate path
|
|
886
|
+
if (filePath.includes('..') || filePath.startsWith('/')) {
|
|
887
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
|
|
891
|
+
const result = await openFileInEditor(projectPath, filePath);
|
|
892
|
+
if (result.success) {
|
|
893
|
+
res.json({
|
|
894
|
+
success: true,
|
|
895
|
+
command: result.command,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
res.status(400).json(result);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
console.error('[Files] Failed to open file:', err);
|
|
904
|
+
res.status(500).json({ error: 'Failed to open file', message: err.message });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
// ========== Editor Proxy Routes ==========
|
|
908
|
+
// Proxy HTTP requests to code-server
|
|
909
|
+
app.use('/editor/:project', async (req, res, _next) => {
|
|
910
|
+
const { project } = req.params;
|
|
911
|
+
// Validate project
|
|
912
|
+
if (!isProjectAllowed(project)) {
|
|
913
|
+
return res.status(403).json({ error: 'Project not allowed' });
|
|
914
|
+
}
|
|
915
|
+
// Check if editor is enabled
|
|
916
|
+
if (!typedConfig.editor?.enabled) {
|
|
917
|
+
return res.status(400).json({ error: 'Editor is disabled in config' });
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
// Get or start the editor
|
|
921
|
+
const editor = await getOrStartEditor(project);
|
|
922
|
+
updateEditorActivity(project);
|
|
923
|
+
// Rewrite the URL to remove /editor/:project prefix
|
|
924
|
+
req.url = req.url.replace(`/editor/${project}`, '') || '/';
|
|
925
|
+
// Proxy to code-server
|
|
926
|
+
editorProxy.web(req, res, {
|
|
927
|
+
target: `http://127.0.0.1:${editor.port}`,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
console.error('[Editor Proxy] Failed:', err);
|
|
932
|
+
res.status(502).json({ error: 'Failed to proxy to editor' });
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
// Handle ALL WebSocket upgrades manually (noServer mode)
|
|
936
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
937
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
938
|
+
// Handle terminal WebSocket
|
|
939
|
+
if (url.pathname === '/terminal') {
|
|
940
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
941
|
+
wss.emit('connection', ws, req);
|
|
942
|
+
});
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
// Handle status WebSocket (real-time session status updates)
|
|
946
|
+
if (url.pathname === '/status') {
|
|
947
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
948
|
+
console.log('[Status WS] New subscriber connected');
|
|
949
|
+
statusSubscribers.add(ws);
|
|
950
|
+
// Send initial session list
|
|
951
|
+
(async () => {
|
|
952
|
+
try {
|
|
953
|
+
const { exec } = await import('child_process');
|
|
954
|
+
const { promisify } = await import('util');
|
|
955
|
+
const execAsync = promisify(exec);
|
|
956
|
+
const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}|#{session_created}" 2>/dev/null');
|
|
957
|
+
const sessions = [];
|
|
958
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
959
|
+
const [name, created] = line.split('|');
|
|
960
|
+
const [project] = name.split('--');
|
|
961
|
+
let status = 'init';
|
|
962
|
+
let attentionReason;
|
|
963
|
+
let statusSource = 'tmux';
|
|
964
|
+
let lastEvent;
|
|
965
|
+
let lastStatusChange;
|
|
966
|
+
// Use hook status if available, otherwise idle
|
|
967
|
+
const hookData = tmuxSessionStatus.get(name);
|
|
968
|
+
if (hookData) {
|
|
969
|
+
status = hookData.status;
|
|
970
|
+
attentionReason = hookData.attentionReason;
|
|
971
|
+
statusSource = 'hook';
|
|
972
|
+
lastEvent = hookData.lastEvent;
|
|
973
|
+
lastStatusChange = hookData.lastStatusChange;
|
|
974
|
+
}
|
|
975
|
+
sessions.push({
|
|
976
|
+
name,
|
|
977
|
+
project,
|
|
978
|
+
createdAt: parseInt(created) * 1000,
|
|
979
|
+
status,
|
|
980
|
+
attentionReason,
|
|
981
|
+
statusSource,
|
|
982
|
+
lastActivity: '',
|
|
983
|
+
lastEvent,
|
|
984
|
+
lastStatusChange,
|
|
985
|
+
environmentId: getSessionEnvironment(name),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
const message = { type: 'sessions-list', sessions };
|
|
989
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
990
|
+
ws.send(JSON.stringify(message));
|
|
991
|
+
console.log(`[Status WS] Sent initial sessions list: ${sessions.length} sessions`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
console.error('[Status WS] Failed to get initial sessions:', err);
|
|
996
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
997
|
+
try {
|
|
998
|
+
const message = { type: 'sessions-list', sessions: [] };
|
|
999
|
+
ws.send(JSON.stringify(message));
|
|
1000
|
+
}
|
|
1001
|
+
catch (sendErr) {
|
|
1002
|
+
console.error('[Status WS] Failed to send error response:', sendErr);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
})();
|
|
1007
|
+
ws.on('close', () => {
|
|
1008
|
+
statusSubscribers.delete(ws);
|
|
1009
|
+
console.log(`[Status WS] Subscriber disconnected (remaining: ${statusSubscribers.size})`);
|
|
1010
|
+
});
|
|
1011
|
+
ws.on('error', (err) => {
|
|
1012
|
+
console.error('[Status WS] Error:', err);
|
|
1013
|
+
statusSubscribers.delete(ws);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
// Handle editor WebSocket
|
|
1019
|
+
if (url.pathname.startsWith('/editor/')) {
|
|
1020
|
+
const pathParts = url.pathname.split('/');
|
|
1021
|
+
const project = pathParts[2];
|
|
1022
|
+
if (!project || !isProjectAllowed(project)) {
|
|
1023
|
+
socket.destroy();
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (!typedConfig.editor?.enabled) {
|
|
1027
|
+
socket.destroy();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
const editor = await getOrStartEditor(project);
|
|
1032
|
+
updateEditorActivity(project);
|
|
1033
|
+
// Rewrite the URL - ensure path starts with /
|
|
1034
|
+
const rewrittenPath = url.pathname.replace(`/editor/${project}`, '') || '/';
|
|
1035
|
+
req.url = rewrittenPath + url.search;
|
|
1036
|
+
console.log(`[Editor WS] Proxying ${url.pathname} → ${req.url} to port ${editor.port}`);
|
|
1037
|
+
editorProxy.ws(req, socket, head, {
|
|
1038
|
+
target: `http://127.0.0.1:${editor.port}`,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
console.error('[Editor WS] Failed:', err);
|
|
1043
|
+
socket.destroy();
|
|
1044
|
+
}
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// Unknown WebSocket path - destroy
|
|
1048
|
+
socket.destroy();
|
|
1049
|
+
});
|
|
1050
|
+
// Graceful shutdown handler
|
|
1051
|
+
const shutdown = () => {
|
|
1052
|
+
console.log('[Server] Shutting down...');
|
|
1053
|
+
shutdownAllEditors();
|
|
1054
|
+
closeDatabase(); // Close SQLite database
|
|
1055
|
+
server.close();
|
|
1056
|
+
process.exit(0);
|
|
1057
|
+
};
|
|
1058
|
+
process.on('SIGTERM', shutdown);
|
|
1059
|
+
process.on('SIGINT', shutdown);
|
|
1060
|
+
return server;
|
|
1061
|
+
}
|
|
1062
|
+
//# sourceMappingURL=server.js.map
|