ai-agent-session-center 2.0.2 → 2.0.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/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- package/server/wsManager.ts +200 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
// apiRouter.ts — Express router for all API endpoints
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
// Express 5 types req.params as string | string[] and req.query similarly.
|
|
7
|
+
// Our routes always use single-value params. This helper safely extracts a string.
|
|
8
|
+
function str(val: unknown): string {
|
|
9
|
+
if (typeof val === 'string') return val;
|
|
10
|
+
if (Array.isArray(val)) return String(val[0] ?? '');
|
|
11
|
+
return val != null ? String(val) : '';
|
|
12
|
+
}
|
|
13
|
+
import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionAccentColor, setSummary, getSession, detectSessionSource, createTerminalSession, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal } from './sessionStore.js';
|
|
14
|
+
import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions, writeToTerminal, writeWhenReady, attachToTmuxPane, consumePendingLink } from './sshManager.js';
|
|
15
|
+
import { getTeam, readTeamConfig } from './teamManager.js';
|
|
16
|
+
import { getStats as getHookStats, resetStats as resetHookStats } from './hookStats.js';
|
|
17
|
+
import * as db from './db.js';
|
|
18
|
+
import { getMqStats } from './mqReader.js';
|
|
19
|
+
import { execFile } from 'child_process';
|
|
20
|
+
import { readFileSync } from 'fs';
|
|
21
|
+
import { join, dirname } from 'path';
|
|
22
|
+
import { homedir, userInfo } from 'os';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
|
|
25
|
+
import log from './logger.js';
|
|
26
|
+
import type { TerminalConfig } from '../src/types/terminal.js';
|
|
27
|
+
|
|
28
|
+
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
const router = Router();
|
|
31
|
+
|
|
32
|
+
// ---- Last-used Username Persistence ----
|
|
33
|
+
|
|
34
|
+
let _lastUsedUsername: string | null = null;
|
|
35
|
+
|
|
36
|
+
function getDefaultUsername(): string | null {
|
|
37
|
+
if (_lastUsedUsername) return _lastUsedUsername;
|
|
38
|
+
try { return userInfo().username; } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveLastUsername(username: string): void {
|
|
42
|
+
if (username) _lastUsedUsername = username;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isLocalHost(host: string): boolean {
|
|
46
|
+
return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- Zod Validation Schemas ----
|
|
50
|
+
|
|
51
|
+
/** Rejects shell metacharacters that could enable injection */
|
|
52
|
+
const SHELL_META_RE = /[;|&$`\\!><()\n\r{}[\]]/;
|
|
53
|
+
|
|
54
|
+
const noShellMeta = (maxLen: number) =>
|
|
55
|
+
z.string().max(maxLen).refine(s => !SHELL_META_RE.test(s), 'contains invalid shell characters');
|
|
56
|
+
|
|
57
|
+
const noShellMetaWorkDir = z.string().max(1024).refine(
|
|
58
|
+
s => !SHELL_META_RE.test(s.replace(/^~/, '')),
|
|
59
|
+
'contains invalid shell characters',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const usernameSchema = z.string().max(128).regex(/^[a-zA-Z0-9_.\-]+$/, 'username contains invalid characters');
|
|
63
|
+
|
|
64
|
+
const authMethodSchema = z.enum(['key', 'password']).optional();
|
|
65
|
+
|
|
66
|
+
const terminalCreateSchema = z.object({
|
|
67
|
+
host: noShellMeta(255).optional(),
|
|
68
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
69
|
+
username: usernameSchema.optional(),
|
|
70
|
+
password: z.string().optional(),
|
|
71
|
+
privateKeyPath: z.string().optional(),
|
|
72
|
+
authMethod: authMethodSchema,
|
|
73
|
+
workingDir: noShellMetaWorkDir.optional(),
|
|
74
|
+
command: noShellMeta(512).optional(),
|
|
75
|
+
apiKey: z.string().optional(),
|
|
76
|
+
tmuxSession: z.string().regex(/^[a-zA-Z0-9_.\-]+$/, 'must be alphanumeric, dash, underscore, or dot').optional(),
|
|
77
|
+
useTmux: z.boolean().optional(),
|
|
78
|
+
sessionTitle: z.string().max(500).optional(),
|
|
79
|
+
label: z.string().optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const tmuxSessionsSchema = z.object({
|
|
83
|
+
host: z.string().optional(),
|
|
84
|
+
port: z.number().optional(),
|
|
85
|
+
username: usernameSchema.optional(),
|
|
86
|
+
password: z.string().optional(),
|
|
87
|
+
privateKeyPath: z.string().optional(),
|
|
88
|
+
authMethod: authMethodSchema,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const hookInstallSchema = z.object({
|
|
92
|
+
density: z.enum(['high', 'medium', 'low']),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const killSessionSchema = z.object({
|
|
96
|
+
confirm: z.literal(true),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const titleSchema = z.object({
|
|
100
|
+
title: z.string().max(500),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const labelSchema = z.object({
|
|
104
|
+
label: z.string(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const accentColorSchema = z.object({
|
|
108
|
+
color: z.string().min(1).max(50),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const summarizeSchema = z.object({
|
|
112
|
+
context: z.string().min(1),
|
|
113
|
+
promptTemplate: z.string().optional(),
|
|
114
|
+
custom_prompt: z.string().max(10000).optional(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const noteSchema = z.object({
|
|
118
|
+
text: z.string().min(1).max(10000),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** Helper: validate body with a Zod schema, send 400 on failure */
|
|
122
|
+
function validateBody<T>(schema: z.ZodType<T>, body: unknown, res: Response): T | null {
|
|
123
|
+
const result = schema.safeParse(body);
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
const msg = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
126
|
+
res.status(400).json({ success: false, error: msg });
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return result.data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Rate Limiting (in-memory, no external deps) ----
|
|
133
|
+
|
|
134
|
+
interface RateLimitBucket {
|
|
135
|
+
count: number;
|
|
136
|
+
windowStart: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sliding window rate limiter: tracks request counts per key per second
|
|
140
|
+
const rateLimitBuckets = new Map<string, RateLimitBucket>();
|
|
141
|
+
|
|
142
|
+
function isRateLimited(key: string, maxPerSecond: number): boolean {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const bucket = rateLimitBuckets.get(key);
|
|
145
|
+
if (!bucket || now - bucket.windowStart > 1000) {
|
|
146
|
+
rateLimitBuckets.set(key, { count: 1, windowStart: now });
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
bucket.count++;
|
|
150
|
+
return bucket.count > maxPerSecond;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Clean up stale rate limit buckets every 30s
|
|
154
|
+
setInterval(() => {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
for (const [key, bucket] of rateLimitBuckets) {
|
|
157
|
+
if (now - bucket.windowStart > 5000) {
|
|
158
|
+
rateLimitBuckets.delete(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}, 30000);
|
|
162
|
+
|
|
163
|
+
// Concurrent request limiter for summarize endpoint
|
|
164
|
+
let activeSummarizeRequests = 0;
|
|
165
|
+
const MAX_CONCURRENT_SUMMARIZE = 2;
|
|
166
|
+
|
|
167
|
+
// Terminal creation cap
|
|
168
|
+
const MAX_TERMINALS = 10;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Hook ingestion rate limit middleware (applied to hookRouter externally).
|
|
172
|
+
* Limits to 100 requests/sec per IP.
|
|
173
|
+
*/
|
|
174
|
+
export function hookRateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
175
|
+
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
176
|
+
if (isRateLimited(`hook:${ip}`, 100)) {
|
|
177
|
+
res.status(429).json({ success: false, error: 'Hook rate limit exceeded (100/sec)' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
next();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Hook performance stats
|
|
184
|
+
router.get('/hook-stats', (_req: Request, res: Response) => {
|
|
185
|
+
res.json(getHookStats());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
router.post('/hook-stats/reset', (_req: Request, res: Response) => {
|
|
189
|
+
resetHookStats();
|
|
190
|
+
res.json({ ok: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Full reset — broadcast to all connected browsers to clear their IndexedDB
|
|
194
|
+
router.post('/reset', async (_req: Request, res: Response) => {
|
|
195
|
+
const { broadcast } = await import('./wsManager.js');
|
|
196
|
+
broadcast({ type: WS_TYPES.CLEAR_BROWSER_DB });
|
|
197
|
+
res.json({ ok: true, message: 'Browser DB clear signal sent' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// MQ reader stats
|
|
201
|
+
router.get('/mq-stats', (_req: Request, res: Response) => {
|
|
202
|
+
res.json(getMqStats());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- Hook Density Management ----
|
|
206
|
+
|
|
207
|
+
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
208
|
+
const INSTALL_HOOKS_SCRIPT = join(__apiDirname, '..', 'hooks', 'install-hooks.js');
|
|
209
|
+
const HOOK_PATTERN = 'dashboard-hook.';
|
|
210
|
+
|
|
211
|
+
// Get current hooks status from ~/.claude/settings.json
|
|
212
|
+
router.get('/hooks/status', (_req: Request, res: Response) => {
|
|
213
|
+
try {
|
|
214
|
+
let claudeSettings: Record<string, unknown> = {};
|
|
215
|
+
try {
|
|
216
|
+
claudeSettings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
|
|
217
|
+
} catch { /* file doesn't exist yet */ }
|
|
218
|
+
|
|
219
|
+
const hooks = (claudeSettings.hooks || {}) as Record<string, Array<{ hooks?: Array<{ command?: string }> }>>;
|
|
220
|
+
const installedEvents = ALL_CLAUDE_HOOK_EVENTS.filter(event =>
|
|
221
|
+
hooks[event]?.some(group => group.hooks?.some(h => h.command?.includes(HOOK_PATTERN)))
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Infer density from installed events
|
|
225
|
+
let density = 'off';
|
|
226
|
+
if (installedEvents.length > 0) {
|
|
227
|
+
if (installedEvents.length === DENSITY_EVENTS.high.length &&
|
|
228
|
+
DENSITY_EVENTS.high.every(e => installedEvents.includes(e))) {
|
|
229
|
+
density = 'high';
|
|
230
|
+
} else if (installedEvents.length === DENSITY_EVENTS.medium.length &&
|
|
231
|
+
DENSITY_EVENTS.medium.every(e => installedEvents.includes(e))) {
|
|
232
|
+
density = 'medium';
|
|
233
|
+
} else if (installedEvents.length === DENSITY_EVENTS.low.length &&
|
|
234
|
+
DENSITY_EVENTS.low.every(e => installedEvents.includes(e))) {
|
|
235
|
+
density = 'low';
|
|
236
|
+
} else {
|
|
237
|
+
density = 'custom';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
res.json({ installed: installedEvents.length > 0, density, events: installedEvents });
|
|
242
|
+
} catch (err: unknown) {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
244
|
+
res.status(500).json({ error: msg });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Install hooks with specified density
|
|
249
|
+
router.post('/hooks/install', (req: Request, res: Response) => {
|
|
250
|
+
const body = validateBody(hookInstallSchema, req.body, res);
|
|
251
|
+
if (!body) return;
|
|
252
|
+
const { density } = body;
|
|
253
|
+
|
|
254
|
+
// Run install-hooks.js with --density flag
|
|
255
|
+
execFile('node', [INSTALL_HOOKS_SCRIPT, '--density', density], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
256
|
+
if (err) {
|
|
257
|
+
log.error('api', `hooks/install failed: ${err.message}`);
|
|
258
|
+
res.status(500).json({ success: false, error: err.message, stdout, stderr });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
log.info('api', `hooks/install: ${stdout.trim()}`);
|
|
262
|
+
res.json({ ok: true, density, events: DENSITY_EVENTS[density as keyof typeof DENSITY_EVENTS], output: stdout });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Uninstall all dashboard hooks
|
|
267
|
+
router.post('/hooks/uninstall', (_req: Request, res: Response) => {
|
|
268
|
+
// Run install-hooks.js with --uninstall flag
|
|
269
|
+
execFile('node', [INSTALL_HOOKS_SCRIPT, '--uninstall'], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
270
|
+
if (err) {
|
|
271
|
+
log.error('api', `hooks/uninstall failed: ${err.message}`);
|
|
272
|
+
res.status(500).json({ success: false, error: err.message, stdout, stderr });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
log.info('api', `hooks/uninstall: ${stdout.trim()}`);
|
|
276
|
+
res.json({ ok: true, output: stdout });
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ---- Session Control Endpoints ----
|
|
281
|
+
|
|
282
|
+
// Resume a disconnected SSH session — tries `claude --resume <id>` first,
|
|
283
|
+
// falls back to `claude --continue` if the conversation wasn't persisted.
|
|
284
|
+
router.post('/sessions/:id/resume', async (req: Request, res: Response) => {
|
|
285
|
+
const sessionId = str(req.params.id);
|
|
286
|
+
|
|
287
|
+
const session = getSession(sessionId);
|
|
288
|
+
if (!session) { res.status(404).json({ error: 'Session not found' }); return; }
|
|
289
|
+
|
|
290
|
+
// Build resume command: try exact session ID first, fall back to --continue
|
|
291
|
+
const resumeCmd = `claude --resume ${sessionId} || claude --continue`;
|
|
292
|
+
|
|
293
|
+
const allTerminals = getTerminals();
|
|
294
|
+
const terminalExists = session.lastTerminalId && allTerminals.some(t => t.terminalId === session.lastTerminalId);
|
|
295
|
+
|
|
296
|
+
if (terminalExists) {
|
|
297
|
+
// Terminal still alive — send resume command to it
|
|
298
|
+
const result = resumeSession(sessionId);
|
|
299
|
+
if ('error' in result) { res.status(400).json({ error: result.error }); return; }
|
|
300
|
+
|
|
301
|
+
writeToTerminal(result.terminalId, `${resumeCmd}\r`);
|
|
302
|
+
|
|
303
|
+
const { broadcast } = await import('./wsManager.js');
|
|
304
|
+
broadcast({ type: WS_TYPES.SESSION_UPDATE, session: result.session });
|
|
305
|
+
|
|
306
|
+
res.json({ ok: true, terminalId: result.terminalId });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Terminal no longer exists — create a new one and run resume command
|
|
311
|
+
const cfg = session.sshConfig;
|
|
312
|
+
const isRemote = cfg && cfg.host && cfg.host !== 'localhost' && cfg.host !== '127.0.0.1';
|
|
313
|
+
|
|
314
|
+
// For non-SSH (display-only) sessions, create a local terminal in the project directory
|
|
315
|
+
if (!cfg || !cfg.username) {
|
|
316
|
+
if (isRemote) {
|
|
317
|
+
res.status(400).json({ error: 'No SSH config stored for this session — cannot reconnect to remote host' });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// Create terminal with command='' to skip auto-launch (the resume command
|
|
324
|
+
// contains || which can't pass shell metacharacter validation).
|
|
325
|
+
// We write the command ourselves after the shell initializes.
|
|
326
|
+
const newConfig: TerminalConfig = cfg && cfg.username
|
|
327
|
+
? { ...cfg, workingDir: cfg.workingDir || '~', command: '' }
|
|
328
|
+
: { host: 'localhost', workingDir: session.projectPath || '~', command: '' };
|
|
329
|
+
const newTerminalId = await createTerminal(newConfig, null);
|
|
330
|
+
|
|
331
|
+
// Immediately consume the pendingLink that createTerminal registered.
|
|
332
|
+
// The resume flow uses pendingResume (not pendingLinks) for session matching.
|
|
333
|
+
// If we leave the pendingLink alive, ANY other Claude session in the same
|
|
334
|
+
// working directory could match it via Priority 2 (tryLinkByWorkDir),
|
|
335
|
+
// stealing the terminal and creating a duplicate card.
|
|
336
|
+
consumePendingLink(newConfig.workingDir || session.projectPath || '');
|
|
337
|
+
|
|
338
|
+
// Update the REAL session and register pendingResume (no duplicate session)
|
|
339
|
+
const result = reconnectSessionTerminal(sessionId, newTerminalId);
|
|
340
|
+
if ('error' in result) { res.status(500).json({ error: result.error }); return; }
|
|
341
|
+
|
|
342
|
+
// Write the resume command once the shell is ready (prompt detected).
|
|
343
|
+
// For remote sessions, export AGENT_MANAGER_TERMINAL_ID (SSH doesn't
|
|
344
|
+
// forward env vars) and cd to workDir first.
|
|
345
|
+
let prefix = '';
|
|
346
|
+
if (isRemote) {
|
|
347
|
+
prefix += `export AGENT_MANAGER_TERMINAL_ID='${newTerminalId}' && `;
|
|
348
|
+
if (cfg?.workingDir) prefix += `cd '${cfg.workingDir}' && `;
|
|
349
|
+
}
|
|
350
|
+
writeWhenReady(newTerminalId, `${prefix}${resumeCmd}\r`);
|
|
351
|
+
|
|
352
|
+
const { broadcast } = await import('./wsManager.js');
|
|
353
|
+
broadcast({ type: WS_TYPES.SESSION_UPDATE, session: result.session });
|
|
354
|
+
|
|
355
|
+
res.json({ ok: true, terminalId: newTerminalId, newTerminal: true });
|
|
356
|
+
} catch (err: unknown) {
|
|
357
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
358
|
+
log.error('api', `Resume with new terminal failed: ${msg}`);
|
|
359
|
+
res.status(500).json({ error: `Failed to create new terminal: ${msg}` });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Kill session process — sends SIGTERM, then SIGKILL after 3s if still alive
|
|
364
|
+
router.post('/sessions/:id/kill', (req: Request, res: Response) => {
|
|
365
|
+
const body = validateBody(killSessionSchema, req.body, res);
|
|
366
|
+
if (!body) return;
|
|
367
|
+
const sessionId = str(req.params.id);
|
|
368
|
+
const mem = getSession(sessionId);
|
|
369
|
+
if (!mem) {
|
|
370
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const pid = findClaudeProcess(sessionId, mem?.projectPath);
|
|
374
|
+
const source = detectSessionSource(sessionId);
|
|
375
|
+
if (pid) {
|
|
376
|
+
try {
|
|
377
|
+
process.kill(pid, 'SIGTERM');
|
|
378
|
+
// Follow up with SIGKILL after 3s if process is still alive
|
|
379
|
+
setTimeout(() => {
|
|
380
|
+
try {
|
|
381
|
+
process.kill(pid, 0); // Check if still alive
|
|
382
|
+
process.kill(pid, 'SIGKILL');
|
|
383
|
+
} catch { /* already dead — good */ }
|
|
384
|
+
}, 3000);
|
|
385
|
+
} catch (e: unknown) {
|
|
386
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
387
|
+
res.status(500).json({ error: `Failed to kill PID ${pid}: ${msg}` });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const session = killSession(sessionId);
|
|
392
|
+
archiveSession(sessionId, true);
|
|
393
|
+
// Close associated SSH terminal if present
|
|
394
|
+
if (session && session.terminalId) {
|
|
395
|
+
closeTerminal(session.terminalId);
|
|
396
|
+
} else if (mem && mem.terminalId) {
|
|
397
|
+
closeTerminal(mem.terminalId);
|
|
398
|
+
}
|
|
399
|
+
if (!session && !pid) {
|
|
400
|
+
res.status(404).json({ error: 'Session not found and no matching process' });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
res.json({ ok: true, pid: pid || null, source });
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Permanently delete a session — removes from memory, broadcasts removal to clients
|
|
407
|
+
router.delete('/sessions/:id', async (req: Request, res: Response) => {
|
|
408
|
+
const sessionId = str(req.params.id);
|
|
409
|
+
const session = getSession(sessionId);
|
|
410
|
+
// Close terminal if still active
|
|
411
|
+
if (session && session.terminalId) {
|
|
412
|
+
closeTerminal(session.terminalId);
|
|
413
|
+
}
|
|
414
|
+
const removed = deleteSessionFromMemory(sessionId);
|
|
415
|
+
// Broadcast session_removed so all connected browsers remove the card
|
|
416
|
+
try {
|
|
417
|
+
const { broadcast } = await import('./wsManager.js');
|
|
418
|
+
broadcast({ type: WS_TYPES.SESSION_REMOVED, sessionId });
|
|
419
|
+
} catch (e: unknown) {
|
|
420
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
421
|
+
log.warn('api', `Failed to broadcast session_removed: ${msg}`);
|
|
422
|
+
}
|
|
423
|
+
res.json({ ok: true, removed });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Detect session source (vscode / terminal)
|
|
427
|
+
router.get('/sessions/:id/source', (req: Request, res: Response) => {
|
|
428
|
+
const source = detectSessionSource(str(req.params.id));
|
|
429
|
+
res.json({ source });
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Update session title (in-memory only, no DB write)
|
|
433
|
+
router.put('/sessions/:id/title', (req: Request, res: Response) => {
|
|
434
|
+
const body = validateBody(titleSchema, req.body, res);
|
|
435
|
+
if (!body) return;
|
|
436
|
+
setSessionTitle(str(req.params.id), body.title);
|
|
437
|
+
res.json({ ok: true });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Update session label (in-memory only, no DB write)
|
|
441
|
+
router.put('/sessions/:id/label', (req: Request, res: Response) => {
|
|
442
|
+
const body = validateBody(labelSchema, req.body, res);
|
|
443
|
+
if (!body) return;
|
|
444
|
+
setSessionLabel(str(req.params.id), body.label);
|
|
445
|
+
res.json({ ok: true });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Update session accent color
|
|
449
|
+
router.put('/sessions/:id/accent-color', (req: Request, res: Response) => {
|
|
450
|
+
const body = validateBody(accentColorSchema, req.body, res);
|
|
451
|
+
if (!body) return;
|
|
452
|
+
setSessionAccentColor(str(req.params.id), body.color);
|
|
453
|
+
res.json({ ok: true });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Summarize session using Claude CLI.
|
|
458
|
+
* The frontend sends { context, promptTemplate } from IndexedDB data.
|
|
459
|
+
* If custom_prompt is provided, use it directly as the prompt template.
|
|
460
|
+
*/
|
|
461
|
+
router.post('/sessions/:id/summarize', async (req: Request, res: Response) => {
|
|
462
|
+
// Rate limit: max 2 concurrent summarize requests
|
|
463
|
+
if (activeSummarizeRequests >= MAX_CONCURRENT_SUMMARIZE) {
|
|
464
|
+
res.status(429).json({ success: false, error: 'Too many concurrent summarize requests (max 2)' });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
activeSummarizeRequests++;
|
|
468
|
+
|
|
469
|
+
const sessionId = str(req.params.id);
|
|
470
|
+
const body = validateBody(summarizeSchema, req.body, res);
|
|
471
|
+
if (!body) {
|
|
472
|
+
activeSummarizeRequests--;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const { context, promptTemplate: bodyPromptTemplate, custom_prompt: customPrompt } = body;
|
|
476
|
+
|
|
477
|
+
// Determine prompt template: custom_prompt > bodyPromptTemplate > default
|
|
478
|
+
const promptTemplate = customPrompt || bodyPromptTemplate || 'Summarize this Claude Code session in detail.';
|
|
479
|
+
|
|
480
|
+
const summaryPrompt = `${promptTemplate}\n\n--- SESSION TRANSCRIPT ---\n${context}`;
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const summary = await new Promise<string>((resolve, reject) => {
|
|
484
|
+
const child = execFile('claude', ['-p', '--model', 'haiku'], {
|
|
485
|
+
timeout: 60000,
|
|
486
|
+
maxBuffer: 1024 * 1024,
|
|
487
|
+
}, (error, stdout) => {
|
|
488
|
+
if (error) return reject(error);
|
|
489
|
+
resolve(stdout.trim());
|
|
490
|
+
});
|
|
491
|
+
child.stdin!.write(summaryPrompt);
|
|
492
|
+
child.stdin!.end();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Store summary in memory
|
|
496
|
+
setSummary(sessionId, summary);
|
|
497
|
+
archiveSession(sessionId, true);
|
|
498
|
+
|
|
499
|
+
activeSummarizeRequests--;
|
|
500
|
+
res.json({ ok: true, summary });
|
|
501
|
+
} catch (err: unknown) {
|
|
502
|
+
activeSummarizeRequests--;
|
|
503
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
504
|
+
log.error('api', `Summarize error: ${msg}`);
|
|
505
|
+
res.status(500).json({ success: false, error: `Summarize failed: ${msg}` });
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ── SSH Keys ──
|
|
510
|
+
|
|
511
|
+
router.get('/ssh-keys', (_req: Request, res: Response) => {
|
|
512
|
+
res.json({ keys: listSshKeys() });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ── Tmux Sessions ──
|
|
516
|
+
|
|
517
|
+
router.post('/tmux-sessions', async (req: Request, res: Response) => {
|
|
518
|
+
const body = validateBody(tmuxSessionsSchema, req.body, res);
|
|
519
|
+
if (!body) return;
|
|
520
|
+
try {
|
|
521
|
+
const resolvedHost = body.host || 'localhost';
|
|
522
|
+
const username = body.username || getDefaultUsername() || (isLocalHost(resolvedHost) ? 'local' : null);
|
|
523
|
+
if (!username) { res.status(400).json({ error: 'username required' }); return; }
|
|
524
|
+
const config: TerminalConfig = {
|
|
525
|
+
host: resolvedHost,
|
|
526
|
+
port: body.port || 22,
|
|
527
|
+
username,
|
|
528
|
+
authMethod: body.authMethod || 'key',
|
|
529
|
+
privateKeyPath: body.privateKeyPath,
|
|
530
|
+
workingDir: '~',
|
|
531
|
+
command: '',
|
|
532
|
+
password: body.password,
|
|
533
|
+
};
|
|
534
|
+
const sessions = await listTmuxSessions(config);
|
|
535
|
+
res.json({ sessions });
|
|
536
|
+
} catch (err: unknown) {
|
|
537
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
538
|
+
res.status(500).json({ error: msg });
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── Terminals ──
|
|
543
|
+
|
|
544
|
+
router.post('/terminals', async (req: Request, res: Response) => {
|
|
545
|
+
// Rate limit: max 10 terminals total
|
|
546
|
+
const currentTerminals = getTerminals();
|
|
547
|
+
if (currentTerminals.length >= MAX_TERMINALS) {
|
|
548
|
+
res.status(429).json({ success: false, error: `Terminal limit reached (max ${MAX_TERMINALS})` });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const body = validateBody(terminalCreateSchema, req.body, res);
|
|
553
|
+
if (!body) return;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const resolvedHost = body.host || 'localhost';
|
|
557
|
+
const username = body.username || getDefaultUsername() || (isLocalHost(resolvedHost) ? 'local' : null);
|
|
558
|
+
if (!username) {
|
|
559
|
+
res.status(400).json({ success: false, error: 'username required — set it once in "+ NEW SESSION" and it will be reused' });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
saveLastUsername(username);
|
|
563
|
+
|
|
564
|
+
const config: TerminalConfig = {
|
|
565
|
+
host: resolvedHost,
|
|
566
|
+
port: body.port || 22,
|
|
567
|
+
username,
|
|
568
|
+
authMethod: body.authMethod || 'key',
|
|
569
|
+
privateKeyPath: body.privateKeyPath,
|
|
570
|
+
workingDir: body.workingDir || '~',
|
|
571
|
+
command: body.command || 'claude',
|
|
572
|
+
password: body.password,
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Tmux modes
|
|
576
|
+
if (body.tmuxSession) config.tmuxSession = body.tmuxSession;
|
|
577
|
+
if (body.useTmux) config.useTmux = true;
|
|
578
|
+
if (body.sessionTitle) config.sessionTitle = body.sessionTitle;
|
|
579
|
+
if (body.label) config.label = body.label;
|
|
580
|
+
|
|
581
|
+
// Resolve API key from request body only (no DB lookup)
|
|
582
|
+
if (body.apiKey) {
|
|
583
|
+
config.apiKey = body.apiKey;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const terminalId = await createTerminal(config, null);
|
|
587
|
+
// Create session card immediately so it appears in the dashboard
|
|
588
|
+
await createTerminalSession(terminalId, config);
|
|
589
|
+
res.json({ ok: true, terminalId });
|
|
590
|
+
} catch (err: unknown) {
|
|
591
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
592
|
+
res.status(500).json({ success: false, error: msg });
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
router.get('/terminals', (_req: Request, res: Response) => {
|
|
597
|
+
res.json({ terminals: getTerminals() });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
router.delete('/terminals/:id', (req: Request, res: Response) => {
|
|
601
|
+
closeTerminal(str(req.params.id));
|
|
602
|
+
res.json({ ok: true });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// ── Team Endpoints ──
|
|
606
|
+
|
|
607
|
+
// Get team config from ~/.claude/teams/{teamName}/config.json
|
|
608
|
+
router.get('/teams/:teamId/config', (req: Request, res: Response) => {
|
|
609
|
+
const team = getTeam(str(req.params.teamId));
|
|
610
|
+
if (!team) {
|
|
611
|
+
res.status(404).json({ error: 'Team not found' });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (!team.teamName) {
|
|
615
|
+
res.status(404).json({ error: 'Team has no name — cannot locate config' });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const config = readTeamConfig(team.teamName);
|
|
619
|
+
if (!config) {
|
|
620
|
+
res.json({ teamName: team.teamName, config: null });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
res.json({ teamName: team.teamName, config });
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Attach to a team member's tmux pane terminal
|
|
627
|
+
router.post('/teams/:teamId/members/:sessionId/terminal', async (req: Request, res: Response) => {
|
|
628
|
+
// Rate limit: max terminals
|
|
629
|
+
const currentTerminals = getTerminals();
|
|
630
|
+
if (currentTerminals.length >= MAX_TERMINALS) {
|
|
631
|
+
res.status(429).json({ success: false, error: `Terminal limit reached (max ${MAX_TERMINALS})` });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const teamId = str(req.params.teamId);
|
|
636
|
+
const sessionId = str(req.params.sessionId);
|
|
637
|
+
|
|
638
|
+
// Validate team exists
|
|
639
|
+
const team = getTeam(teamId);
|
|
640
|
+
if (!team) {
|
|
641
|
+
res.status(404).json({ error: 'Team not found' });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Validate session belongs to this team
|
|
646
|
+
const isMember = sessionId === team.parentSessionId || team.childSessionIds.includes(sessionId);
|
|
647
|
+
if (!isMember) {
|
|
648
|
+
res.status(404).json({ error: 'Session is not a member of this team' });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Get the member's session to find tmuxPaneId
|
|
653
|
+
const session = getSession(sessionId);
|
|
654
|
+
if (!session) {
|
|
655
|
+
res.status(404).json({ error: 'Session not found' });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const tmuxPaneId = session.tmuxPaneId;
|
|
660
|
+
if (!tmuxPaneId) {
|
|
661
|
+
res.status(400).json({ error: 'Session does not have a tmux pane ID — member may not be running in tmux' });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
const terminalId = await attachToTmuxPane(tmuxPaneId, null);
|
|
667
|
+
res.json({ ok: true, terminalId, tmuxPaneId });
|
|
668
|
+
} catch (err: unknown) {
|
|
669
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
670
|
+
log.error('api', `Failed to attach to tmux pane ${tmuxPaneId}: ${msg}`);
|
|
671
|
+
res.status(500).json({ success: false, error: msg });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// ---- Session History & DB endpoints (SQLite) ----
|
|
676
|
+
|
|
677
|
+
// Search/list sessions from DB (used by history panel, replaces IndexedDB reads)
|
|
678
|
+
router.get('/db/sessions', (req: Request, res: Response) => {
|
|
679
|
+
const { query, project, status, dateFrom, dateTo, archived, sortBy, sortDir, page, pageSize } = req.query;
|
|
680
|
+
const result = db.searchSessions({
|
|
681
|
+
query: (query as string) || undefined,
|
|
682
|
+
project: (project as string) || undefined,
|
|
683
|
+
status: (status as string) || undefined,
|
|
684
|
+
dateFrom: dateFrom ? Number(dateFrom) : undefined,
|
|
685
|
+
dateTo: dateTo ? Number(dateTo) : undefined,
|
|
686
|
+
archived: (archived as string) || undefined,
|
|
687
|
+
sortBy: ((sortBy as string) || 'started_at') as 'started_at' | 'last_activity_at' | 'project_name' | 'status',
|
|
688
|
+
sortDir: ((sortDir as string) || 'desc') as 'asc' | 'desc',
|
|
689
|
+
page: page ? Number(page) : 1,
|
|
690
|
+
pageSize: pageSize ? Number(pageSize) : 50,
|
|
691
|
+
});
|
|
692
|
+
res.json(result);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Get single session detail with all child records
|
|
696
|
+
router.get('/db/sessions/:id', (req: Request, res: Response) => {
|
|
697
|
+
const detail = db.getSessionDetail(str(req.params.id));
|
|
698
|
+
if (!detail) { res.status(404).json({ error: 'Session not found' }); return; }
|
|
699
|
+
res.json(detail);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Delete session from DB (cascade)
|
|
703
|
+
router.delete('/db/sessions/:id', (req: Request, res: Response) => {
|
|
704
|
+
db.deleteSessionCascade(str(req.params.id));
|
|
705
|
+
res.json({ ok: true });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Get distinct projects
|
|
709
|
+
router.get('/db/projects', (_req: Request, res: Response) => {
|
|
710
|
+
res.json(db.getDistinctProjects());
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// Full-text search across prompts and responses
|
|
714
|
+
router.get('/db/search', (req: Request, res: Response) => {
|
|
715
|
+
const { query, type, page, pageSize } = req.query;
|
|
716
|
+
res.json(db.fullTextSearch({
|
|
717
|
+
query: (query as string) || '',
|
|
718
|
+
type: (type as string) || 'all',
|
|
719
|
+
page: page ? Number(page) : 1,
|
|
720
|
+
pageSize: pageSize ? Number(pageSize) : 50,
|
|
721
|
+
}));
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ---- Notes (server-side, shared across all clients) ----
|
|
725
|
+
|
|
726
|
+
router.get('/db/sessions/:id/notes', (req: Request, res: Response) => {
|
|
727
|
+
res.json(db.getNotes(str(req.params.id)));
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
router.post('/db/sessions/:id/notes', (req: Request, res: Response) => {
|
|
731
|
+
const body = validateBody(noteSchema, req.body, res);
|
|
732
|
+
if (!body) return;
|
|
733
|
+
const note = db.addNote(str(req.params.id), body.text.trim());
|
|
734
|
+
res.json(note);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
router.delete('/db/notes/:id', (req: Request, res: Response) => {
|
|
738
|
+
db.deleteNote(Number(str(req.params.id)));
|
|
739
|
+
res.json({ ok: true });
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// ---- Analytics (server-side, shared across all clients) ----
|
|
743
|
+
|
|
744
|
+
router.get('/db/analytics/summary', (_req: Request, res: Response) => {
|
|
745
|
+
res.json(db.getSummaryStats());
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
router.get('/db/analytics/tools', (_req: Request, res: Response) => {
|
|
749
|
+
res.json(db.getToolBreakdown());
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
router.get('/db/analytics/projects', (_req: Request, res: Response) => {
|
|
753
|
+
res.json(db.getActiveProjects());
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
router.get('/db/analytics/heatmap', (_req: Request, res: Response) => {
|
|
757
|
+
res.json(db.getHeatmap());
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// Legacy endpoint (kept for backward compatibility)
|
|
761
|
+
router.get('/sessions/history', (req: Request, res: Response) => {
|
|
762
|
+
const projectPath = str(req.query.projectPath);
|
|
763
|
+
if (projectPath) {
|
|
764
|
+
if (projectPath.length > 1024) {
|
|
765
|
+
res.status(400).json({ error: 'Invalid projectPath' });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
res.json(db.getSessionsByProjectPath(projectPath));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
res.json(db.getAllPersistedSessions());
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
export default router;
|