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,543 @@
1
+ import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, existsSync, statSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const isWindows = process.platform === 'win32';
9
+
10
+ // ── ANSI colors ──
11
+ const RESET = '\x1b[0m';
12
+ const BOLD = '\x1b[1m';
13
+ const DIM = '\x1b[2m';
14
+ const GREEN = '\x1b[32m';
15
+ const YELLOW = '\x1b[33m';
16
+ const RED = '\x1b[31m';
17
+ const CYAN = '\x1b[36m';
18
+ const MAGENTA = '\x1b[35m';
19
+
20
+ // ── Log helpers ──
21
+ const ok = (msg) => console.log(` ${GREEN}✓${RESET} ${msg}`);
22
+ const warn = (msg) => console.log(` ${YELLOW}⚠${RESET} ${msg}`);
23
+ const fail = (msg) => console.log(` ${RED}✗${RESET} ${msg}`);
24
+ const info = (msg) => console.log(` ${DIM}→${RESET} ${msg}`);
25
+ const step = (n, total, label) => console.log(`\n${CYAN}[${n}/${total}]${RESET} ${BOLD}${label}${RESET}`);
26
+
27
+ // ── Platform-specific hook config ──
28
+ const HOOK_SCRIPT = isWindows ? 'dashboard-hook.ps1' : 'dashboard-hook.sh';
29
+ const HOOKS_DIR = join(homedir(), '.claude', 'hooks');
30
+ const HOOK_DEST = join(HOOKS_DIR, HOOK_SCRIPT);
31
+ const HOOK_COMMAND = isWindows
32
+ ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${HOOK_DEST}"`
33
+ : '~/.claude/hooks/dashboard-hook.sh';
34
+ const HOOK_PATTERN = 'dashboard-hook.';
35
+ const HOOK_SOURCE = 'ai-agent-session-center'; // Marker to identify our hooks in settings
36
+
37
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
38
+
39
+ // All possible hook events (Claude Code supports 14 lifecycle events)
40
+ const ALL_EVENTS = [
41
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
42
+ 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
43
+ 'TeammateIdle', 'TaskCompleted', 'PreCompact', 'SessionEnd'
44
+ ];
45
+
46
+ // Density levels: which events to register
47
+ const DENSITY_EVENTS = {
48
+ high: ALL_EVENTS,
49
+ medium: [
50
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
51
+ 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
52
+ 'TaskCompleted', 'SessionEnd'
53
+ ],
54
+ low: [
55
+ 'SessionStart', 'UserPromptSubmit', 'PermissionRequest', 'Stop', 'SessionEnd'
56
+ ]
57
+ };
58
+
59
+ // Parse CLI flags, then fall back to saved config
60
+ let density = 'medium';
61
+ let enabledClis = ['claude'];
62
+ const densityArgIdx = process.argv.indexOf('--density');
63
+ if (densityArgIdx >= 0 && process.argv[densityArgIdx + 1]) {
64
+ const val = process.argv[densityArgIdx + 1].toLowerCase();
65
+ if (DENSITY_EVENTS[val]) {
66
+ density = val;
67
+ } else {
68
+ console.error(`${RED}ERROR${RESET} Invalid density: "${val}" (use: high, medium, low)`);
69
+ process.exit(1);
70
+ }
71
+ } else {
72
+ // Read from saved config if no CLI flag
73
+ try {
74
+ const configPath = join(__dirname, '..', 'data', 'server-config.json');
75
+ const savedConfig = JSON.parse(readFileSync(configPath, 'utf8'));
76
+ if (savedConfig.hookDensity && DENSITY_EVENTS[savedConfig.hookDensity]) {
77
+ density = savedConfig.hookDensity;
78
+ }
79
+ if (savedConfig.enabledClis) enabledClis = savedConfig.enabledClis;
80
+ } catch { /* no saved config, use default */ }
81
+ }
82
+
83
+ // Parse --clis flag (e.g., --clis claude,gemini,codex)
84
+ const clisArgIdx = process.argv.indexOf('--clis');
85
+ if (clisArgIdx >= 0 && process.argv[clisArgIdx + 1]) {
86
+ enabledClis = process.argv[clisArgIdx + 1].split(',').map(s => s.trim().toLowerCase());
87
+ }
88
+
89
+ const uninstallMode = process.argv.includes('--uninstall');
90
+ const quietMode = process.argv.includes('--quiet');
91
+
92
+ const EVENTS = DENSITY_EVENTS[density];
93
+ const TOTAL_STEPS = uninstallMode ? 4 : 6;
94
+
95
+ // Gemini events by density
96
+ const GEMINI_DENSITY_EVENTS = {
97
+ high: ['SessionStart', 'BeforeAgent', 'BeforeTool', 'AfterTool', 'AfterAgent', 'SessionEnd', 'Notification'],
98
+ medium: ['SessionStart', 'BeforeAgent', 'AfterAgent', 'SessionEnd', 'Notification'],
99
+ low: ['SessionStart', 'AfterAgent', 'SessionEnd'],
100
+ };
101
+ const GEMINI_EVENTS = GEMINI_DENSITY_EVENTS[density] || GEMINI_DENSITY_EVENTS.medium;
102
+
103
+ // ── Banner ──
104
+ if (!quietMode) {
105
+ console.log(`\n${CYAN}╭──────────────────────────────────────────────╮${RESET}`);
106
+ console.log(`${CYAN}│${RESET} ${BOLD}AI Agent Session Center — Hook Setup${RESET} ${CYAN}│${RESET}`);
107
+ console.log(`${CYAN}╰──────────────────────────────────────────────╯${RESET}`);
108
+ }
109
+
110
+ // ═══════════════════════════════════════════════
111
+ // STEP 1: Platform Detection
112
+ // ═══════════════════════════════════════════════
113
+ step(1, TOTAL_STEPS, 'Detecting platform...');
114
+ ok(`Platform: ${process.platform} (${isWindows ? 'PowerShell' : 'Bash'} hook)`);
115
+ ok(`Architecture: ${process.arch}`);
116
+ ok(`Node.js: ${process.version}`);
117
+ ok(`Home directory: ${homedir()}`);
118
+ info(`Settings path: ${SETTINGS_PATH}`);
119
+ info(`Hooks directory: ${HOOKS_DIR}`);
120
+
121
+ if (uninstallMode) {
122
+ info(`Mode: ${YELLOW}UNINSTALL${RESET} (removing all dashboard hooks)`);
123
+ } else {
124
+ info(`Enabled CLIs: ${BOLD}${enabledClis.join(', ')}${RESET}`);
125
+ info(`Density: ${BOLD}${density}${RESET} → ${EVENTS.length} of ${ALL_EVENTS.length} Claude events`);
126
+ info(`Claude events: ${EVENTS.join(', ')}`);
127
+ if (enabledClis.includes('gemini')) {
128
+ info(`Gemini events: ${GEMINI_EVENTS.join(', ')}`);
129
+ }
130
+ if (enabledClis.includes('codex')) {
131
+ info(`Codex: agent-turn-complete (only event)`);
132
+ }
133
+ const excluded = ALL_EVENTS.filter(e => !EVENTS.includes(e));
134
+ if (excluded.length > 0) {
135
+ info(`Excluded (Claude): ${DIM}${excluded.join(', ')}${RESET}`);
136
+ }
137
+ }
138
+
139
+ // ═══════════════════════════════════════════════
140
+ // STEP 2: Dependency Check
141
+ // ═══════════════════════════════════════════════
142
+ step(2, TOTAL_STEPS, 'Checking dependencies...');
143
+
144
+ if (!isWindows && !uninstallMode) {
145
+ // Check jq
146
+ try {
147
+ const jqPath = execSync('which jq', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
148
+ const jqVersion = execSync('jq --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
149
+ ok(`jq: ${jqVersion} (${jqPath})`);
150
+ } catch {
151
+ warn(`jq: ${YELLOW}NOT FOUND${RESET} — hook will work but without PID/TTY/tab enrichment`);
152
+ info(`Install jq for full session detection:`);
153
+ info(` ${DIM}brew install jq${RESET} (macOS)`);
154
+ info(` ${DIM}apt install jq${RESET} (Linux)`);
155
+ }
156
+
157
+ // Check curl
158
+ try {
159
+ const curlPath = execSync('which curl', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
160
+ ok(`curl: found (${curlPath})`);
161
+ } catch {
162
+ fail(`curl: ${RED}NOT FOUND${RESET} — hook cannot send data to dashboard!`);
163
+ info('Install curl to enable hook communication');
164
+ }
165
+
166
+ // Check bash version
167
+ try {
168
+ const bashVersion = execSync('bash --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
169
+ .split('\n')[0].replace(/.*version\s+/, '').replace(/\(.*/, '').trim();
170
+ ok(`bash: ${bashVersion}`);
171
+ } catch {
172
+ info('bash: version unknown');
173
+ }
174
+ } else if (isWindows) {
175
+ try {
176
+ const psVersion = execSync('powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"',
177
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
178
+ ok(`PowerShell: ${psVersion}`);
179
+ } catch {
180
+ warn('PowerShell: version unknown');
181
+ }
182
+ } else {
183
+ info('Dependency check skipped (uninstall mode)');
184
+ }
185
+
186
+ // ═══════════════════════════════════════════════
187
+ // STEP 3: Prepare Directories & Read Settings
188
+ // ═══════════════════════════════════════════════
189
+ step(3, TOTAL_STEPS, 'Preparing directories & reading settings...');
190
+
191
+ // Ensure ~/.claude/hooks/ exists
192
+ if (existsSync(HOOKS_DIR)) {
193
+ ok(`Hooks directory exists: ${HOOKS_DIR}`);
194
+ } else {
195
+ mkdirSync(HOOKS_DIR, { recursive: true });
196
+ ok(`Created hooks directory: ${HOOKS_DIR}`);
197
+ }
198
+
199
+ // Ensure ~/.claude/ exists
200
+ const claudeDir = join(homedir(), '.claude');
201
+ if (!existsSync(claudeDir)) {
202
+ mkdirSync(claudeDir, { recursive: true });
203
+ ok(`Created ~/.claude/ directory`);
204
+ }
205
+
206
+ // Read current settings
207
+ let settings;
208
+ try {
209
+ const raw = readFileSync(SETTINGS_PATH, 'utf8');
210
+ settings = JSON.parse(raw);
211
+ const size = statSync(SETTINGS_PATH).size;
212
+ ok(`Settings file loaded: ${SETTINGS_PATH} (${formatBytes(size)})`);
213
+
214
+ // Report existing hooks
215
+ const existingHookEvents = Object.keys(settings.hooks || {});
216
+ if (existingHookEvents.length > 0) {
217
+ info(`Existing hook events in settings: ${existingHookEvents.join(', ')}`);
218
+ // Check for non-dashboard hooks
219
+ for (const event of existingHookEvents) {
220
+ const groups = settings.hooks[event] || [];
221
+ const otherHooks = groups.filter(g => !g.hooks?.some(h => h.command?.includes(HOOK_PATTERN)));
222
+ if (otherHooks.length > 0) {
223
+ info(` ${event}: ${otherHooks.length} non-dashboard hook(s) will be preserved`);
224
+ }
225
+ }
226
+ } else {
227
+ info('No existing hooks registered');
228
+ }
229
+ } catch (err) {
230
+ if (err.code === 'ENOENT') {
231
+ settings = {};
232
+ ok(`Creating new settings file: ${SETTINGS_PATH}`);
233
+ } else {
234
+ fail(`Failed to read settings: ${err.message}`);
235
+ throw err;
236
+ }
237
+ }
238
+
239
+ if (!settings.hooks) settings.hooks = {};
240
+
241
+ // ═══════════════════════════════════════════════
242
+ // UNINSTALL MODE
243
+ // ═══════════════════════════════════════════════
244
+ if (uninstallMode) {
245
+ step(4, TOTAL_STEPS, 'Removing dashboard hooks...');
246
+
247
+ let removed = 0;
248
+ for (const event of ALL_EVENTS) {
249
+ if (!settings.hooks[event]) continue;
250
+ const before = settings.hooks[event].length;
251
+ settings.hooks[event] = settings.hooks[event].filter(group =>
252
+ !group.hooks?.some(h => h.command?.includes(HOOK_PATTERN))
253
+ );
254
+ if (settings.hooks[event].length === 0) {
255
+ delete settings.hooks[event];
256
+ }
257
+ if (before !== (settings.hooks[event]?.length ?? 0)) {
258
+ removed++;
259
+ ok(`Removed hook for ${event}`);
260
+ }
261
+ }
262
+
263
+ if (removed === 0) {
264
+ info('No dashboard hooks were found to remove');
265
+ }
266
+
267
+ if (Object.keys(settings.hooks).length === 0) {
268
+ settings.hooks = {};
269
+ }
270
+
271
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
272
+ ok(`Saved settings: ${removed} hook(s) removed`);
273
+
274
+ // Summary
275
+ printSummary(`Uninstall complete — ${removed} hook(s) removed`);
276
+ process.exit(0);
277
+ }
278
+
279
+ // ═══════════════════════════════════════════════
280
+ // STEP 4: Configure Hook Events
281
+ // ═══════════════════════════════════════════════
282
+ step(4, TOTAL_STEPS, `Configuring hook events (density: ${density})...`);
283
+
284
+ let added = 0;
285
+ let updated = 0;
286
+ let unchanged = 0;
287
+ let removedCount = 0;
288
+
289
+ // Add/update hooks for events in the selected density
290
+ for (const event of EVENTS) {
291
+ if (!settings.hooks[event]) settings.hooks[event] = [];
292
+
293
+ const existingIdx = settings.hooks[event].findIndex(group =>
294
+ group.hooks?.some(h => h.command?.includes(HOOK_PATTERN))
295
+ );
296
+
297
+ if (existingIdx >= 0) {
298
+ const group = settings.hooks[event][existingIdx];
299
+ const hookEntry = group.hooks.find(h => h.command?.includes(HOOK_PATTERN));
300
+ if (hookEntry && hookEntry.command !== HOOK_COMMAND) {
301
+ hookEntry.command = HOOK_COMMAND;
302
+ ok(`Updated hook command for ${event}`);
303
+ updated++;
304
+ } else {
305
+ info(`${event}: already registered ${DIM}(no change)${RESET}`);
306
+ unchanged++;
307
+ }
308
+ } else {
309
+ settings.hooks[event].push({
310
+ _source: HOOK_SOURCE,
311
+ hooks: [{
312
+ type: 'command',
313
+ command: HOOK_COMMAND,
314
+ async: true
315
+ }]
316
+ });
317
+ ok(`Added hook for ${GREEN}${event}${RESET}`);
318
+ added++;
319
+ }
320
+ }
321
+
322
+ // Remove hooks for events NOT in the selected density
323
+ const excludedEvents = ALL_EVENTS.filter(e => !EVENTS.includes(e));
324
+ for (const event of excludedEvents) {
325
+ if (!settings.hooks[event]) continue;
326
+ const before = settings.hooks[event].length;
327
+ settings.hooks[event] = settings.hooks[event].filter(group =>
328
+ !group.hooks?.some(h => h.command?.includes(HOOK_PATTERN))
329
+ );
330
+ if (settings.hooks[event].length === 0) {
331
+ delete settings.hooks[event];
332
+ }
333
+ if (before !== (settings.hooks[event]?.length ?? 0)) {
334
+ removedCount++;
335
+ ok(`Removed hook for ${YELLOW}${event}${RESET} ${DIM}(not in ${density} density)${RESET}`);
336
+ }
337
+ }
338
+
339
+ // Write settings
340
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
341
+ info(`Settings saved: ${GREEN}${added} added${RESET}, ${CYAN}${updated} updated${RESET}, ${YELLOW}${removedCount} removed${RESET}, ${DIM}${unchanged} unchanged${RESET}`);
342
+
343
+ // ═══════════════════════════════════════════════
344
+ // STEP 5: Deploy Hook Scripts
345
+ // ═══════════════════════════════════════════════
346
+ step(5, TOTAL_STEPS, 'Deploying hook scripts...');
347
+
348
+ // Copy primary hook script
349
+ const src = join(__dirname, HOOK_SCRIPT);
350
+ if (!existsSync(src)) {
351
+ fail(`Hook script not found: ${src}`);
352
+ console.error(`\n${RED}ERROR${RESET}: Expected hook script at ${src}`);
353
+ console.error('Make sure you are running this from the project directory.');
354
+ process.exit(1);
355
+ }
356
+
357
+ const srcSize = statSync(src).size;
358
+ copyFileSync(src, HOOK_DEST);
359
+ if (!isWindows) {
360
+ chmodSync(HOOK_DEST, 0o755);
361
+ }
362
+ ok(`Deployed ${HOOK_SCRIPT} → ${HOOK_DEST} (${formatBytes(srcSize)}, ${isWindows ? 'standard' : 'chmod 755'})`);
363
+
364
+ // Copy alternate platform hook (for reference / dual-boot)
365
+ const altScript = isWindows ? 'dashboard-hook.sh' : 'dashboard-hook.ps1';
366
+ const altSrc = join(__dirname, altScript);
367
+ if (existsSync(altSrc)) {
368
+ const altDest = join(HOOKS_DIR, altScript);
369
+ copyFileSync(altSrc, altDest);
370
+ if (!isWindows && altScript.endsWith('.sh')) chmodSync(altDest, 0o755);
371
+ info(`Also copied ${altScript} → ${altDest} ${DIM}(reference copy)${RESET}`);
372
+ } else {
373
+ info(`Alternate hook ${altScript} not found ${DIM}(skipped)${RESET}`);
374
+ }
375
+
376
+ // Deploy Gemini hook script
377
+ if (enabledClis.includes('gemini')) {
378
+ const geminiSrc = join(__dirname, 'dashboard-hook-gemini.sh');
379
+ const geminiHooksDir = join(homedir(), '.gemini', 'hooks');
380
+ const geminiDest = join(geminiHooksDir, 'dashboard-hook.sh');
381
+ if (existsSync(geminiSrc)) {
382
+ mkdirSync(geminiHooksDir, { recursive: true });
383
+ copyFileSync(geminiSrc, geminiDest);
384
+ chmodSync(geminiDest, 0o755);
385
+ ok(`Deployed dashboard-hook-gemini.sh → ${geminiDest}`);
386
+ } else {
387
+ fail(`Gemini hook script not found: ${geminiSrc}`);
388
+ }
389
+
390
+ // Register in ~/.gemini/settings.json
391
+ const geminiSettingsPath = join(homedir(), '.gemini', 'settings.json');
392
+ try {
393
+ let gs;
394
+ try { gs = JSON.parse(readFileSync(geminiSettingsPath, 'utf8')); } catch { gs = {}; }
395
+ if (!gs.hooks) gs.hooks = {};
396
+ let gChanged = 0;
397
+ for (const event of GEMINI_EVENTS) {
398
+ if (!gs.hooks[event]) gs.hooks[event] = [];
399
+ const has = gs.hooks[event].some(g => g.hooks?.some(h => h.command?.includes('dashboard-hook')));
400
+ if (!has) {
401
+ gs.hooks[event].push({
402
+ _source: HOOK_SOURCE,
403
+ hooks: [{ type: 'command', command: `~/.gemini/hooks/dashboard-hook.sh ${event}` }]
404
+ });
405
+ gChanged++;
406
+ }
407
+ }
408
+ if (gChanged) {
409
+ mkdirSync(join(homedir(), '.gemini'), { recursive: true });
410
+ writeFileSync(geminiSettingsPath, JSON.stringify(gs, null, 2) + '\n');
411
+ ok(`Registered ${gChanged} Gemini hook events in ~/.gemini/settings.json`);
412
+ } else {
413
+ info('Gemini hooks already registered');
414
+ }
415
+ } catch (e) {
416
+ warn(`Gemini hook registration: ${e.message}`);
417
+ }
418
+ }
419
+
420
+ // Deploy Codex hook script
421
+ if (enabledClis.includes('codex')) {
422
+ const codexSrc = join(__dirname, 'dashboard-hook-codex.sh');
423
+ const codexHooksDir = join(homedir(), '.codex', 'hooks');
424
+ const codexDest = join(codexHooksDir, 'dashboard-hook.sh');
425
+ if (existsSync(codexSrc)) {
426
+ mkdirSync(codexHooksDir, { recursive: true });
427
+ copyFileSync(codexSrc, codexDest);
428
+ chmodSync(codexDest, 0o755);
429
+ ok(`Deployed dashboard-hook-codex.sh → ${codexDest}`);
430
+ } else {
431
+ fail(`Codex hook script not found: ${codexSrc}`);
432
+ }
433
+
434
+ // Register in ~/.codex/config.toml
435
+ const codexConfigPath = join(homedir(), '.codex', 'config.toml');
436
+ try {
437
+ let toml = '';
438
+ try { toml = readFileSync(codexConfigPath, 'utf8'); } catch {}
439
+ if (!toml.includes('dashboard-hook')) {
440
+ mkdirSync(join(homedir(), '.codex'), { recursive: true });
441
+ const commentLine = `# [${HOOK_SOURCE}] Dashboard hook — safe to remove with "npm run reset"`;
442
+ const notifyLine = 'notify = ["~/.codex/hooks/dashboard-hook.sh"]';
443
+ if (toml && !toml.endsWith('\n')) toml += '\n';
444
+ toml += commentLine + '\n' + notifyLine + '\n';
445
+ writeFileSync(codexConfigPath, toml);
446
+ ok('Registered Codex notify hook in ~/.codex/config.toml');
447
+ } else {
448
+ info('Codex hook already registered');
449
+ }
450
+ } catch (e) {
451
+ warn(`Codex hook registration: ${e.message}`);
452
+ }
453
+ }
454
+
455
+ // ═══════════════════════════════════════════════
456
+ // STEP 6: Verify Installation
457
+ // ═══════════════════════════════════════════════
458
+ step(6, TOTAL_STEPS, 'Verifying installation...');
459
+
460
+ let verifyOk = true;
461
+
462
+ // Check hook script is executable
463
+ if (!isWindows) {
464
+ try {
465
+ execSync(`test -x "${HOOK_DEST}"`, { stdio: 'ignore' });
466
+ ok('Hook script is executable');
467
+ } catch {
468
+ fail('Hook script is NOT executable');
469
+ verifyOk = false;
470
+ }
471
+ }
472
+
473
+ // Verify settings file is valid JSON
474
+ try {
475
+ const check = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
476
+ const registeredEvents = Object.keys(check.hooks || {}).filter(event =>
477
+ check.hooks[event]?.some(g => g.hooks?.some(h => h.command?.includes(HOOK_PATTERN)))
478
+ );
479
+ ok(`Settings file: valid JSON`);
480
+ ok(`Dashboard hooks registered: ${registeredEvents.length} event(s)`);
481
+ if (registeredEvents.length !== EVENTS.length) {
482
+ warn(`Expected ${EVENTS.length} events but found ${registeredEvents.length}`);
483
+ verifyOk = false;
484
+ }
485
+ } catch (err) {
486
+ fail(`Settings file invalid: ${err.message}`);
487
+ verifyOk = false;
488
+ }
489
+
490
+ // Check hook destination exists
491
+ if (existsSync(HOOK_DEST)) {
492
+ ok(`Claude hook file exists at ${HOOK_DEST}`);
493
+ } else {
494
+ fail(`Claude hook file missing: ${HOOK_DEST}`);
495
+ verifyOk = false;
496
+ }
497
+
498
+ // Verify Gemini hooks
499
+ if (enabledClis.includes('gemini')) {
500
+ const geminiDest = join(homedir(), '.gemini', 'hooks', 'dashboard-hook.sh');
501
+ if (existsSync(geminiDest)) {
502
+ ok(`Gemini hook file exists at ${geminiDest}`);
503
+ } else {
504
+ fail(`Gemini hook file missing: ${geminiDest}`);
505
+ verifyOk = false;
506
+ }
507
+ }
508
+
509
+ // Verify Codex hooks
510
+ if (enabledClis.includes('codex')) {
511
+ const codexDest = join(homedir(), '.codex', 'hooks', 'dashboard-hook.sh');
512
+ if (existsSync(codexDest)) {
513
+ ok(`Codex hook file exists at ${codexDest}`);
514
+ } else {
515
+ fail(`Codex hook file missing: ${codexDest}`);
516
+ verifyOk = false;
517
+ }
518
+ }
519
+
520
+ // ═══════════════════════════════════════════════
521
+ // Summary
522
+ // ═══════════════════════════════════════════════
523
+ if (verifyOk) {
524
+ printSummary(`Setup complete! (density: ${density}, ${EVENTS.length} events)`);
525
+ info('Hook captures: claude_pid, tty_path, term_program, tab_id, vscode_pid, tmux, window_id');
526
+ console.log(`\n Start the dashboard: ${BOLD}npm start${RESET}\n`);
527
+ } else {
528
+ console.log(`\n${YELLOW}────────────────────────────────────────────────${RESET}`);
529
+ console.log(` ${YELLOW}⚠ Setup completed with warnings${RESET}`);
530
+ console.log(`${YELLOW}────────────────────────────────────────────────${RESET}\n`);
531
+ }
532
+
533
+ // ── Utility functions ──
534
+ function formatBytes(bytes) {
535
+ if (bytes < 1024) return `${bytes} B`;
536
+ return `${(bytes / 1024).toFixed(1)} KB`;
537
+ }
538
+
539
+ function printSummary(msg) {
540
+ console.log(`\n${GREEN}────────────────────────────────────────────────${RESET}`);
541
+ console.log(` ${GREEN}✓ ${msg}${RESET}`);
542
+ console.log(`${GREEN}────────────────────────────────────────────────${RESET}`);
543
+ }