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.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. 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
+ }
@@ -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'));
@@ -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
+ }