ai-agent-session-center 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// hookStats.js — 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
|
+
const ROLLING_WINDOW = 200; // keep last N samples per event type
|
|
6
|
+
const RATE_WINDOW_MS = 60_000; // 1 minute for hooks/min calculation
|
|
7
|
+
|
|
8
|
+
// Per event type: { count, latencies: [], processingTimes: [], timestamps: [] }
|
|
9
|
+
const byEvent = {};
|
|
10
|
+
// Global totals
|
|
11
|
+
let totalHooks = 0;
|
|
12
|
+
const globalTimestamps = []; // for hooks/min rate
|
|
13
|
+
|
|
14
|
+
function ensureEvent(eventType) {
|
|
15
|
+
if (!byEvent[eventType]) {
|
|
16
|
+
byEvent[eventType] = {
|
|
17
|
+
count: 0,
|
|
18
|
+
latencies: [], // delivery latency (hook_sent_at → server received), ms
|
|
19
|
+
processingTimes: [], // server handleEvent() duration, ms
|
|
20
|
+
timestamps: [], // for per-event rate
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return byEvent[eventType];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record a hook event's timing.
|
|
28
|
+
* @param {string} eventType - e.g. 'PreToolUse', 'Stop'
|
|
29
|
+
* @param {number|null} deliveryLatencyMs - hook_sent_at → server received (null if no timestamp)
|
|
30
|
+
* @param {number} processingTimeMs - server handleEvent() duration
|
|
31
|
+
*/
|
|
32
|
+
export function recordHook(eventType, deliveryLatencyMs, processingTimeMs) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
totalHooks++;
|
|
35
|
+
|
|
36
|
+
// Global rate tracking
|
|
37
|
+
globalTimestamps.push(now);
|
|
38
|
+
while (globalTimestamps.length > 0 && now - globalTimestamps[0] > RATE_WINDOW_MS) {
|
|
39
|
+
globalTimestamps.shift();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ev = ensureEvent(eventType);
|
|
43
|
+
ev.count++;
|
|
44
|
+
ev.timestamps.push(now);
|
|
45
|
+
|
|
46
|
+
if (deliveryLatencyMs !== null && deliveryLatencyMs >= 0) {
|
|
47
|
+
ev.latencies.push(deliveryLatencyMs);
|
|
48
|
+
if (ev.latencies.length > ROLLING_WINDOW) ev.latencies.shift();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ev.processingTimes.push(processingTimeMs);
|
|
52
|
+
if (ev.processingTimes.length > ROLLING_WINDOW) ev.processingTimes.shift();
|
|
53
|
+
|
|
54
|
+
// Trim timestamps
|
|
55
|
+
while (ev.timestamps.length > 0 && now - ev.timestamps[0] > RATE_WINDOW_MS) {
|
|
56
|
+
ev.timestamps.shift();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function calcStats(arr) {
|
|
61
|
+
if (arr.length === 0) return { avg: 0, min: 0, max: 0, p95: 0 };
|
|
62
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
63
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
64
|
+
return {
|
|
65
|
+
avg: Math.round(sum / sorted.length),
|
|
66
|
+
min: sorted[0],
|
|
67
|
+
max: sorted[sorted.length - 1],
|
|
68
|
+
p95: sorted[Math.floor(sorted.length * 0.95)],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get current hook stats snapshot for API/WebSocket.
|
|
74
|
+
*/
|
|
75
|
+
export function getStats() {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const events = {};
|
|
78
|
+
|
|
79
|
+
for (const [eventType, ev] of Object.entries(byEvent)) {
|
|
80
|
+
// Count hooks in last minute for per-event rate
|
|
81
|
+
const recentCount = ev.timestamps.filter(t => now - t < RATE_WINDOW_MS).length;
|
|
82
|
+
events[eventType] = {
|
|
83
|
+
count: ev.count,
|
|
84
|
+
rate: recentCount, // hooks in last minute
|
|
85
|
+
latency: calcStats(ev.latencies),
|
|
86
|
+
processing: calcStats(ev.processingTimes),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
totalHooks,
|
|
92
|
+
hooksPerMin: globalTimestamps.filter(t => now - t < RATE_WINDOW_MS).length,
|
|
93
|
+
events,
|
|
94
|
+
sampledAt: now,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reset all stats (for testing or manual reset).
|
|
100
|
+
*/
|
|
101
|
+
export function resetStats() {
|
|
102
|
+
totalHooks = 0;
|
|
103
|
+
globalTimestamps.length = 0;
|
|
104
|
+
for (const key of Object.keys(byEvent)) {
|
|
105
|
+
delete byEvent[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// index.js — Express + WS server entry point
|
|
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 { homedir } from 'os';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import { copyFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
|
11
|
+
import hookRouter from './hookRouter.js';
|
|
12
|
+
import { handleConnection, broadcast } from './wsManager.js';
|
|
13
|
+
import { getAllSessions } from './sessionStore.js';
|
|
14
|
+
import apiRouter from './apiRouter.js';
|
|
15
|
+
import { startMqReader, stopMqReader } from './mqReader.js';
|
|
16
|
+
import log from './logger.js';
|
|
17
|
+
import { config } from './serverConfig.js';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const noOpen = args.includes('--no-open');
|
|
22
|
+
|
|
23
|
+
const app = express();
|
|
24
|
+
const server = createServer(app);
|
|
25
|
+
const wss = new WebSocketServer({ server });
|
|
26
|
+
|
|
27
|
+
app.use(express.json({ limit: '10mb' }));
|
|
28
|
+
app.use(express.static(join(__dirname, '..', 'public')));
|
|
29
|
+
app.use('/api', apiRouter);
|
|
30
|
+
app.use('/api/hooks', hookRouter);
|
|
31
|
+
app.get('/api/sessions', (req, res) => {
|
|
32
|
+
log.debug('api', 'GET /api/sessions');
|
|
33
|
+
res.json(getAllSessions());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Request logging middleware (debug mode only)
|
|
37
|
+
if (log.isDebug) {
|
|
38
|
+
app.use((req, res, next) => {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
res.on('finish', () => {
|
|
41
|
+
log.debug('http', `${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
|
|
42
|
+
});
|
|
43
|
+
next();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
wss.on('connection', handleConnection);
|
|
48
|
+
wss.on('error', () => {}); // Suppress WSS re-emit; handled on HTTP server
|
|
49
|
+
|
|
50
|
+
// Port priority: --port flag > PORT env > config file > 3333
|
|
51
|
+
function resolvePort() {
|
|
52
|
+
const portArgIdx = args.indexOf('--port');
|
|
53
|
+
if (portArgIdx >= 0 && args[portArgIdx + 1]) {
|
|
54
|
+
const p = parseInt(args[portArgIdx + 1], 10);
|
|
55
|
+
if (p > 0) return p;
|
|
56
|
+
}
|
|
57
|
+
if (process.env.PORT) {
|
|
58
|
+
const p = parseInt(process.env.PORT, 10);
|
|
59
|
+
if (p > 0) return p;
|
|
60
|
+
}
|
|
61
|
+
return config.port || 3333;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PORT = resolvePort();
|
|
65
|
+
|
|
66
|
+
function killPortProcess(port) {
|
|
67
|
+
try {
|
|
68
|
+
if (process.platform === 'win32') {
|
|
69
|
+
const output = execSync(
|
|
70
|
+
`netstat -ano | findstr :${port} | findstr LISTENING`,
|
|
71
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
72
|
+
);
|
|
73
|
+
const pids = [...new Set(
|
|
74
|
+
output.trim().split('\n')
|
|
75
|
+
.map(line => line.trim().split(/\s+/).pop())
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
)];
|
|
78
|
+
for (const pid of pids) {
|
|
79
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); } catch {}
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// macOS & Linux
|
|
83
|
+
const output = execSync(`lsof -ti:${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
84
|
+
const pids = output.trim().split('\n').filter(Boolean);
|
|
85
|
+
for (const pid of pids) {
|
|
86
|
+
try { process.kill(Number(pid), 'SIGTERM'); } catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// No process found on port — nothing to kill
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Hook auto-install ──
|
|
95
|
+
// Copies hook scripts and registers hooks for all enabled CLIs
|
|
96
|
+
// Runs on every startup so users never need to manually install hooks
|
|
97
|
+
function ensureHooksInstalled() {
|
|
98
|
+
const isWindows = process.platform === 'win32';
|
|
99
|
+
const hookPattern = 'dashboard-hook';
|
|
100
|
+
const hookSource = 'ai-agent-session-center';
|
|
101
|
+
|
|
102
|
+
// Read saved config
|
|
103
|
+
let density = 'medium';
|
|
104
|
+
let enabledClis = ['claude'];
|
|
105
|
+
try {
|
|
106
|
+
const serverConfig = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'server-config.json'), 'utf8'));
|
|
107
|
+
if (serverConfig.hookDensity) density = serverConfig.hookDensity;
|
|
108
|
+
if (serverConfig.enabledClis) enabledClis = serverConfig.enabledClis;
|
|
109
|
+
} catch {}
|
|
110
|
+
|
|
111
|
+
// ── Claude Code hooks ──
|
|
112
|
+
if (enabledClis.includes('claude')) {
|
|
113
|
+
const hookName = isWindows ? 'dashboard-hook.ps1' : 'dashboard-hook.sh';
|
|
114
|
+
const hookCommand = isWindows
|
|
115
|
+
? `powershell -NoProfile -ExecutionPolicy Bypass -File "~/.claude/hooks/${hookName}"`
|
|
116
|
+
: '~/.claude/hooks/dashboard-hook.sh';
|
|
117
|
+
const src = join(__dirname, '..', 'hooks', hookName);
|
|
118
|
+
const hooksDir = join(homedir(), '.claude', 'hooks');
|
|
119
|
+
const dest = join(hooksDir, hookName);
|
|
120
|
+
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
121
|
+
|
|
122
|
+
// Copy hook script
|
|
123
|
+
syncHookFile(src, dest, hooksDir, isWindows, 'claude');
|
|
124
|
+
|
|
125
|
+
// Register in settings.json
|
|
126
|
+
const densityEvents = {
|
|
127
|
+
high: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'TeammateIdle', 'TaskCompleted', 'PreCompact', 'SessionEnd'],
|
|
128
|
+
medium: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'TaskCompleted', 'SessionEnd'],
|
|
129
|
+
low: ['SessionStart', 'UserPromptSubmit', 'PermissionRequest', 'Stop', 'SessionEnd'],
|
|
130
|
+
};
|
|
131
|
+
const events = densityEvents[density] || densityEvents.medium;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
let settings;
|
|
135
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
|
|
136
|
+
if (!settings.hooks) settings.hooks = {};
|
|
137
|
+
|
|
138
|
+
let changed = false;
|
|
139
|
+
for (const event of events) {
|
|
140
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
141
|
+
const hasHook = settings.hooks[event].some(g =>
|
|
142
|
+
g.hooks?.some(h => h.command?.includes(hookPattern))
|
|
143
|
+
);
|
|
144
|
+
if (!hasHook) {
|
|
145
|
+
settings.hooks[event].push({
|
|
146
|
+
_source: hookSource,
|
|
147
|
+
hooks: [{ type: 'command', command: hookCommand, async: true }]
|
|
148
|
+
});
|
|
149
|
+
changed = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (changed) {
|
|
153
|
+
mkdirSync(join(homedir(), '.claude'), { recursive: true });
|
|
154
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
155
|
+
log.info('server', `Registered ${events.length} Claude hook events (density: ${density})`);
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
log.debug('server', `Claude hook registration skipped: ${e.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Gemini CLI hooks ──
|
|
163
|
+
if (enabledClis.includes('gemini')) {
|
|
164
|
+
const src = join(__dirname, '..', 'hooks', 'dashboard-hook-gemini.sh');
|
|
165
|
+
const hooksDir = join(homedir(), '.gemini', 'hooks');
|
|
166
|
+
const dest = join(hooksDir, 'dashboard-hook.sh');
|
|
167
|
+
const settingsPath = join(homedir(), '.gemini', 'settings.json');
|
|
168
|
+
|
|
169
|
+
syncHookFile(src, dest, hooksDir, false, 'gemini');
|
|
170
|
+
|
|
171
|
+
// Gemini events mapped to density
|
|
172
|
+
const geminiDensityEvents = {
|
|
173
|
+
high: ['SessionStart', 'BeforeAgent', 'BeforeTool', 'AfterTool', 'AfterAgent', 'SessionEnd', 'Notification'],
|
|
174
|
+
medium: ['SessionStart', 'BeforeAgent', 'AfterAgent', 'SessionEnd', 'Notification'],
|
|
175
|
+
low: ['SessionStart', 'AfterAgent', 'SessionEnd'],
|
|
176
|
+
};
|
|
177
|
+
const geminiEvents = geminiDensityEvents[density] || geminiDensityEvents.medium;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
let settings;
|
|
181
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
|
|
182
|
+
if (!settings.hooks) settings.hooks = {};
|
|
183
|
+
|
|
184
|
+
let changed = false;
|
|
185
|
+
for (const event of geminiEvents) {
|
|
186
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
187
|
+
const hasHook = settings.hooks[event].some(g =>
|
|
188
|
+
g.hooks?.some(h => h.command?.includes(hookPattern))
|
|
189
|
+
);
|
|
190
|
+
if (!hasHook) {
|
|
191
|
+
settings.hooks[event].push({
|
|
192
|
+
_source: hookSource,
|
|
193
|
+
hooks: [{ type: 'command', command: `~/.gemini/hooks/dashboard-hook.sh ${event}` }]
|
|
194
|
+
});
|
|
195
|
+
changed = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (changed) {
|
|
199
|
+
mkdirSync(join(homedir(), '.gemini'), { recursive: true });
|
|
200
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
201
|
+
log.info('server', `Registered ${geminiEvents.length} Gemini hook events (density: ${density})`);
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
log.debug('server', `Gemini hook registration skipped: ${e.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Codex CLI hooks ──
|
|
209
|
+
if (enabledClis.includes('codex')) {
|
|
210
|
+
const src = join(__dirname, '..', 'hooks', 'dashboard-hook-codex.sh');
|
|
211
|
+
const hooksDir = join(homedir(), '.codex', 'hooks');
|
|
212
|
+
const dest = join(hooksDir, 'dashboard-hook.sh');
|
|
213
|
+
const configPath = join(homedir(), '.codex', 'config.toml');
|
|
214
|
+
|
|
215
|
+
syncHookFile(src, dest, hooksDir, false, 'codex');
|
|
216
|
+
|
|
217
|
+
// Codex uses TOML config with a notify command
|
|
218
|
+
try {
|
|
219
|
+
let toml = '';
|
|
220
|
+
try { toml = readFileSync(configPath, 'utf8'); } catch {}
|
|
221
|
+
|
|
222
|
+
if (!toml.includes(hookPattern)) {
|
|
223
|
+
mkdirSync(join(homedir(), '.codex'), { recursive: true });
|
|
224
|
+
const commentLine = `# [${hookSource}] Dashboard hook — safe to remove with "npm run reset"`;
|
|
225
|
+
const notifyLine = `notify = ["~/.codex/hooks/dashboard-hook.sh"]`;
|
|
226
|
+
if (toml && !toml.endsWith('\n')) toml += '\n';
|
|
227
|
+
toml += commentLine + '\n' + notifyLine + '\n';
|
|
228
|
+
writeFileSync(configPath, toml);
|
|
229
|
+
log.info('server', 'Registered Codex notify hook in ~/.codex/config.toml');
|
|
230
|
+
}
|
|
231
|
+
} catch (e) {
|
|
232
|
+
log.debug('server', `Codex hook registration skipped: ${e.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Helper: copy hook script if changed
|
|
238
|
+
function syncHookFile(src, dest, hooksDir, isWindows, label) {
|
|
239
|
+
if (!existsSync(src)) return;
|
|
240
|
+
try {
|
|
241
|
+
let needsCopy = !existsSync(dest);
|
|
242
|
+
if (!needsCopy) {
|
|
243
|
+
const srcContent = readFileSync(src);
|
|
244
|
+
const destContent = readFileSync(dest);
|
|
245
|
+
needsCopy = !srcContent.equals(destContent);
|
|
246
|
+
}
|
|
247
|
+
if (needsCopy) {
|
|
248
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
249
|
+
copyFileSync(src, dest);
|
|
250
|
+
if (!isWindows) chmodSync(dest, 0o755);
|
|
251
|
+
log.info('server', `Synced ${label} hook → ${dest}`);
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
log.debug('server', `${label} hook file sync skipped: ${e.message}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Auto-open browser ──
|
|
259
|
+
function openBrowser(url) {
|
|
260
|
+
if (noOpen) return;
|
|
261
|
+
try {
|
|
262
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
263
|
+
: process.platform === 'win32' ? 'start'
|
|
264
|
+
: 'xdg-open';
|
|
265
|
+
execSync(`${cmd} "${url}"`, { stdio: 'ignore', timeout: 5000 });
|
|
266
|
+
} catch {
|
|
267
|
+
// Browser open failed — not critical
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function onReady() {
|
|
272
|
+
log.info('server', `AI Agent Session Center`);
|
|
273
|
+
log.info('server', `Dashboard: http://localhost:${PORT}`);
|
|
274
|
+
if (log.isDebug) {
|
|
275
|
+
log.info('server', 'Debug mode ENABLED — verbose logging active');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Auto-install hooks (copy script + register in settings.json)
|
|
279
|
+
ensureHooksInstalled();
|
|
280
|
+
|
|
281
|
+
// Start file-based message queue reader
|
|
282
|
+
startMqReader();
|
|
283
|
+
|
|
284
|
+
// Open browser after a brief delay (let server fully initialize)
|
|
285
|
+
setTimeout(() => openBrowser(`http://localhost:${PORT}`), 300);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let retried = false;
|
|
289
|
+
server.on('error', (err) => {
|
|
290
|
+
if (err.code === 'EADDRINUSE' && !retried) {
|
|
291
|
+
retried = true;
|
|
292
|
+
log.info('server', `Port ${PORT} in use — killing existing process…`);
|
|
293
|
+
killPortProcess(PORT);
|
|
294
|
+
setTimeout(() => server.listen(PORT, onReady), 1000);
|
|
295
|
+
} else {
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
server.listen(PORT, onReady);
|
|
301
|
+
|
|
302
|
+
// Graceful shutdown
|
|
303
|
+
function gracefulShutdown(signal) {
|
|
304
|
+
log.info('server', `Received ${signal}, shutting down...`);
|
|
305
|
+
stopMqReader();
|
|
306
|
+
server.close(() => {
|
|
307
|
+
log.info('server', 'Server closed');
|
|
308
|
+
process.exit(0);
|
|
309
|
+
});
|
|
310
|
+
setTimeout(() => process.exit(1), 5000);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
314
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
package/server/logger.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// logger.js — 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 GREEN = '\x1b[32m';
|
|
24
|
+
const MAGENTA = '\x1b[35m';
|
|
25
|
+
|
|
26
|
+
function timestamp() {
|
|
27
|
+
return new Date().toISOString().replace('T', ' ').replace('Z', '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatTag(tag) {
|
|
31
|
+
return `${DIM}[${timestamp()}]${RESET} ${CYAN}[${tag}]${RESET}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logger = {
|
|
35
|
+
/** Always shown */
|
|
36
|
+
info(tag, ...args) {
|
|
37
|
+
console.log(formatTag(tag), ...args);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/** Always shown */
|
|
41
|
+
warn(tag, ...args) {
|
|
42
|
+
console.warn(`${formatTag(tag)} ${YELLOW}WARN${RESET}`, ...args);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/** Always shown */
|
|
46
|
+
error(tag, ...args) {
|
|
47
|
+
console.error(`${formatTag(tag)} ${RED}ERROR${RESET}`, ...args);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/** Only shown in debug mode */
|
|
51
|
+
debug(tag, ...args) {
|
|
52
|
+
if (!isDebug) return;
|
|
53
|
+
console.log(`${formatTag(tag)} ${MAGENTA}DEBUG${RESET}`, ...args);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/** Only shown in debug mode — logs object as JSON */
|
|
57
|
+
debugJson(tag, label, obj) {
|
|
58
|
+
if (!isDebug) return;
|
|
59
|
+
console.log(`${formatTag(tag)} ${MAGENTA}DEBUG${RESET} ${label}:`, JSON.stringify(obj, null, 2));
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
get isDebug() {
|
|
63
|
+
return isDebug;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default logger;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// mqReader.js — File-based JSONL message queue reader
|
|
2
|
+
// Hooks append JSON lines to a queue file; this module watches it and processes events.
|
|
3
|
+
//
|
|
4
|
+
// Performance: fs.watch() for instant notification + 500ms fallback poll.
|
|
5
|
+
// Atomicity: POSIX guarantees atomic append for writes <= PIPE_BUF (4096 bytes).
|
|
6
|
+
// Our enriched hook JSON is typically 300-800 bytes.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync, mkdirSync, writeFileSync,
|
|
10
|
+
openSync, readSync, closeSync, fstatSync, watch
|
|
11
|
+
} from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { processHookEvent } from './hookProcessor.js';
|
|
14
|
+
import log from './logger.js';
|
|
15
|
+
|
|
16
|
+
// Use /tmp on macOS/Linux (matches the hardcoded path in dashboard-hook.sh).
|
|
17
|
+
// os.tmpdir() on macOS returns /var/folders/... which hooks can't predict.
|
|
18
|
+
// On Windows, hooks use $env:TEMP which matches os.tmpdir().
|
|
19
|
+
const QUEUE_DIR = process.platform === 'win32'
|
|
20
|
+
? join(process.env.TEMP || process.env.TMP || 'C:\\Temp', 'claude-session-center')
|
|
21
|
+
: '/tmp/claude-session-center';
|
|
22
|
+
const QUEUE_FILE = join(QUEUE_DIR, 'queue.jsonl');
|
|
23
|
+
const POLL_INTERVAL_MS = 500;
|
|
24
|
+
const DEBOUNCE_MS = 10;
|
|
25
|
+
const TRUNCATE_THRESHOLD = 1 * 1024 * 1024; // 1 MB
|
|
26
|
+
|
|
27
|
+
// Internal state
|
|
28
|
+
let watcher = null;
|
|
29
|
+
let pollTimer = null;
|
|
30
|
+
let lastByteOffset = 0;
|
|
31
|
+
let partialLine = '';
|
|
32
|
+
let debounceTimer = null;
|
|
33
|
+
let running = false;
|
|
34
|
+
|
|
35
|
+
// Stats
|
|
36
|
+
const mqStats = {
|
|
37
|
+
linesProcessed: 0,
|
|
38
|
+
linesErrored: 0,
|
|
39
|
+
truncations: 0,
|
|
40
|
+
lastProcessedAt: null,
|
|
41
|
+
startedAt: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start the MQ reader. Called once from server startup.
|
|
46
|
+
* Creates queue directory/file, truncates stale data, begins watching.
|
|
47
|
+
*/
|
|
48
|
+
export function startMqReader() {
|
|
49
|
+
if (running) return;
|
|
50
|
+
running = true;
|
|
51
|
+
mqStats.startedAt = Date.now();
|
|
52
|
+
|
|
53
|
+
// Ensure queue directory exists
|
|
54
|
+
mkdirSync(QUEUE_DIR, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Truncate (or create) the queue file — fresh start
|
|
57
|
+
writeFileSync(QUEUE_FILE, '');
|
|
58
|
+
lastByteOffset = 0;
|
|
59
|
+
partialLine = '';
|
|
60
|
+
|
|
61
|
+
log.info('mq', `Queue reader started: ${QUEUE_FILE}`);
|
|
62
|
+
|
|
63
|
+
// Start fs.watch for instant notification
|
|
64
|
+
try {
|
|
65
|
+
watcher = watch(QUEUE_FILE, (eventType) => {
|
|
66
|
+
if (eventType === 'change') {
|
|
67
|
+
scheduleRead();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
watcher.on('error', (err) => {
|
|
71
|
+
log.warn('mq', `fs.watch error: ${err.message}, relying on poll`);
|
|
72
|
+
watcher = null;
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.warn('mq', `fs.watch failed: ${err.message}, using poll only`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback poll (catches events fs.watch may miss)
|
|
79
|
+
pollTimer = setInterval(() => {
|
|
80
|
+
readNewLines();
|
|
81
|
+
}, POLL_INTERVAL_MS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Debounced read scheduler — coalesces rapid fs.watch events */
|
|
85
|
+
function scheduleRead() {
|
|
86
|
+
if (debounceTimer) return;
|
|
87
|
+
debounceTimer = setTimeout(() => {
|
|
88
|
+
debounceTimer = null;
|
|
89
|
+
readNewLines();
|
|
90
|
+
}, DEBOUNCE_MS);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Core read loop: reads from lastByteOffset to current EOF,
|
|
95
|
+
* processes complete JSON lines, retains any partial trailing line.
|
|
96
|
+
*/
|
|
97
|
+
function readNewLines() {
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = openSync(QUEUE_FILE, 'r');
|
|
101
|
+
const fileStat = fstatSync(fd);
|
|
102
|
+
const fileSize = fileStat.size;
|
|
103
|
+
|
|
104
|
+
// File was truncated externally or is smaller than our offset
|
|
105
|
+
if (fileSize < lastByteOffset) {
|
|
106
|
+
log.info('mq', 'Detected external truncation, resetting offset');
|
|
107
|
+
lastByteOffset = 0;
|
|
108
|
+
partialLine = '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (fileSize <= lastByteOffset) {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Read the new chunk
|
|
117
|
+
const bytesToRead = fileSize - lastByteOffset;
|
|
118
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
119
|
+
const bytesRead = readSync(fd, buffer, 0, bytesToRead, lastByteOffset);
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
fd = null;
|
|
122
|
+
|
|
123
|
+
if (bytesRead === 0) return;
|
|
124
|
+
|
|
125
|
+
const chunk = buffer.toString('utf-8', 0, bytesRead);
|
|
126
|
+
const combined = partialLine + chunk;
|
|
127
|
+
const lines = combined.split('\n');
|
|
128
|
+
|
|
129
|
+
// Last element is either '' (if chunk ended with \n) or a partial line
|
|
130
|
+
partialLine = lines.pop();
|
|
131
|
+
|
|
132
|
+
// Process each complete line
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed) continue;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const hookData = JSON.parse(trimmed);
|
|
139
|
+
processHookEvent(hookData, 'mq');
|
|
140
|
+
mqStats.linesProcessed++;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
mqStats.linesErrored++;
|
|
143
|
+
log.warn('mq', `Parse error: ${err.message} — line: ${trimmed.substring(0, 100)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Update offset: advance by bytes consumed (exclude held-back partial)
|
|
148
|
+
const partialBytes = Buffer.byteLength(partialLine, 'utf-8');
|
|
149
|
+
lastByteOffset = lastByteOffset + bytesRead - partialBytes;
|
|
150
|
+
mqStats.lastProcessedAt = Date.now();
|
|
151
|
+
|
|
152
|
+
// Truncate if file grew too large and we've fully caught up
|
|
153
|
+
if (lastByteOffset > TRUNCATE_THRESHOLD && partialLine === '') {
|
|
154
|
+
truncateQueue();
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (fd != null) {
|
|
158
|
+
try { closeSync(fd); } catch {}
|
|
159
|
+
}
|
|
160
|
+
if (err.code !== 'ENOENT') {
|
|
161
|
+
log.warn('mq', `Read error: ${err.message}`);
|
|
162
|
+
} else {
|
|
163
|
+
// Queue file deleted — recreate it
|
|
164
|
+
try { writeFileSync(QUEUE_FILE, ''); } catch {}
|
|
165
|
+
lastByteOffset = 0;
|
|
166
|
+
partialLine = '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Truncate the queue file after all lines have been processed. */
|
|
172
|
+
function truncateQueue() {
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(QUEUE_FILE, '');
|
|
175
|
+
lastByteOffset = 0;
|
|
176
|
+
partialLine = '';
|
|
177
|
+
mqStats.truncations++;
|
|
178
|
+
log.info('mq', 'Queue file truncated (all events processed)');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log.warn('mq', `Truncation error: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Stop the MQ reader. Called during server shutdown. */
|
|
185
|
+
export function stopMqReader() {
|
|
186
|
+
running = false;
|
|
187
|
+
if (watcher) {
|
|
188
|
+
watcher.close();
|
|
189
|
+
watcher = null;
|
|
190
|
+
}
|
|
191
|
+
if (pollTimer) {
|
|
192
|
+
clearInterval(pollTimer);
|
|
193
|
+
pollTimer = null;
|
|
194
|
+
}
|
|
195
|
+
if (debounceTimer) {
|
|
196
|
+
clearTimeout(debounceTimer);
|
|
197
|
+
debounceTimer = null;
|
|
198
|
+
}
|
|
199
|
+
// Final read to flush remaining lines
|
|
200
|
+
readNewLines();
|
|
201
|
+
log.info('mq', `Queue reader stopped. Processed: ${mqStats.linesProcessed}, Errors: ${mqStats.linesErrored}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Get MQ reader stats for the API. */
|
|
205
|
+
export function getMqStats() {
|
|
206
|
+
return {
|
|
207
|
+
...mqStats,
|
|
208
|
+
queueFile: QUEUE_FILE,
|
|
209
|
+
running,
|
|
210
|
+
currentOffset: lastByteOffset,
|
|
211
|
+
hasPartialLine: partialLine.length > 0,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Get the queue file path (used by install-hooks logging). */
|
|
216
|
+
export function getQueueFilePath() {
|
|
217
|
+
return QUEUE_FILE;
|
|
218
|
+
}
|