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.
Files changed (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. package/server/wsManager.ts +200 -0
@@ -0,0 +1,116 @@
1
+ // hookStats.ts — In-memory hook performance statistics
2
+ // Tracks delivery latency, server processing time, and throughput per event type.
3
+ // Stats are broadcast to the dashboard via WebSocket.
4
+
5
+ import type { HookStats, HookTimingStats, HookEventStats } from '../src/types/websocket.js';
6
+
7
+ const ROLLING_WINDOW = 200; // keep last N samples per event type
8
+ const RATE_WINDOW_MS = 60_000; // 1 minute for hooks/min calculation
9
+
10
+ interface EventBucket {
11
+ count: number;
12
+ latencies: number[]; // delivery latency (hook_sent_at -> server received), ms
13
+ processingTimes: number[]; // server handleEvent() duration, ms
14
+ timestamps: number[]; // for per-event rate
15
+ }
16
+
17
+ // Per event type
18
+ const byEvent: Record<string, EventBucket> = {};
19
+ // Global totals
20
+ let totalHooks = 0;
21
+ const globalTimestamps: number[] = []; // for hooks/min rate
22
+
23
+ function ensureEvent(eventType: string): EventBucket {
24
+ if (!byEvent[eventType]) {
25
+ byEvent[eventType] = {
26
+ count: 0,
27
+ latencies: [],
28
+ processingTimes: [],
29
+ timestamps: [],
30
+ };
31
+ }
32
+ return byEvent[eventType];
33
+ }
34
+
35
+ /**
36
+ * Record a hook event's timing.
37
+ * @param eventType - e.g. 'PreToolUse', 'Stop'
38
+ * @param deliveryLatencyMs - hook_sent_at -> server received (null if no timestamp)
39
+ * @param processingTimeMs - server handleEvent() duration
40
+ */
41
+ export function recordHook(eventType: string, deliveryLatencyMs: number | null, processingTimeMs: number): void {
42
+ const now = Date.now();
43
+ totalHooks++;
44
+
45
+ // Global rate tracking
46
+ globalTimestamps.push(now);
47
+ while (globalTimestamps.length > 0 && now - globalTimestamps[0] > RATE_WINDOW_MS) {
48
+ globalTimestamps.shift();
49
+ }
50
+
51
+ const ev = ensureEvent(eventType);
52
+ ev.count++;
53
+ ev.timestamps.push(now);
54
+
55
+ if (deliveryLatencyMs !== null && deliveryLatencyMs >= 0) {
56
+ ev.latencies.push(deliveryLatencyMs);
57
+ if (ev.latencies.length > ROLLING_WINDOW) ev.latencies.shift();
58
+ }
59
+
60
+ ev.processingTimes.push(processingTimeMs);
61
+ if (ev.processingTimes.length > ROLLING_WINDOW) ev.processingTimes.shift();
62
+
63
+ // Trim timestamps
64
+ while (ev.timestamps.length > 0 && now - ev.timestamps[0] > RATE_WINDOW_MS) {
65
+ ev.timestamps.shift();
66
+ }
67
+ }
68
+
69
+ function calcStats(arr: number[]): HookTimingStats {
70
+ if (arr.length === 0) return { avg: 0, min: 0, max: 0, p95: 0 };
71
+ const sorted = [...arr].sort((a, b) => a - b);
72
+ const sum = sorted.reduce((a, b) => a + b, 0);
73
+ return {
74
+ avg: Math.round(sum / sorted.length),
75
+ min: sorted[0],
76
+ max: sorted[sorted.length - 1],
77
+ p95: sorted[Math.floor(sorted.length * 0.95)],
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Get current hook stats snapshot for API/WebSocket.
83
+ */
84
+ export function getStats(): HookStats {
85
+ const now = Date.now();
86
+ const events: Record<string, HookEventStats> = {};
87
+
88
+ for (const [eventType, ev] of Object.entries(byEvent)) {
89
+ // Count hooks in last minute for per-event rate
90
+ const recentCount = ev.timestamps.filter(t => now - t < RATE_WINDOW_MS).length;
91
+ events[eventType] = {
92
+ count: ev.count,
93
+ rate: recentCount, // hooks in last minute
94
+ latency: calcStats(ev.latencies),
95
+ processing: calcStats(ev.processingTimes),
96
+ };
97
+ }
98
+
99
+ return {
100
+ totalHooks,
101
+ hooksPerMin: globalTimestamps.filter(t => now - t < RATE_WINDOW_MS).length,
102
+ events,
103
+ sampledAt: now,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Reset all stats (for testing or manual reset).
109
+ */
110
+ export function resetStats(): void {
111
+ totalHooks = 0;
112
+ globalTimestamps.length = 0;
113
+ for (const key of Object.keys(byEvent)) {
114
+ delete byEvent[key];
115
+ }
116
+ }
package/server/index.js CHANGED
@@ -65,7 +65,14 @@ app.post('/api/auth/logout', (req, res) => {
65
65
  });
66
66
 
67
67
  // ── Static files (always served — login page is part of SPA) ──
68
- app.use(express.static(join(__dirname, '..', 'public')));
68
+ // Serve built React app from dist/client (after vite build), fallback to public/
69
+ import { existsSync } from 'fs';
70
+ const distDir = join(__dirname, '..', 'dist', 'client');
71
+ if (existsSync(distDir)) {
72
+ app.use(express.static(distDir));
73
+ } else {
74
+ app.use(express.static(join(__dirname, '..', 'public')));
75
+ }
69
76
 
70
77
  // ── Hook endpoints (no auth — CLI hooks must work without login) ──
71
78
  app.use('/api/hooks', hookRateLimitMiddleware, hookRouter);
@@ -88,6 +95,13 @@ if (log.isDebug) {
88
95
  });
89
96
  }
90
97
 
98
+ // ── SPA fallback for React Router (dist/client only) ──
99
+ if (existsSync(distDir)) {
100
+ app.get('/{*path}', (req, res) => {
101
+ res.sendFile(join(distDir, 'index.html'));
102
+ });
103
+ }
104
+
91
105
  // ── WebSocket with auth validation ──
92
106
  wss.on('connection', (ws, req) => {
93
107
  if (isPasswordEnabled()) {
@@ -0,0 +1,230 @@
1
+ // index.ts — Express + WS server entry point (thin orchestrator)
2
+ // Quick start: npm start -> auto-installs hooks, starts server, opens browser
3
+ import express from 'express';
4
+ import { createServer } from 'http';
5
+ import { WebSocketServer } from 'ws';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+ import { execSync } from 'child_process';
9
+ import hookRouter from './hookRouter.js';
10
+ import { handleConnection, stopHeartbeat } from './wsManager.js';
11
+ import { getAllSessions, loadSnapshot, saveSnapshot, startPeriodicSave, stopPeriodicSave } from './sessionStore.js';
12
+ import { closeDb } from './db.js';
13
+ import apiRouter, { hookRateLimitMiddleware } from './apiRouter.js';
14
+ import { startMqReader, stopMqReader, getMqOffset } from './mqReader.js';
15
+ import log from './logger.js';
16
+ import { config } from './serverConfig.js';
17
+ import { ensureHooksInstalled } from './hookInstaller.js';
18
+ import { resolvePort, killPortProcess } from './portManager.js';
19
+ import { networkInterfaces } from 'os';
20
+ import {
21
+ isPasswordEnabled, verifyPassword, createToken, validateToken,
22
+ removeToken, parseCookieToken, extractToken, authMiddleware,
23
+ startTokenCleanup, stopTokenCleanup,
24
+ } from './authManager.js';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const args = process.argv.slice(2);
28
+ const noOpen = args.includes('--no-open');
29
+
30
+ const app = express();
31
+ const server = createServer(app);
32
+ const wss = new WebSocketServer({ server });
33
+
34
+ app.use(express.json({ limit: '10mb' }));
35
+
36
+ // -- Auth endpoints (no auth required) --
37
+ app.get('/api/auth/status', (req, res) => {
38
+ const passwordRequired = isPasswordEnabled();
39
+ const token = parseCookieToken(req.headers.cookie);
40
+ const authenticated = passwordRequired ? validateToken(token) : true;
41
+ res.json({ passwordRequired, authenticated });
42
+ });
43
+
44
+ app.post('/api/auth/login', (req, res) => {
45
+ if (!isPasswordEnabled()) {
46
+ res.json({ success: true });
47
+ return;
48
+ }
49
+ const { password } = req.body || {};
50
+ if (!password || typeof password !== 'string') {
51
+ res.status(400).json({ error: 'Password is required' });
52
+ return;
53
+ }
54
+ if (!verifyPassword(password, config.passwordHash ?? '')) {
55
+ res.status(401).json({ error: 'Wrong password' });
56
+ return;
57
+ }
58
+ const token = createToken();
59
+ res.setHeader('Set-Cookie', `auth_token=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${24 * 60 * 60}`);
60
+ res.json({ success: true, token });
61
+ });
62
+
63
+ app.post('/api/auth/logout', (req, res) => {
64
+ const token = parseCookieToken(req.headers.cookie);
65
+ removeToken(token ?? '');
66
+ res.setHeader('Set-Cookie', 'auth_token=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
67
+ res.json({ success: true });
68
+ });
69
+
70
+ // -- Static files (always served -- login page is part of SPA) --
71
+ app.use(express.static(join(__dirname, '..', 'public')));
72
+
73
+ // -- Hook endpoints (no auth -- CLI hooks must work without login) --
74
+ app.use('/api/hooks', hookRateLimitMiddleware, hookRouter);
75
+
76
+ // -- Protected API routes --
77
+ app.use('/api', authMiddleware, apiRouter);
78
+ app.get('/api/sessions', authMiddleware, (_req, res) => {
79
+ log.debug('api', 'GET /api/sessions');
80
+ res.json(getAllSessions());
81
+ });
82
+
83
+ // Request logging middleware (debug mode only)
84
+ if (log.isDebug) {
85
+ app.use((req, res, next) => {
86
+ const start = Date.now();
87
+ res.on('finish', () => {
88
+ log.debug('http', `${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
89
+ });
90
+ next();
91
+ });
92
+ }
93
+
94
+ // -- WebSocket with auth validation --
95
+ wss.on('connection', (ws, req) => {
96
+ if (isPasswordEnabled()) {
97
+ const token = extractToken(req);
98
+ if (!validateToken(token)) {
99
+ log.debug('auth', 'Rejected unauthorized WebSocket connection');
100
+ ws.close(4001, 'Unauthorized');
101
+ return;
102
+ }
103
+ }
104
+ handleConnection(ws);
105
+ });
106
+
107
+ wss.on('error', (err) => {
108
+ log.warn('ws', `WebSocket server error: ${err.message}`);
109
+ });
110
+
111
+ const PORT = resolvePort(args, config);
112
+
113
+ // Auto-open browser
114
+ function openBrowser(url: string): void {
115
+ if (noOpen) return;
116
+ try {
117
+ const cmd = process.platform === 'darwin' ? 'open'
118
+ : process.platform === 'win32' ? 'start'
119
+ : 'xdg-open';
120
+ execSync(`${cmd} "${url}"`, { stdio: 'ignore', timeout: 5000 });
121
+ } catch {
122
+ // Browser open failed -- not critical
123
+ }
124
+ }
125
+
126
+ function getLocalIP(): string | null {
127
+ const nets = networkInterfaces();
128
+ // Prefer en0 (Wi-Fi on macOS) for the most useful LAN address
129
+ const preferred = ['en0', 'en1', 'eth0', 'wlan0'];
130
+ for (const name of preferred) {
131
+ if (nets[name]) {
132
+ for (const cfg of nets[name]!) {
133
+ if (cfg.family === 'IPv4' && !cfg.internal) return cfg.address;
134
+ }
135
+ }
136
+ }
137
+ // Fallback to first non-internal IPv4
138
+ for (const iface of Object.values(nets)) {
139
+ if (!iface) continue;
140
+ for (const cfg of iface) {
141
+ if (cfg.family === 'IPv4' && !cfg.internal) return cfg.address;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function onReady(): void {
148
+ const localIP = getLocalIP();
149
+ log.info('server', 'AI Agent Session Center');
150
+ log.info('server', `Local: http://localhost:${PORT}`);
151
+ if (localIP) {
152
+ log.info('server', `Network: http://${localIP}:${PORT}`);
153
+ }
154
+ if (isPasswordEnabled()) {
155
+ log.info('server', 'Password protection ENABLED -- login required');
156
+ }
157
+ if (log.isDebug) {
158
+ log.info('server', 'Debug mode ENABLED -- verbose logging active');
159
+ }
160
+
161
+ // Auto-install hooks (copy script + register in settings.json)
162
+ ensureHooksInstalled(config);
163
+
164
+ // Restore sessions from snapshot (before starting MQ reader)
165
+ const snapshotResult = loadSnapshot();
166
+
167
+ // Start file-based message queue reader (resume from snapshot offset if available)
168
+ startMqReader(snapshotResult ? { resumeOffset: snapshotResult.mqOffset } : undefined);
169
+
170
+ // Start periodic snapshot saving (every 10s)
171
+ startPeriodicSave(getMqOffset);
172
+
173
+ // Start auth token cleanup (every hour)
174
+ startTokenCleanup();
175
+
176
+ // Open browser after a brief delay (let server fully initialize)
177
+ setTimeout(() => openBrowser(`http://localhost:${PORT}`), 300);
178
+ }
179
+
180
+ let retried = false;
181
+ server.on('error', (err: NodeJS.ErrnoException) => {
182
+ if (err.code === 'EADDRINUSE' && !retried) {
183
+ retried = true;
184
+ log.info('server', `Port ${PORT} in use -- killing existing process...`);
185
+ killPortProcess(PORT);
186
+ setTimeout(() => server.listen(PORT, onReady), 1000);
187
+ } else {
188
+ throw err;
189
+ }
190
+ });
191
+
192
+ server.listen(PORT, onReady);
193
+
194
+ // Graceful shutdown
195
+ function gracefulShutdown(signal: string): void {
196
+ log.info('server', `Received ${signal}, shutting down...`);
197
+ stopPeriodicSave();
198
+ stopHeartbeat();
199
+ stopMqReader();
200
+ stopTokenCleanup();
201
+ // Save final snapshot before exiting
202
+ saveSnapshot(getMqOffset());
203
+ closeDb();
204
+ server.close(() => {
205
+ log.info('server', 'Server closed');
206
+ process.exit(0);
207
+ });
208
+ setTimeout(() => process.exit(1), 5000);
209
+ }
210
+
211
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
212
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
213
+
214
+ // Global error handlers -- log and continue (don't crash on transient errors)
215
+ process.on('uncaughtException', (err: Error) => {
216
+ log.error('server', `Uncaught exception: ${err.message}`);
217
+ log.error('server', err.stack || '');
218
+ // Exit on truly fatal errors (e.g., out of memory)
219
+ if (err.message?.includes('out of memory') || err.message?.includes('ENOMEM')) {
220
+ process.exit(1);
221
+ }
222
+ });
223
+
224
+ process.on('unhandledRejection', (reason: unknown) => {
225
+ const msg = reason instanceof Error ? reason.message : String(reason);
226
+ log.error('server', `Unhandled rejection: ${msg}`);
227
+ if (reason instanceof Error && reason.stack) {
228
+ log.error('server', reason.stack);
229
+ }
230
+ });
@@ -0,0 +1,75 @@
1
+ // logger.ts — Debug-aware logging utility
2
+ // Usage: node server/index.js --debug OR npm start -- --debug
3
+
4
+ import { readFileSync } from 'fs';
5
+ import { join, dirname } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ // Check CLI flag first, then fall back to config file
9
+ let isDebug = process.argv.includes('--debug') || process.argv.includes('-debug');
10
+ if (!isDebug) {
11
+ try {
12
+ const __dir = dirname(fileURLToPath(import.meta.url));
13
+ const cfg = JSON.parse(readFileSync(join(__dir, '..', 'data', 'server-config.json'), 'utf8'));
14
+ if (cfg.debug) isDebug = true;
15
+ } catch { /* no config file yet */ }
16
+ }
17
+
18
+ const RESET = '\x1b[0m';
19
+ const DIM = '\x1b[2m';
20
+ const CYAN = '\x1b[36m';
21
+ const YELLOW = '\x1b[33m';
22
+ const RED = '\x1b[31m';
23
+ const MAGENTA = '\x1b[35m';
24
+
25
+ function timestamp(): string {
26
+ return new Date().toISOString().replace('T', ' ').replace('Z', '');
27
+ }
28
+
29
+ function formatTag(tag: string): string {
30
+ return `${DIM}[${timestamp()}]${RESET} ${CYAN}[${tag}]${RESET}`;
31
+ }
32
+
33
+ interface Logger {
34
+ info(tag: string, ...args: unknown[]): void;
35
+ warn(tag: string, ...args: unknown[]): void;
36
+ error(tag: string, ...args: unknown[]): void;
37
+ debug(tag: string, ...args: unknown[]): void;
38
+ debugJson(tag: string, label: string, obj: unknown): void;
39
+ readonly isDebug: boolean;
40
+ }
41
+
42
+ const logger: Logger = {
43
+ /** Always shown */
44
+ info(tag: string, ...args: unknown[]) {
45
+ console.log(formatTag(tag), ...args);
46
+ },
47
+
48
+ /** Always shown */
49
+ warn(tag: string, ...args: unknown[]) {
50
+ console.warn(`${formatTag(tag)} ${YELLOW}WARN${RESET}`, ...args);
51
+ },
52
+
53
+ /** Always shown */
54
+ error(tag: string, ...args: unknown[]) {
55
+ console.error(`${formatTag(tag)} ${RED}ERROR${RESET}`, ...args);
56
+ },
57
+
58
+ /** Only shown in debug mode */
59
+ debug(tag: string, ...args: unknown[]) {
60
+ if (!isDebug) return;
61
+ console.log(`${formatTag(tag)} ${MAGENTA}DEBUG${RESET}`, ...args);
62
+ },
63
+
64
+ /** Only shown in debug mode — logs object as JSON */
65
+ debugJson(tag: string, label: string, obj: unknown) {
66
+ if (!isDebug) return;
67
+ console.log(`${formatTag(tag)} ${MAGENTA}DEBUG${RESET} ${label}:`, JSON.stringify(obj, null, 2));
68
+ },
69
+
70
+ get isDebug() {
71
+ return isDebug;
72
+ },
73
+ };
74
+
75
+ export default logger;