@kosinal/claude-code-dashboard 0.0.0 → 0.0.2

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 (2) hide show
  1. package/dist/bin.js +973 -713
  2. package/package.json +6 -2
package/dist/bin.js CHANGED
@@ -5,77 +5,572 @@ import { exec, spawn } from "child_process";
5
5
  import * as http2 from "http";
6
6
  import * as net from "net";
7
7
 
8
- // src/state.ts
9
- var EVENT_TO_STATUS = {
10
- SessionStart: "waiting",
11
- UserPromptSubmit: "running",
12
- Stop: "waiting"
13
- };
14
- function createStore() {
15
- const sessions = /* @__PURE__ */ new Map();
16
- return {
17
- handleEvent(payload) {
18
- const { session_id, hook_event_name, cwd } = payload;
19
- if (hook_event_name === "SessionEnd") {
20
- sessions.delete(session_id);
21
- return null;
8
+ // src/hooks.ts
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ var MARKER_QUICK = "__claude_code_dashboard_quick__";
13
+ var MARKER_INSTALL = "__claude_code_dashboard_install__";
14
+ var MARKER_LEGACY = "__claude_code_dashboard__";
15
+ var HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"];
16
+ function getConfigDir(configDir) {
17
+ return configDir ?? path.join(os.homedir(), ".claude");
18
+ }
19
+ function getSettingsPath(configDir) {
20
+ return path.join(getConfigDir(configDir), "settings.json");
21
+ }
22
+ function readSettings(configDir) {
23
+ const settingsPath = getSettingsPath(configDir);
24
+ try {
25
+ const content = fs.readFileSync(settingsPath, "utf-8");
26
+ const parsed = JSON.parse(content);
27
+ return parsed;
28
+ } catch (err) {
29
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
30
+ return {};
31
+ }
32
+ try {
33
+ fs.copyFileSync(settingsPath, `${settingsPath}.bak`);
34
+ console.warn(`Warning: Invalid settings.json backed up to ${settingsPath}.bak`);
35
+ } catch {
36
+ }
37
+ return {};
38
+ }
39
+ }
40
+ function writeSettings(settings, configDir) {
41
+ const settingsPath = getSettingsPath(configDir);
42
+ const dir = path.dirname(settingsPath);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
45
+ `);
46
+ }
47
+ function backupSettings(configDir) {
48
+ const settingsPath = getSettingsPath(configDir);
49
+ const backupPath = settingsPath.replace(/\.json$/, ".pre-dashboard.json");
50
+ try {
51
+ fs.copyFileSync(settingsPath, backupPath);
52
+ } catch {
53
+ }
54
+ }
55
+ function removeHooksByMarkers(settings, markers) {
56
+ if (!settings.hooks) return;
57
+ for (const event of HOOK_EVENTS) {
58
+ const groups = settings.hooks[event];
59
+ if (!groups) continue;
60
+ const filtered = [];
61
+ for (const group of groups) {
62
+ const kept = group.hooks.filter(
63
+ (h) => !h.statusMessage || !markers.includes(h.statusMessage)
64
+ );
65
+ if (kept.length > 0) {
66
+ filtered.push({ ...group, hooks: kept });
22
67
  }
23
- const status = EVENT_TO_STATUS[hook_event_name];
24
- if (!status) {
25
- const existing2 = sessions.get(session_id);
26
- if (existing2) return existing2;
27
- const session2 = {
28
- sessionId: session_id,
29
- status: "waiting",
30
- cwd: cwd ?? "",
31
- lastEvent: hook_event_name,
32
- updatedAt: Date.now(),
33
- startedAt: Date.now()
34
- };
35
- sessions.set(session_id, session2);
36
- return session2;
68
+ }
69
+ if (filtered.length > 0) {
70
+ settings.hooks[event] = filtered;
71
+ } else {
72
+ delete settings.hooks[event];
73
+ }
74
+ }
75
+ if (Object.keys(settings.hooks).length === 0) {
76
+ delete settings.hooks;
77
+ }
78
+ }
79
+ function installHooks(port, configDir) {
80
+ const settings = readSettings(configDir);
81
+ backupSettings(configDir);
82
+ removeHooksByMarkers(settings, [MARKER_QUICK, MARKER_LEGACY]);
83
+ if (!settings.hooks) {
84
+ settings.hooks = {};
85
+ }
86
+ for (const event of HOOK_EVENTS) {
87
+ if (!settings.hooks[event]) {
88
+ settings.hooks[event] = [];
89
+ }
90
+ const command = process.platform === "win32" ? `powershell -NoProfile -Command "$input | Invoke-WebRequest -Uri http://localhost:${port}/api/hook -Method POST -ContentType 'application/json' -ErrorAction SilentlyContinue | Out-Null"` : `curl -s -X POST -H "Content-Type: application/json" -d @- http://localhost:${port}/api/hook > /dev/null 2>&1`;
91
+ settings.hooks[event].push({
92
+ hooks: [
93
+ {
94
+ type: "command",
95
+ command,
96
+ async: true,
97
+ statusMessage: MARKER_QUICK
98
+ }
99
+ ]
100
+ });
101
+ }
102
+ writeSettings(settings, configDir);
103
+ }
104
+ function installHooksWithCommand(command, configDir) {
105
+ const settings = readSettings(configDir);
106
+ backupSettings(configDir);
107
+ removeHooksByMarkers(settings, [MARKER_INSTALL, MARKER_LEGACY]);
108
+ if (!settings.hooks) {
109
+ settings.hooks = {};
110
+ }
111
+ for (const event of HOOK_EVENTS) {
112
+ if (!settings.hooks[event]) {
113
+ settings.hooks[event] = [];
114
+ }
115
+ settings.hooks[event].push({
116
+ hooks: [
117
+ {
118
+ type: "command",
119
+ command,
120
+ async: true,
121
+ statusMessage: MARKER_INSTALL
122
+ }
123
+ ]
124
+ });
125
+ }
126
+ writeSettings(settings, configDir);
127
+ }
128
+ function removeHooks(configDir, mode) {
129
+ const settingsPath = getSettingsPath(configDir);
130
+ try {
131
+ fs.accessSync(settingsPath);
132
+ } catch {
133
+ return;
134
+ }
135
+ const settings = readSettings(configDir);
136
+ let markers;
137
+ if (mode === "quick") {
138
+ markers = [MARKER_QUICK, MARKER_LEGACY];
139
+ } else if (mode === "install") {
140
+ markers = [MARKER_INSTALL, MARKER_LEGACY];
141
+ } else {
142
+ markers = [MARKER_QUICK, MARKER_INSTALL, MARKER_LEGACY];
143
+ }
144
+ removeHooksByMarkers(settings, markers);
145
+ writeSettings(settings, configDir);
146
+ }
147
+
148
+ // src/installer.ts
149
+ import { execSync } from "child_process";
150
+ import * as fs2 from "fs";
151
+ import * as os2 from "os";
152
+ import * as path2 from "path";
153
+ var DASHBOARD_DIR = path2.join(os2.homedir(), ".claude", "dashboard");
154
+ var BIN_DIR = path2.join(os2.homedir(), ".claude", "bin");
155
+ var CONFIG_PATH = path2.join(DASHBOARD_DIR, "config.json");
156
+ var SERVER_DIR = path2.join(DASHBOARD_DIR, "server");
157
+ var HOOK_SCRIPT_PATH = path2.join(BIN_DIR, "claude-dashboard-hook.mjs");
158
+ var LOCK_PATH = path2.join(DASHBOARD_DIR, "dashboard.lock");
159
+ var PROTOCOL_SCHEME = "claude-dashboard";
160
+ function getThisBundle() {
161
+ return process.argv[1] ?? __filename;
162
+ }
163
+ function writeHookScript(_port, _serverEntry) {
164
+ const script = `#!/usr/bin/env node
165
+ // Auto-generated by claude-code-dashboard install
166
+ import { readFileSync, existsSync, unlinkSync } from 'node:fs';
167
+ import { spawn, exec } from 'node:child_process';
168
+ import { request } from 'node:http';
169
+ import { openSync } from 'node:fs';
170
+
171
+ const CONFIG_PATH = ${JSON.stringify(CONFIG_PATH)};
172
+ const LOCK_PATH = ${JSON.stringify(LOCK_PATH)};
173
+
174
+ let config;
175
+ try {
176
+ config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
177
+ } catch {
178
+ process.exit(0); // Config missing \u2014 probably uninstalled
179
+ }
180
+
181
+ const { port, serverEntry } = config;
182
+ const stdin = readFileSync(0, 'utf-8');
183
+
184
+ function postHook(data) {
185
+ return new Promise((resolve, reject) => {
186
+ const req = request({
187
+ hostname: '127.0.0.1',
188
+ port,
189
+ path: '/api/hook',
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ timeout: 5000,
193
+ }, (res) => {
194
+ res.resume();
195
+ resolve(res.statusCode);
196
+ });
197
+ req.on('error', reject);
198
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
199
+ req.write(data);
200
+ req.end();
201
+ });
202
+ }
203
+
204
+ function httpPing() {
205
+ return new Promise((resolve, reject) => {
206
+ const req = request({
207
+ hostname: '127.0.0.1',
208
+ port,
209
+ path: '/api/sessions',
210
+ method: 'GET',
211
+ timeout: 2000,
212
+ }, (res) => {
213
+ res.resume();
214
+ resolve(res.statusCode);
215
+ });
216
+ req.on('error', reject);
217
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
218
+ req.end();
219
+ });
220
+ }
221
+
222
+ async function isServerRunning() {
223
+ try {
224
+ if (!existsSync(LOCK_PATH)) return false;
225
+ const pid = parseInt(readFileSync(LOCK_PATH, 'utf-8').trim().split(':')[0], 10);
226
+ if (isNaN(pid)) return false;
227
+ try {
228
+ process.kill(pid, 0); // Check if process exists
229
+ } catch (e) {
230
+ // On Windows, EPERM can be thrown for processes we can't signal but that exist.
231
+ // Only treat ESRCH (no such process) as definitely dead.
232
+ if (e && e.code === 'ESRCH') {
233
+ try { unlinkSync(LOCK_PATH); } catch { /* ignore */ }
234
+ return false;
37
235
  }
38
- const now = Date.now();
39
- const existing = sessions.get(session_id);
40
- if (existing) {
41
- existing.status = status;
42
- existing.lastEvent = hook_event_name;
43
- existing.updatedAt = now;
44
- if (cwd) existing.cwd = cwd;
45
- return existing;
236
+ // EPERM \u2014 process might exist, fall through to HTTP check
237
+ }
238
+ // PID check passed or was ambiguous \u2014 verify with HTTP ping
239
+ try {
240
+ await httpPing();
241
+ return true;
242
+ } catch {
243
+ // HTTP ping failed \u2014 server is not actually running, clean stale lock
244
+ try { unlinkSync(LOCK_PATH); } catch { /* ignore */ }
245
+ return false;
246
+ }
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ function openBrowser(url) {
253
+ const platform = process.platform;
254
+ const cmd = platform === 'win32'
255
+ ? \`start "" "\${url}"\`
256
+ : platform === 'darwin'
257
+ ? \`open "\${url}"\`
258
+ : \`xdg-open "\${url}"\`;
259
+ exec(cmd, () => {});
260
+ }
261
+
262
+ function startServer() {
263
+ const logPath = ${JSON.stringify(path2.join(DASHBOARD_DIR, "server.log"))};
264
+ const logFd = openSync(logPath, 'a');
265
+ const child = spawn(process.execPath, [serverEntry, '--no-hooks', '--no-open', '--port', String(port)], {
266
+ detached: true,
267
+ stdio: ['ignore', logFd, logFd],
268
+ env: { ...process.env },
269
+ });
270
+ child.unref();
271
+ }
272
+
273
+ async function waitForServer(maxWaitMs = 10000) {
274
+ const start = Date.now();
275
+ let delay = 100;
276
+ while (Date.now() - start < maxWaitMs) {
277
+ try {
278
+ await postHook('{"session_id":"ping","hook_event_name":"Ping"}');
279
+ return true;
280
+ } catch {
281
+ await new Promise(r => setTimeout(r, delay));
282
+ delay = Math.min(delay * 1.5, 1000);
283
+ }
284
+ }
285
+ return false;
286
+ }
287
+
288
+ async function main() {
289
+ try {
290
+ await postHook(stdin);
291
+ } catch {
292
+ // Server not running \u2014 try to start it
293
+ const running = await isServerRunning();
294
+ if (!running) {
295
+ startServer();
296
+ }
297
+ const ready = await waitForServer();
298
+ if (ready) {
299
+ if (!running) {
300
+ // We just auto-started the server \u2014 open the browser
301
+ openBrowser(\`http://localhost:\${port}\`);
46
302
  }
47
- const session = {
48
- sessionId: session_id,
49
- status,
50
- cwd: cwd ?? "",
51
- lastEvent: hook_event_name,
52
- updatedAt: now,
53
- startedAt: now
54
- };
55
- sessions.set(session_id, session);
56
- return session;
57
- },
58
- getAllSessions() {
59
- return Array.from(sessions.values());
60
- },
61
- getSession(sessionId) {
62
- return sessions.get(sessionId);
63
- },
64
- removeSession(sessionId) {
65
- return sessions.delete(sessionId);
66
- },
67
- cleanIdleSessions(maxIdleMs) {
68
- const now = Date.now();
69
- const removed = [];
70
- for (const [id, session] of sessions) {
71
- if (now - session.updatedAt > maxIdleMs) {
72
- sessions.delete(id);
73
- removed.push(id);
303
+ try { await postHook(stdin); } catch { /* give up */ }
304
+ }
305
+ }
306
+ }
307
+
308
+ main().catch(() => {});
309
+ `;
310
+ fs2.mkdirSync(path2.dirname(HOOK_SCRIPT_PATH), { recursive: true });
311
+ fs2.writeFileSync(HOOK_SCRIPT_PATH, script);
312
+ }
313
+ function getShortcutPath() {
314
+ const platform = process.platform;
315
+ if (platform === "win32") {
316
+ const appData = process.env.APPDATA;
317
+ if (!appData) return null;
318
+ return path2.join(
319
+ appData,
320
+ "Microsoft",
321
+ "Windows",
322
+ "Start Menu",
323
+ "Programs",
324
+ "Claude Dashboard.url"
325
+ );
326
+ }
327
+ if (platform === "darwin") {
328
+ return path2.join(os2.homedir(), "Applications", "Claude Dashboard.webloc");
329
+ }
330
+ return path2.join(os2.homedir(), ".local", "share", "applications", "claude-dashboard.desktop");
331
+ }
332
+ function createShortcuts(port) {
333
+ const shortcutPath = getShortcutPath();
334
+ if (!shortcutPath) return;
335
+ fs2.mkdirSync(path2.dirname(shortcutPath), { recursive: true });
336
+ const platform = process.platform;
337
+ const url = `http://localhost:${port}`;
338
+ if (platform === "win32") {
339
+ const content = `[InternetShortcut]
340
+ URL=${url}
341
+ IconIndex=0
342
+ `;
343
+ fs2.writeFileSync(shortcutPath, content);
344
+ } else if (platform === "darwin") {
345
+ const content = [
346
+ '<?xml version="1.0" encoding="UTF-8"?>',
347
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
348
+ '<plist version="1.0">',
349
+ "<dict>",
350
+ " <key>URL</key>",
351
+ ` <string>${url}</string>`,
352
+ "</dict>",
353
+ "</plist>",
354
+ ""
355
+ ].join("\n");
356
+ fs2.writeFileSync(shortcutPath, content);
357
+ } else {
358
+ const content = [
359
+ "[Desktop Entry]",
360
+ "Version=1.1",
361
+ "Type=Link",
362
+ "Name=Claude Dashboard",
363
+ `URL=${url}`,
364
+ "Icon=text-html",
365
+ ""
366
+ ].join("\n");
367
+ fs2.writeFileSync(shortcutPath, content);
368
+ }
369
+ console.log(` Shortcut: ${shortcutPath}`);
370
+ }
371
+ function removeShortcuts() {
372
+ const shortcutPath = getShortcutPath();
373
+ if (!shortcutPath) return;
374
+ try {
375
+ fs2.unlinkSync(shortcutPath);
376
+ } catch {
377
+ }
378
+ }
379
+ function getProtocolDesktopPath() {
380
+ return path2.join(
381
+ os2.homedir(),
382
+ ".local",
383
+ "share",
384
+ "applications",
385
+ "claude-dashboard-handler.desktop"
386
+ );
387
+ }
388
+ function registerProtocol(port) {
389
+ const platform = process.platform;
390
+ const url = `http://localhost:${port}`;
391
+ try {
392
+ if (platform === "win32") {
393
+ const regBase = `HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}`;
394
+ execSync(`reg add "${regBase}" /ve /d "URL:Claude Dashboard" /f`, { stdio: "ignore" });
395
+ execSync(`reg add "${regBase}" /v "URL Protocol" /d "" /f`, { stdio: "ignore" });
396
+ execSync(`reg add "${regBase}\\shell\\open\\command" /ve /d "cmd /c start ${url}" /f`, {
397
+ stdio: "ignore"
398
+ });
399
+ } else if (platform === "darwin") {
400
+ const appDir = path2.join(
401
+ os2.homedir(),
402
+ "Applications",
403
+ "Claude Dashboard Protocol.app",
404
+ "Contents"
405
+ );
406
+ const macOSDir = path2.join(appDir, "MacOS");
407
+ fs2.mkdirSync(macOSDir, { recursive: true });
408
+ const infoPlist = [
409
+ '<?xml version="1.0" encoding="UTF-8"?>',
410
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
411
+ '<plist version="1.0">',
412
+ "<dict>",
413
+ " <key>CFBundleIdentifier</key>",
414
+ " <string>com.claude-dashboard.protocol</string>",
415
+ " <key>CFBundleName</key>",
416
+ " <string>Claude Dashboard Protocol</string>",
417
+ " <key>CFBundleExecutable</key>",
418
+ " <string>open-dashboard</string>",
419
+ " <key>CFBundleURLTypes</key>",
420
+ " <array>",
421
+ " <dict>",
422
+ " <key>CFBundleURLName</key>",
423
+ " <string>Claude Dashboard</string>",
424
+ " <key>CFBundleURLSchemes</key>",
425
+ " <array>",
426
+ ` <string>${PROTOCOL_SCHEME}</string>`,
427
+ " </array>",
428
+ " </dict>",
429
+ " </array>",
430
+ "</dict>",
431
+ "</plist>",
432
+ ""
433
+ ].join("\n");
434
+ fs2.writeFileSync(path2.join(appDir, "Info.plist"), infoPlist);
435
+ const launchScript = `#!/bin/sh
436
+ open "${url}"
437
+ `;
438
+ const scriptPath = path2.join(macOSDir, "open-dashboard");
439
+ fs2.writeFileSync(scriptPath, launchScript, { mode: 493 });
440
+ try {
441
+ execSync(
442
+ `/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -R "${path2.dirname(appDir)}"`,
443
+ { stdio: "ignore" }
444
+ );
445
+ } catch {
446
+ }
447
+ } else {
448
+ const desktopPath = getProtocolDesktopPath();
449
+ fs2.mkdirSync(path2.dirname(desktopPath), { recursive: true });
450
+ const content = [
451
+ "[Desktop Entry]",
452
+ "Version=1.1",
453
+ "Type=Application",
454
+ "Name=Claude Dashboard Protocol Handler",
455
+ `Exec=xdg-open ${url}`,
456
+ `MimeType=x-scheme-handler/${PROTOCOL_SCHEME};`,
457
+ "NoDisplay=true",
458
+ ""
459
+ ].join("\n");
460
+ fs2.writeFileSync(desktopPath, content);
461
+ try {
462
+ execSync(
463
+ `xdg-mime default claude-dashboard-handler.desktop x-scheme-handler/${PROTOCOL_SCHEME}`,
464
+ { stdio: "ignore" }
465
+ );
466
+ } catch {
467
+ }
468
+ }
469
+ } catch {
470
+ }
471
+ console.log(` Protocol: ${PROTOCOL_SCHEME}:// registered`);
472
+ }
473
+ function unregisterProtocol() {
474
+ const platform = process.platform;
475
+ try {
476
+ if (platform === "win32") {
477
+ execSync(`reg delete "HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}" /f`, { stdio: "ignore" });
478
+ } else if (platform === "darwin") {
479
+ const appDir = path2.join(os2.homedir(), "Applications", "Claude Dashboard Protocol.app");
480
+ fs2.rmSync(appDir, { recursive: true, force: true });
481
+ } else {
482
+ const desktopPath = getProtocolDesktopPath();
483
+ try {
484
+ fs2.unlinkSync(desktopPath);
485
+ } catch {
486
+ }
487
+ try {
488
+ execSync("update-desktop-database ~/.local/share/applications/", {
489
+ stdio: "ignore"
490
+ });
491
+ } catch {
492
+ }
493
+ }
494
+ } catch {
495
+ }
496
+ }
497
+ function install(port) {
498
+ fs2.mkdirSync(SERVER_DIR, { recursive: true });
499
+ fs2.mkdirSync(BIN_DIR, { recursive: true });
500
+ const bundleSrc = getThisBundle();
501
+ const bundleDest = path2.join(SERVER_DIR, "bin.js");
502
+ fs2.copyFileSync(bundleSrc, bundleDest);
503
+ fs2.writeFileSync(CONFIG_PATH, `${JSON.stringify({ port, serverEntry: bundleDest }, null, 2)}
504
+ `);
505
+ writeHookScript(port, bundleDest);
506
+ const command = `node ${JSON.stringify(HOOK_SCRIPT_PATH)}`;
507
+ installHooksWithCommand(command);
508
+ console.log("Dashboard installed successfully!");
509
+ console.log(` Server bundle: ${bundleDest}`);
510
+ console.log(` Hook script: ${HOOK_SCRIPT_PATH}`);
511
+ console.log(` Config: ${CONFIG_PATH}`);
512
+ console.log(` Port: ${port}`);
513
+ createShortcuts(port);
514
+ registerProtocol(port);
515
+ console.log("");
516
+ console.log("The dashboard will auto-launch when a Claude Code session starts.");
517
+ console.log(
518
+ `You can also open it via the shortcut or by typing "${PROTOCOL_SCHEME}://" in your browser.`
519
+ );
520
+ console.log("To uninstall: npx @kosinal/claude-code-dashboard uninstall");
521
+ }
522
+ function uninstall() {
523
+ removeHooks();
524
+ removeShortcuts();
525
+ unregisterProtocol();
526
+ try {
527
+ fs2.rmSync(DASHBOARD_DIR, { recursive: true, force: true });
528
+ } catch {
529
+ }
530
+ try {
531
+ fs2.unlinkSync(HOOK_SCRIPT_PATH);
532
+ } catch {
533
+ }
534
+ console.log("Dashboard uninstalled successfully.");
535
+ console.log(" Shortcut removed, protocol handler unregistered.");
536
+ }
537
+ function writeLockFile(port) {
538
+ fs2.mkdirSync(DASHBOARD_DIR, { recursive: true });
539
+ fs2.writeFileSync(LOCK_PATH, `${process.pid}:${port}`);
540
+ }
541
+ function readLockFile() {
542
+ try {
543
+ const content = fs2.readFileSync(LOCK_PATH, "utf-8").trim();
544
+ const parts = content.split(":");
545
+ const pid = parseInt(parts[0], 10);
546
+ const port = parts.length > 1 ? parseInt(parts[1], 10) : NaN;
547
+ if (Number.isNaN(pid) || Number.isNaN(port)) return null;
548
+ try {
549
+ process.kill(pid, 0);
550
+ } catch (err) {
551
+ const code = err.code;
552
+ if (code === "ESRCH") {
553
+ try {
554
+ fs2.unlinkSync(LOCK_PATH);
555
+ } catch {
74
556
  }
557
+ return null;
75
558
  }
76
- return removed;
77
559
  }
78
- };
560
+ return { pid, port };
561
+ } catch {
562
+ try {
563
+ fs2.unlinkSync(LOCK_PATH);
564
+ } catch {
565
+ }
566
+ return null;
567
+ }
568
+ }
569
+ function removeLockFile() {
570
+ try {
571
+ fs2.unlinkSync(LOCK_PATH);
572
+ } catch {
573
+ }
79
574
  }
80
575
 
81
576
  // src/server.ts
@@ -462,686 +957,451 @@ function getDashboardHtml() {
462
957
  </div>
463
958
  </header>
464
959
  <div id="overlayContainer"></div>
465
- <main id="app">
466
- <div class="empty-state">
467
- <h2>No sessions yet</h2>
468
- <p>Start a Claude Code session and it will appear here.<br>
469
- Sessions already running when the dashboard started won't be tracked.</p>
470
- </div>
471
- </main>
472
- <footer>
473
- <button id="btnRestart" disabled>Restart</button>
474
- <button id="btnStop" class="btn-danger" disabled>Stop</button>
475
- </footer>
476
- <script>
477
- (function() {
478
- var app = document.getElementById('app');
479
- var connDot = document.getElementById('connDot');
480
- var connLabel = document.getElementById('connLabel');
481
- var overlayContainer = document.getElementById('overlayContainer');
482
- var btnStop = document.getElementById('btnStop');
483
- var btnRestart = document.getElementById('btnRestart');
484
- var notifToggle = document.getElementById('notifToggle');
485
- var notificationsEnabled = localStorage.getItem('notificationsEnabled') !== 'false';
486
- var sessions = [];
487
- var previousStatuses = {};
488
- var initialized = false;
489
- var es = null;
490
-
491
- notifToggle.checked = notificationsEnabled;
492
- notifToggle.addEventListener('change', function() {
493
- notificationsEnabled = notifToggle.checked;
494
- localStorage.setItem('notificationsEnabled', notificationsEnabled ? 'true' : 'false');
495
- if (notificationsEnabled && 'Notification' in window && Notification.permission === 'default') {
496
- Notification.requestPermission();
497
- }
498
- });
499
-
500
- if (notificationsEnabled && 'Notification' in window && Notification.permission === 'default') {
501
- Notification.requestPermission();
502
- }
503
-
504
- var STATUS_LABELS = { running: 'Running', waiting: 'Waiting for input', done: 'Done' };
505
- var STATUS_ORDER = { running: 0, waiting: 1, done: 2 };
506
-
507
- function timeAgo(ts) {
508
- var diff = Math.floor((Date.now() - ts) / 1000);
509
- if (diff < 5) return 'just now';
510
- if (diff < 60) return diff + 's ago';
511
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
512
- if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
513
- return Math.floor(diff / 86400) + 'd ago';
514
- }
515
-
516
- function folderName(cwd) {
517
- if (!cwd) return 'Unknown';
518
- var parts = cwd.replace(/\\\\/g, '/').split('/');
519
- return parts[parts.length - 1] || parts[parts.length - 2] || cwd;
520
- }
521
-
522
- function shortId(id) {
523
- if (!id) return '';
524
- return id.length > 12 ? id.slice(0, 8) + '...' : id;
525
- }
526
-
527
- function render() {
528
- if (sessions.length === 0) {
529
- app.innerHTML = '<div class="empty-state"><h2>No sessions yet</h2>' +
530
- '<p>Start a Claude Code session and it will appear here.<br>' +
531
- 'Sessions already running when the dashboard started won\\'t be tracked.</p></div>';
532
- return;
533
- }
534
-
535
- var sorted = sessions.slice().sort(function(a, b) {
536
- var od = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
537
- if (od !== 0) return od;
538
- return b.updatedAt - a.updatedAt;
539
- });
540
-
541
- app.innerHTML = '<div class="sessions-grid">' + sorted.map(function(s) {
542
- return '<div class="session-card status-' + s.status + '">' +
543
- '<div class="card-header">' +
544
- '<span class="project-name" title="' + esc(s.cwd) + '">' + esc(folderName(s.cwd)) + '</span>' +
545
- '<span class="status-badge"><span class="status-dot"></span>' + STATUS_LABELS[s.status] + '</span>' +
546
- '</div>' +
547
- '<div class="card-details">' +
548
- '<div class="detail-row"><span class="detail-label">Session</span>' +
549
- '<span class="detail-value session-id-short" title="' + esc(s.sessionId) + '">' + esc(shortId(s.sessionId)) + '</span></div>' +
550
- '<div class="detail-row"><span class="detail-label">Path</span>' +
551
- '<span class="detail-value" title="' + esc(s.cwd) + '">' + esc(s.cwd) + '</span></div>' +
552
- '<div class="detail-row"><span class="detail-label">Event</span>' +
553
- '<span class="detail-value">' + esc(s.lastEvent) + ' &middot; ' + timeAgo(s.updatedAt) + '</span></div>' +
554
- '</div>' +
555
- '</div>';
556
- }).join('') + '</div>';
557
- }
558
-
559
- function esc(str) {
560
- if (!str) return '';
561
- return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
562
- }
563
-
564
- function checkAndNotify(newSessions) {
565
- if (!initialized) {
566
- initialized = true;
567
- newSessions.forEach(function(s) { previousStatuses[s.sessionId] = s.status; });
568
- return;
569
- }
570
- if (notificationsEnabled && 'Notification' in window && Notification.permission === 'granted') {
571
- newSessions.forEach(function(s) {
572
- if (s.status === 'waiting' && previousStatuses[s.sessionId] !== 'waiting') {
573
- new Notification('Claude Code - Waiting for input', {
574
- body: folderName(s.cwd),
575
- tag: 'claude-waiting-' + s.sessionId
576
- });
577
- }
578
- });
579
- }
580
- previousStatuses = {};
581
- newSessions.forEach(function(s) { previousStatuses[s.sessionId] = s.status; });
582
- }
583
-
584
- function setButtonsEnabled(enabled) {
585
- btnStop.disabled = !enabled;
586
- btnRestart.disabled = !enabled;
587
- }
588
-
589
- function clearOverlay() {
590
- overlayContainer.innerHTML = '';
591
- }
592
-
593
- function showOverlay(title, message) {
594
- overlayContainer.innerHTML =
595
- '<div class="overlay"><div class="overlay-card">' +
596
- '<h2>' + esc(title) + '</h2>' +
597
- '<p>' + esc(message) + '</p>' +
598
- '</div></div>';
599
- }
600
-
601
- function showConfirm(title, message, label, isDanger, onConfirm) {
602
- var btnClass = isDanger ? 'btn-confirm-danger' : 'btn-confirm';
603
- overlayContainer.innerHTML =
604
- '<div class="overlay"><div class="overlay-card">' +
605
- '<h2>' + esc(title) + '</h2>' +
606
- '<p>' + esc(message) + '</p>' +
607
- '<div class="overlay-actions">' +
608
- '<button class="btn-cancel" id="overlayCancel">Cancel</button>' +
609
- '<button class="' + btnClass + '" id="overlayConfirm">' + esc(label) + '</button>' +
610
- '</div>' +
611
- '</div></div>';
612
- document.getElementById('overlayCancel').onclick = clearOverlay;
613
- document.getElementById('overlayConfirm').onclick = function() {
614
- onConfirm();
615
- };
616
- }
617
-
618
- function attemptReconnect() {
619
- var attempts = 0;
620
- var maxAttempts = 30;
621
- var timer = setInterval(function() {
622
- attempts++;
623
- if (attempts > maxAttempts) {
624
- clearInterval(timer);
625
- showOverlay('Connection Lost', 'Could not reconnect to the dashboard server.');
626
- return;
627
- }
628
- var req = new XMLHttpRequest();
629
- req.open('GET', '/api/sessions', true);
630
- req.timeout = 2000;
631
- req.onload = function() {
632
- if (req.status === 200) {
633
- clearInterval(timer);
634
- clearOverlay();
635
- connect();
636
- }
637
- };
638
- req.onerror = function() {};
639
- req.ontimeout = function() {};
640
- req.send();
641
- }, 1000);
642
- }
643
-
644
- btnStop.onclick = function() {
645
- showConfirm('Stop Dashboard', 'Are you sure you want to stop the dashboard server?', 'Stop', true, function() {
646
- showOverlay('Stopping...', 'Shutting down the dashboard server.');
647
- setButtonsEnabled(false);
648
- var req = new XMLHttpRequest();
649
- req.open('POST', '/api/shutdown', true);
650
- req.onload = function() {
651
- showOverlay('Server Stopped', 'The dashboard server has been shut down.');
652
- };
653
- req.onerror = function() {
654
- showOverlay('Server Stopped', 'The dashboard server has been shut down.');
655
- };
656
- req.send();
657
- });
658
- };
659
-
660
- btnRestart.onclick = function() {
661
- showConfirm('Restart Dashboard', 'Are you sure you want to restart the dashboard server?', 'Restart', false, function() {
662
- showOverlay('Restarting...', 'The dashboard server is restarting. Reconnecting automatically...');
663
- setButtonsEnabled(false);
664
- var req = new XMLHttpRequest();
665
- req.open('POST', '/api/restart', true);
666
- req.onload = function() {};
667
- req.onerror = function() {};
668
- req.send();
669
- });
670
- };
671
-
672
- function connect() {
673
- if (es) {
674
- es.close();
675
- es = null;
676
- }
677
-
678
- es = new EventSource('/api/events');
679
-
680
- es.addEventListener('init', function(e) {
681
- sessions = JSON.parse(e.data);
682
- checkAndNotify(sessions);
683
- render();
684
- });
685
-
686
- es.addEventListener('update', function(e) {
687
- sessions = JSON.parse(e.data);
688
- checkAndNotify(sessions);
689
- render();
690
- });
691
-
692
- es.addEventListener('shutdown', function() {
693
- if (es) { es.close(); es = null; }
694
- setButtonsEnabled(false);
695
- showOverlay('Server Stopped', 'The dashboard server has been shut down.');
696
- });
697
-
698
- es.addEventListener('restart', function() {
699
- if (es) { es.close(); es = null; }
700
- setButtonsEnabled(false);
701
- showOverlay('Restarting...', 'The dashboard server is restarting. Reconnecting automatically...');
702
- attemptReconnect();
703
- });
960
+ <main id="app">
961
+ <div class="empty-state">
962
+ <h2>No sessions yet</h2>
963
+ <p>Start a Claude Code session and it will appear here.<br>
964
+ Sessions already running when the dashboard started won't be tracked.</p>
965
+ </div>
966
+ </main>
967
+ <footer>
968
+ <button id="btnRestart" disabled>Restart</button>
969
+ <button id="btnStop" class="btn-danger" disabled>Stop</button>
970
+ </footer>
971
+ <script>
972
+ (function() {
973
+ var app = document.getElementById('app');
974
+ var connDot = document.getElementById('connDot');
975
+ var connLabel = document.getElementById('connLabel');
976
+ var overlayContainer = document.getElementById('overlayContainer');
977
+ var btnStop = document.getElementById('btnStop');
978
+ var btnRestart = document.getElementById('btnRestart');
979
+ var notifToggle = document.getElementById('notifToggle');
980
+ var notificationsEnabled = localStorage.getItem('notificationsEnabled') !== 'false';
981
+ var sessions = [];
982
+ var previousStatuses = {};
983
+ var initialized = false;
984
+ var es = null;
704
985
 
705
- es.onopen = function() {
706
- connDot.classList.add('connected');
707
- connLabel.textContent = 'Connected';
708
- setButtonsEnabled(true);
709
- };
986
+ notifToggle.checked = notificationsEnabled;
987
+ notifToggle.addEventListener('change', function() {
988
+ notificationsEnabled = notifToggle.checked;
989
+ localStorage.setItem('notificationsEnabled', notificationsEnabled ? 'true' : 'false');
990
+ if (notificationsEnabled && 'Notification' in window && Notification.permission === 'default') {
991
+ Notification.requestPermission();
992
+ }
993
+ });
710
994
 
711
- es.onerror = function() {
712
- connDot.classList.remove('connected');
713
- connLabel.textContent = 'Disconnected';
714
- setButtonsEnabled(false);
715
- };
995
+ if (notificationsEnabled && 'Notification' in window && Notification.permission === 'default') {
996
+ Notification.requestPermission();
716
997
  }
717
998
 
718
- // Update time-ago values every 10 seconds
719
- setInterval(render, 10000);
720
-
721
- connect();
722
- })();
723
- </script>
724
- </body>
725
- </html>`;
726
- }
999
+ var STATUS_LABELS = { running: 'Running', waiting: 'Waiting for input', done: 'Done' };
1000
+ var STATUS_ORDER = { running: 0, waiting: 1, done: 2 };
727
1001
 
728
- // src/server.ts
729
- function createServer2(options) {
730
- const { store, onShutdown, onRestart } = options;
731
- const idleTimeoutMs = options.idleTimeoutMs ?? 5 * 60 * 1e3;
732
- const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
733
- const sseClients = /* @__PURE__ */ new Set();
734
- function broadcastEvent(event, data) {
735
- for (const res of sseClients) {
736
- res.write(`event: ${event}
737
- data: ${data}
1002
+ function timeAgo(ts) {
1003
+ var diff = Math.floor((Date.now() - ts) / 1000);
1004
+ if (diff < 5) return 'just now';
1005
+ if (diff < 60) return diff + 's ago';
1006
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
1007
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
1008
+ return Math.floor(diff / 86400) + 'd ago';
1009
+ }
738
1010
 
739
- `);
740
- }
1011
+ function folderName(cwd) {
1012
+ if (!cwd) return 'Unknown';
1013
+ var parts = cwd.replace(/\\\\/g, '/').split('/');
1014
+ return parts[parts.length - 1] || parts[parts.length - 2] || cwd;
741
1015
  }
742
- function broadcast() {
743
- broadcastEvent("update", JSON.stringify(store.getAllSessions()));
1016
+
1017
+ function shortId(id) {
1018
+ if (!id) return '';
1019
+ return id.length > 12 ? id.slice(0, 8) + '...' : id;
744
1020
  }
745
- const server = http.createServer((req, res) => {
746
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
747
- const pathname = url.pathname;
748
- if (req.method === "GET" && pathname === "/") {
749
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
750
- res.end(getDashboardHtml());
751
- return;
752
- }
753
- if (req.method === "POST" && pathname === "/api/hook") {
754
- let body = "";
755
- req.on("data", (chunk) => {
756
- body += chunk.toString();
757
- });
758
- req.on("end", () => {
759
- try {
760
- const payload = JSON.parse(body);
761
- if (!payload.session_id || !payload.hook_event_name) {
762
- res.writeHead(400, { "Content-Type": "application/json" });
763
- res.end(JSON.stringify({ error: "Missing session_id or hook_event_name" }));
764
- return;
765
- }
766
- store.handleEvent(payload);
767
- broadcast();
768
- res.writeHead(200, { "Content-Type": "application/json" });
769
- res.end(JSON.stringify({ ok: true }));
770
- } catch {
771
- res.writeHead(400, { "Content-Type": "application/json" });
772
- res.end(JSON.stringify({ error: "Invalid JSON" }));
773
- }
774
- });
775
- return;
776
- }
777
- if (req.method === "GET" && pathname === "/api/events") {
778
- res.writeHead(200, {
779
- "Content-Type": "text/event-stream",
780
- "Cache-Control": "no-cache",
781
- Connection: "keep-alive"
782
- });
783
- const initData = JSON.stringify(store.getAllSessions());
784
- res.write(`event: init
785
- data: ${initData}
786
1021
 
787
- `);
788
- sseClients.add(res);
789
- req.on("close", () => {
790
- sseClients.delete(res);
791
- });
792
- return;
793
- }
794
- if (req.method === "GET" && pathname === "/api/sessions") {
795
- res.writeHead(200, { "Content-Type": "application/json" });
796
- res.end(JSON.stringify(store.getAllSessions()));
797
- return;
798
- }
799
- if (req.method === "POST" && pathname === "/api/shutdown") {
800
- broadcastEvent("shutdown", JSON.stringify({ ok: true }));
801
- res.writeHead(200, { "Content-Type": "application/json" });
802
- res.end(JSON.stringify({ ok: true }));
803
- if (onShutdown) setImmediate(() => onShutdown());
804
- return;
805
- }
806
- if (req.method === "POST" && pathname === "/api/restart") {
807
- broadcastEvent("restart", JSON.stringify({ ok: true }));
808
- res.writeHead(200, { "Content-Type": "application/json" });
809
- res.end(JSON.stringify({ ok: true }));
810
- if (onRestart) setImmediate(() => onRestart());
1022
+ function render() {
1023
+ if (sessions.length === 0) {
1024
+ app.innerHTML = '<div class="empty-state"><h2>No sessions yet</h2>' +
1025
+ '<p>Start a Claude Code session and it will appear here.<br>' +
1026
+ 'Sessions already running when the dashboard started won\\'t be tracked.</p></div>';
811
1027
  return;
812
1028
  }
813
- res.writeHead(404, { "Content-Type": "application/json" });
814
- res.end(JSON.stringify({ error: "Not found" }));
815
- });
816
- const cleanupTimer = setInterval(() => {
817
- const removed = store.cleanIdleSessions(idleTimeoutMs);
818
- if (removed.length > 0) broadcast();
819
- }, cleanupIntervalMs);
820
- cleanupTimer.unref();
821
- return {
822
- server,
823
- listen(port, callback) {
824
- server.listen(port, "127.0.0.1", callback);
825
- },
826
- close() {
827
- clearInterval(cleanupTimer);
828
- for (const res of sseClients) {
829
- res.end();
830
- }
831
- sseClients.clear();
832
- return new Promise((resolve, reject) => {
833
- server.close((err) => err ? reject(err) : resolve());
834
- });
835
- }
836
- };
837
- }
838
1029
 
839
- // src/hooks.ts
840
- import * as fs from "fs";
841
- import * as path from "path";
842
- import * as os from "os";
843
- var MARKER = "__claude_code_dashboard__";
844
- var HOOK_EVENTS = [
845
- "SessionStart",
846
- "UserPromptSubmit",
847
- "Stop",
848
- "SessionEnd"
849
- ];
850
- function getConfigDir(configDir) {
851
- return configDir ?? path.join(os.homedir(), ".claude");
852
- }
853
- function getSettingsPath(configDir) {
854
- return path.join(getConfigDir(configDir), "settings.json");
855
- }
856
- function readSettings(configDir) {
857
- const settingsPath = getSettingsPath(configDir);
858
- try {
859
- const content = fs.readFileSync(settingsPath, "utf-8");
860
- const parsed = JSON.parse(content);
861
- return parsed;
862
- } catch (err) {
863
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
864
- return {};
865
- }
866
- try {
867
- fs.copyFileSync(settingsPath, settingsPath + ".bak");
868
- console.warn(
869
- `Warning: Invalid settings.json backed up to ${settingsPath}.bak`
870
- );
871
- } catch {
872
- }
873
- return {};
874
- }
875
- }
876
- function writeSettings(settings, configDir) {
877
- const settingsPath = getSettingsPath(configDir);
878
- const dir = path.dirname(settingsPath);
879
- fs.mkdirSync(dir, { recursive: true });
880
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
881
- }
882
- function backupSettings(configDir) {
883
- const settingsPath = getSettingsPath(configDir);
884
- const backupPath = settingsPath.replace(/\.json$/, ".pre-dashboard.json");
885
- try {
886
- fs.copyFileSync(settingsPath, backupPath);
887
- } catch {
888
- }
889
- }
890
- function installHooks(port, configDir) {
891
- const settings = readSettings(configDir);
892
- backupSettings(configDir);
893
- removeHooksFromSettings(settings);
894
- if (!settings.hooks) {
895
- settings.hooks = {};
896
- }
897
- for (const event of HOOK_EVENTS) {
898
- if (!settings.hooks[event]) {
899
- settings.hooks[event] = [];
900
- }
901
- const command = process.platform === "win32" ? `powershell -NoProfile -Command "$input | Invoke-WebRequest -Uri http://localhost:${port}/api/hook -Method POST -ContentType 'application/json' -ErrorAction SilentlyContinue | Out-Null"` : `curl -s -X POST -H "Content-Type: application/json" -d @- http://localhost:${port}/api/hook > /dev/null 2>&1`;
902
- settings.hooks[event].push({
903
- hooks: [{
904
- type: "command",
905
- command,
906
- async: true,
907
- statusMessage: MARKER
908
- }]
1030
+ var sorted = sessions.slice().sort(function(a, b) {
1031
+ var od = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
1032
+ if (od !== 0) return od;
1033
+ return b.updatedAt - a.updatedAt;
909
1034
  });
1035
+
1036
+ app.innerHTML = '<div class="sessions-grid">' + sorted.map(function(s) {
1037
+ return '<div class="session-card status-' + s.status + '">' +
1038
+ '<div class="card-header">' +
1039
+ '<span class="project-name" title="' + esc(s.cwd) + '">' + esc(folderName(s.cwd)) + '</span>' +
1040
+ '<span class="status-badge"><span class="status-dot"></span>' + STATUS_LABELS[s.status] + '</span>' +
1041
+ '</div>' +
1042
+ '<div class="card-details">' +
1043
+ '<div class="detail-row"><span class="detail-label">Session</span>' +
1044
+ '<span class="detail-value session-id-short" title="' + esc(s.sessionId) + '">' + esc(shortId(s.sessionId)) + '</span></div>' +
1045
+ '<div class="detail-row"><span class="detail-label">Path</span>' +
1046
+ '<span class="detail-value" title="' + esc(s.cwd) + '">' + esc(s.cwd) + '</span></div>' +
1047
+ '<div class="detail-row"><span class="detail-label">Event</span>' +
1048
+ '<span class="detail-value">' + esc(s.lastEvent) + ' &middot; ' + timeAgo(s.updatedAt) + '</span></div>' +
1049
+ '</div>' +
1050
+ '</div>';
1051
+ }).join('') + '</div>';
910
1052
  }
911
- writeSettings(settings, configDir);
912
- }
913
- function installHooksWithCommand(command, configDir) {
914
- const settings = readSettings(configDir);
915
- backupSettings(configDir);
916
- removeHooksFromSettings(settings);
917
- if (!settings.hooks) {
918
- settings.hooks = {};
919
- }
920
- for (const event of HOOK_EVENTS) {
921
- if (!settings.hooks[event]) {
922
- settings.hooks[event] = [];
923
- }
924
- settings.hooks[event].push({
925
- hooks: [{
926
- type: "command",
927
- command,
928
- async: true,
929
- statusMessage: MARKER
930
- }]
931
- });
1053
+
1054
+ function esc(str) {
1055
+ if (!str) return '';
1056
+ return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
932
1057
  }
933
- writeSettings(settings, configDir);
934
- }
935
- function removeHooksFromSettings(settings) {
936
- if (!settings.hooks) return;
937
- for (const event of HOOK_EVENTS) {
938
- const groups = settings.hooks[event];
939
- if (!groups) continue;
940
- const filtered = [];
941
- for (const group of groups) {
942
- const kept = group.hooks.filter((h) => h.statusMessage !== MARKER);
943
- if (kept.length > 0) {
944
- filtered.push({ ...group, hooks: kept });
945
- }
1058
+
1059
+ function checkAndNotify(newSessions) {
1060
+ if (!initialized) {
1061
+ initialized = true;
1062
+ newSessions.forEach(function(s) { previousStatuses[s.sessionId] = s.status; });
1063
+ return;
946
1064
  }
947
- if (filtered.length > 0) {
948
- settings.hooks[event] = filtered;
949
- } else {
950
- delete settings.hooks[event];
1065
+ if (notificationsEnabled && 'Notification' in window && Notification.permission === 'granted') {
1066
+ newSessions.forEach(function(s) {
1067
+ if (s.status === 'waiting' && previousStatuses[s.sessionId] !== 'waiting') {
1068
+ new Notification('Claude Code - Waiting for input', {
1069
+ body: folderName(s.cwd),
1070
+ tag: 'claude-waiting-' + s.sessionId
1071
+ });
1072
+ }
1073
+ });
951
1074
  }
1075
+ previousStatuses = {};
1076
+ newSessions.forEach(function(s) { previousStatuses[s.sessionId] = s.status; });
952
1077
  }
953
- if (Object.keys(settings.hooks).length === 0) {
954
- delete settings.hooks;
1078
+
1079
+ function setButtonsEnabled(enabled) {
1080
+ btnStop.disabled = !enabled;
1081
+ btnRestart.disabled = !enabled;
955
1082
  }
956
- }
957
- function removeHooks(configDir) {
958
- const settingsPath = getSettingsPath(configDir);
959
- try {
960
- fs.accessSync(settingsPath);
961
- } catch {
962
- return;
1083
+
1084
+ function clearOverlay() {
1085
+ overlayContainer.innerHTML = '';
963
1086
  }
964
- const settings = readSettings(configDir);
965
- removeHooksFromSettings(settings);
966
- writeSettings(settings, configDir);
967
- }
968
1087
 
969
- // src/installer.ts
970
- import * as fs2 from "fs";
971
- import * as path2 from "path";
972
- import * as os2 from "os";
973
- var DASHBOARD_DIR = path2.join(os2.homedir(), ".claude", "dashboard");
974
- var BIN_DIR = path2.join(os2.homedir(), ".claude", "bin");
975
- var CONFIG_PATH = path2.join(DASHBOARD_DIR, "config.json");
976
- var SERVER_DIR = path2.join(DASHBOARD_DIR, "server");
977
- var HOOK_SCRIPT_PATH = path2.join(BIN_DIR, "claude-dashboard-hook.mjs");
978
- var LOCK_PATH = path2.join(DASHBOARD_DIR, "dashboard.lock");
979
- function getThisBundle() {
980
- return process.argv[1] ?? __filename;
981
- }
982
- function writeHookScript(port, serverEntry) {
983
- const script = `#!/usr/bin/env node
984
- // Auto-generated by claude-code-dashboard install
985
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
986
- import { spawn } from 'node:child_process';
987
- import { request } from 'node:http';
988
- import { openSync } from 'node:fs';
1088
+ function showOverlay(title, message) {
1089
+ overlayContainer.innerHTML =
1090
+ '<div class="overlay"><div class="overlay-card">' +
1091
+ '<h2>' + esc(title) + '</h2>' +
1092
+ '<p>' + esc(message) + '</p>' +
1093
+ '</div></div>';
1094
+ }
989
1095
 
990
- const CONFIG_PATH = ${JSON.stringify(CONFIG_PATH)};
991
- const LOCK_PATH = ${JSON.stringify(LOCK_PATH)};
1096
+ function showConfirm(title, message, label, isDanger, onConfirm) {
1097
+ var btnClass = isDanger ? 'btn-confirm-danger' : 'btn-confirm';
1098
+ overlayContainer.innerHTML =
1099
+ '<div class="overlay"><div class="overlay-card">' +
1100
+ '<h2>' + esc(title) + '</h2>' +
1101
+ '<p>' + esc(message) + '</p>' +
1102
+ '<div class="overlay-actions">' +
1103
+ '<button class="btn-cancel" id="overlayCancel">Cancel</button>' +
1104
+ '<button class="' + btnClass + '" id="overlayConfirm">' + esc(label) + '</button>' +
1105
+ '</div>' +
1106
+ '</div></div>';
1107
+ document.getElementById('overlayCancel').onclick = clearOverlay;
1108
+ document.getElementById('overlayConfirm').onclick = function() {
1109
+ onConfirm();
1110
+ };
1111
+ }
992
1112
 
993
- let config;
994
- try {
995
- config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
996
- } catch {
997
- process.exit(0); // Config missing \u2014 probably uninstalled
998
- }
1113
+ function attemptReconnect() {
1114
+ var attempts = 0;
1115
+ var maxAttempts = 30;
1116
+ var timer = setInterval(function() {
1117
+ attempts++;
1118
+ if (attempts > maxAttempts) {
1119
+ clearInterval(timer);
1120
+ showOverlay('Connection Lost', 'Could not reconnect to the dashboard server.');
1121
+ return;
1122
+ }
1123
+ var req = new XMLHttpRequest();
1124
+ req.open('GET', '/api/sessions', true);
1125
+ req.timeout = 2000;
1126
+ req.onload = function() {
1127
+ if (req.status === 200) {
1128
+ clearInterval(timer);
1129
+ clearOverlay();
1130
+ connect();
1131
+ }
1132
+ };
1133
+ req.onerror = function() {};
1134
+ req.ontimeout = function() {};
1135
+ req.send();
1136
+ }, 1000);
1137
+ }
999
1138
 
1000
- const { port, serverEntry } = config;
1001
- const stdin = readFileSync(0, 'utf-8');
1139
+ btnStop.onclick = function() {
1140
+ showConfirm('Stop Dashboard', 'Are you sure you want to stop the dashboard server?', 'Stop', true, function() {
1141
+ showOverlay('Stopping...', 'Shutting down the dashboard server.');
1142
+ setButtonsEnabled(false);
1143
+ var req = new XMLHttpRequest();
1144
+ req.open('POST', '/api/shutdown', true);
1145
+ req.onload = function() {
1146
+ showOverlay('Server Stopped', 'The dashboard server has been shut down.');
1147
+ };
1148
+ req.onerror = function() {
1149
+ showOverlay('Server Stopped', 'The dashboard server has been shut down.');
1150
+ };
1151
+ req.send();
1152
+ });
1153
+ };
1002
1154
 
1003
- function postHook(data) {
1004
- return new Promise((resolve, reject) => {
1005
- const req = request({
1006
- hostname: '127.0.0.1',
1007
- port,
1008
- path: '/api/hook',
1009
- method: 'POST',
1010
- headers: { 'Content-Type': 'application/json' },
1011
- timeout: 5000,
1012
- }, (res) => {
1013
- res.resume();
1014
- resolve(res.statusCode);
1155
+ btnRestart.onclick = function() {
1156
+ showConfirm('Restart Dashboard', 'Are you sure you want to restart the dashboard server?', 'Restart', false, function() {
1157
+ showOverlay('Restarting...', 'The dashboard server is restarting. Reconnecting automatically...');
1158
+ setButtonsEnabled(false);
1159
+ var req = new XMLHttpRequest();
1160
+ req.open('POST', '/api/restart', true);
1161
+ req.onload = function() {};
1162
+ req.onerror = function() {};
1163
+ req.send();
1015
1164
  });
1016
- req.on('error', reject);
1017
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
1018
- req.write(data);
1019
- req.end();
1020
- });
1021
- }
1165
+ };
1022
1166
 
1023
- function isServerRunning() {
1024
- try {
1025
- if (!existsSync(LOCK_PATH)) return false;
1026
- const pid = parseInt(readFileSync(LOCK_PATH, 'utf-8').trim().split(':')[0], 10);
1027
- if (isNaN(pid)) return false;
1028
- process.kill(pid, 0); // Check if process exists
1029
- return true;
1030
- } catch {
1031
- return false;
1167
+ function connect() {
1168
+ if (es) {
1169
+ es.close();
1170
+ es = null;
1171
+ }
1172
+
1173
+ es = new EventSource('/api/events');
1174
+
1175
+ es.addEventListener('init', function(e) {
1176
+ sessions = JSON.parse(e.data);
1177
+ checkAndNotify(sessions);
1178
+ render();
1179
+ });
1180
+
1181
+ es.addEventListener('update', function(e) {
1182
+ sessions = JSON.parse(e.data);
1183
+ checkAndNotify(sessions);
1184
+ render();
1185
+ });
1186
+
1187
+ es.addEventListener('shutdown', function() {
1188
+ if (es) { es.close(); es = null; }
1189
+ setButtonsEnabled(false);
1190
+ showOverlay('Server Stopped', 'The dashboard server has been shut down.');
1191
+ });
1192
+
1193
+ es.addEventListener('restart', function() {
1194
+ if (es) { es.close(); es = null; }
1195
+ setButtonsEnabled(false);
1196
+ showOverlay('Restarting...', 'The dashboard server is restarting. Reconnecting automatically...');
1197
+ attemptReconnect();
1198
+ });
1199
+
1200
+ es.onopen = function() {
1201
+ connDot.classList.add('connected');
1202
+ connLabel.textContent = 'Connected';
1203
+ setButtonsEnabled(true);
1204
+ };
1205
+
1206
+ es.onerror = function() {
1207
+ connDot.classList.remove('connected');
1208
+ connLabel.textContent = 'Disconnected';
1209
+ setButtonsEnabled(false);
1210
+ };
1032
1211
  }
1033
- }
1034
1212
 
1035
- function startServer() {
1036
- const logPath = ${JSON.stringify(path2.join(DASHBOARD_DIR, "server.log"))};
1037
- const logFd = openSync(logPath, 'a');
1038
- const child = spawn(process.execPath, [serverEntry, '--no-hooks', '--no-open', '--port', String(port)], {
1039
- detached: true,
1040
- stdio: ['ignore', logFd, logFd],
1041
- env: { ...process.env },
1042
- });
1043
- child.unref();
1213
+ // Update time-ago values every 10 seconds
1214
+ setInterval(render, 10000);
1215
+
1216
+ connect();
1217
+ })();
1218
+ </script>
1219
+ </body>
1220
+ </html>`;
1044
1221
  }
1045
1222
 
1046
- async function waitForServer(maxWaitMs = 10000) {
1047
- const start = Date.now();
1048
- let delay = 100;
1049
- while (Date.now() - start < maxWaitMs) {
1050
- try {
1051
- await postHook('{"session_id":"ping","hook_event_name":"Ping"}');
1052
- return true;
1053
- } catch {
1054
- await new Promise(r => setTimeout(r, delay));
1055
- delay = Math.min(delay * 1.5, 1000);
1223
+ // src/server.ts
1224
+ function createServer2(options) {
1225
+ const { store, onShutdown, onRestart } = options;
1226
+ const idleTimeoutMs = options.idleTimeoutMs ?? 5 * 60 * 1e3;
1227
+ const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
1228
+ const sseClients = /* @__PURE__ */ new Set();
1229
+ function broadcastEvent(event, data) {
1230
+ for (const res of sseClients) {
1231
+ res.write(`event: ${event}
1232
+ data: ${data}
1233
+
1234
+ `);
1056
1235
  }
1057
1236
  }
1058
- return false;
1059
- }
1237
+ function broadcast() {
1238
+ broadcastEvent("update", JSON.stringify(store.getAllSessions()));
1239
+ }
1240
+ const server = http.createServer((req, res) => {
1241
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1242
+ const pathname = url.pathname;
1243
+ if (req.method === "GET" && pathname === "/") {
1244
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1245
+ res.end(getDashboardHtml());
1246
+ return;
1247
+ }
1248
+ if (req.method === "POST" && pathname === "/api/hook") {
1249
+ let body = "";
1250
+ req.on("data", (chunk) => {
1251
+ body += chunk.toString();
1252
+ });
1253
+ req.on("end", () => {
1254
+ try {
1255
+ const payload = JSON.parse(body);
1256
+ if (!payload.session_id || !payload.hook_event_name) {
1257
+ res.writeHead(400, { "Content-Type": "application/json" });
1258
+ res.end(JSON.stringify({ error: "Missing session_id or hook_event_name" }));
1259
+ return;
1260
+ }
1261
+ store.handleEvent(payload);
1262
+ broadcast();
1263
+ res.writeHead(200, { "Content-Type": "application/json" });
1264
+ res.end(JSON.stringify({ ok: true }));
1265
+ } catch {
1266
+ res.writeHead(400, { "Content-Type": "application/json" });
1267
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
1268
+ }
1269
+ });
1270
+ return;
1271
+ }
1272
+ if (req.method === "GET" && pathname === "/api/events") {
1273
+ res.writeHead(200, {
1274
+ "Content-Type": "text/event-stream",
1275
+ "Cache-Control": "no-cache",
1276
+ Connection: "keep-alive"
1277
+ });
1278
+ const initData = JSON.stringify(store.getAllSessions());
1279
+ res.write(`event: init
1280
+ data: ${initData}
1060
1281
 
1061
- async function main() {
1062
- try {
1063
- await postHook(stdin);
1064
- } catch {
1065
- // Server not running \u2014 try to start it
1066
- if (!isServerRunning()) {
1067
- startServer();
1282
+ `);
1283
+ sseClients.add(res);
1284
+ req.on("close", () => {
1285
+ sseClients.delete(res);
1286
+ });
1287
+ return;
1068
1288
  }
1069
- const ready = await waitForServer();
1070
- if (ready) {
1071
- try { await postHook(stdin); } catch { /* give up */ }
1289
+ if (req.method === "GET" && pathname === "/api/sessions") {
1290
+ res.writeHead(200, { "Content-Type": "application/json" });
1291
+ res.end(JSON.stringify(store.getAllSessions()));
1292
+ return;
1072
1293
  }
1073
- }
1294
+ if (req.method === "POST" && pathname === "/api/shutdown") {
1295
+ broadcastEvent("shutdown", JSON.stringify({ ok: true }));
1296
+ res.writeHead(200, { "Content-Type": "application/json" });
1297
+ res.end(JSON.stringify({ ok: true }));
1298
+ if (onShutdown) setImmediate(() => onShutdown());
1299
+ return;
1300
+ }
1301
+ if (req.method === "POST" && pathname === "/api/restart") {
1302
+ broadcastEvent("restart", JSON.stringify({ ok: true }));
1303
+ res.writeHead(200, { "Content-Type": "application/json" });
1304
+ res.end(JSON.stringify({ ok: true }));
1305
+ if (onRestart) setImmediate(() => onRestart());
1306
+ return;
1307
+ }
1308
+ res.writeHead(404, { "Content-Type": "application/json" });
1309
+ res.end(JSON.stringify({ error: "Not found" }));
1310
+ });
1311
+ const cleanupTimer = setInterval(() => {
1312
+ const removed = store.cleanIdleSessions(idleTimeoutMs);
1313
+ if (removed.length > 0) broadcast();
1314
+ }, cleanupIntervalMs);
1315
+ cleanupTimer.unref();
1316
+ return {
1317
+ server,
1318
+ listen(port, callback) {
1319
+ server.listen(port, "127.0.0.1", callback);
1320
+ },
1321
+ close() {
1322
+ clearInterval(cleanupTimer);
1323
+ for (const res of sseClients) {
1324
+ res.end();
1325
+ }
1326
+ sseClients.clear();
1327
+ return new Promise((resolve, reject) => {
1328
+ server.close((err) => err ? reject(err) : resolve());
1329
+ });
1330
+ }
1331
+ };
1074
1332
  }
1075
1333
 
1076
- main().catch(() => {});
1077
- `;
1078
- fs2.mkdirSync(path2.dirname(HOOK_SCRIPT_PATH), { recursive: true });
1079
- fs2.writeFileSync(HOOK_SCRIPT_PATH, script);
1080
- }
1081
- function install(port) {
1082
- fs2.mkdirSync(SERVER_DIR, { recursive: true });
1083
- fs2.mkdirSync(BIN_DIR, { recursive: true });
1084
- const bundleSrc = getThisBundle();
1085
- const bundleDest = path2.join(SERVER_DIR, "bin.js");
1086
- fs2.copyFileSync(bundleSrc, bundleDest);
1087
- fs2.writeFileSync(
1088
- CONFIG_PATH,
1089
- JSON.stringify({ port, serverEntry: bundleDest }, null, 2) + "\n"
1090
- );
1091
- writeHookScript(port, bundleDest);
1092
- const command = `node ${JSON.stringify(HOOK_SCRIPT_PATH)}`;
1093
- installHooksWithCommand(command);
1094
- console.log("Dashboard installed successfully!");
1095
- console.log(` Server bundle: ${bundleDest}`);
1096
- console.log(` Hook script: ${HOOK_SCRIPT_PATH}`);
1097
- console.log(` Config: ${CONFIG_PATH}`);
1098
- console.log(` Port: ${port}`);
1099
- console.log("");
1100
- console.log(
1101
- "The dashboard will auto-launch when a Claude Code session starts."
1102
- );
1103
- console.log(
1104
- "To uninstall: npx @kosinal/claude-code-dashboard uninstall"
1105
- );
1106
- }
1107
- function uninstall() {
1108
- removeHooks();
1109
- try {
1110
- fs2.rmSync(DASHBOARD_DIR, { recursive: true, force: true });
1111
- } catch {
1112
- }
1113
- try {
1114
- fs2.unlinkSync(HOOK_SCRIPT_PATH);
1115
- } catch {
1116
- }
1117
- console.log("Dashboard uninstalled successfully.");
1118
- }
1119
- function writeLockFile(port) {
1120
- fs2.mkdirSync(DASHBOARD_DIR, { recursive: true });
1121
- fs2.writeFileSync(LOCK_PATH, `${process.pid}:${port}`);
1122
- }
1123
- function readLockFile() {
1124
- try {
1125
- const content = fs2.readFileSync(LOCK_PATH, "utf-8").trim();
1126
- const parts = content.split(":");
1127
- const pid = parseInt(parts[0], 10);
1128
- const port = parts.length > 1 ? parseInt(parts[1], 10) : NaN;
1129
- if (isNaN(pid) || isNaN(port)) return null;
1130
- process.kill(pid, 0);
1131
- return { pid, port };
1132
- } catch {
1133
- try {
1134
- fs2.unlinkSync(LOCK_PATH);
1135
- } catch {
1334
+ // src/state.ts
1335
+ var EVENT_TO_STATUS = {
1336
+ SessionStart: "waiting",
1337
+ UserPromptSubmit: "running",
1338
+ Stop: "waiting"
1339
+ };
1340
+ function createStore() {
1341
+ const sessions = /* @__PURE__ */ new Map();
1342
+ return {
1343
+ handleEvent(payload) {
1344
+ const { session_id, hook_event_name, cwd } = payload;
1345
+ if (hook_event_name === "SessionEnd") {
1346
+ sessions.delete(session_id);
1347
+ return null;
1348
+ }
1349
+ const status = EVENT_TO_STATUS[hook_event_name];
1350
+ if (!status) {
1351
+ const existing2 = sessions.get(session_id);
1352
+ if (existing2) return existing2;
1353
+ const session2 = {
1354
+ sessionId: session_id,
1355
+ status: "waiting",
1356
+ cwd: cwd ?? "",
1357
+ lastEvent: hook_event_name,
1358
+ updatedAt: Date.now(),
1359
+ startedAt: Date.now()
1360
+ };
1361
+ sessions.set(session_id, session2);
1362
+ return session2;
1363
+ }
1364
+ const now = Date.now();
1365
+ const existing = sessions.get(session_id);
1366
+ if (existing) {
1367
+ existing.status = status;
1368
+ existing.lastEvent = hook_event_name;
1369
+ existing.updatedAt = now;
1370
+ if (cwd) existing.cwd = cwd;
1371
+ return existing;
1372
+ }
1373
+ const session = {
1374
+ sessionId: session_id,
1375
+ status,
1376
+ cwd: cwd ?? "",
1377
+ lastEvent: hook_event_name,
1378
+ updatedAt: now,
1379
+ startedAt: now
1380
+ };
1381
+ sessions.set(session_id, session);
1382
+ return session;
1383
+ },
1384
+ getAllSessions() {
1385
+ return Array.from(sessions.values());
1386
+ },
1387
+ getSession(sessionId) {
1388
+ return sessions.get(sessionId);
1389
+ },
1390
+ removeSession(sessionId) {
1391
+ return sessions.delete(sessionId);
1392
+ },
1393
+ cleanIdleSessions(maxIdleMs) {
1394
+ const now = Date.now();
1395
+ const removed = [];
1396
+ for (const [id, session] of sessions) {
1397
+ if (now - session.updatedAt > maxIdleMs) {
1398
+ sessions.delete(id);
1399
+ removed.push(id);
1400
+ }
1401
+ }
1402
+ return removed;
1136
1403
  }
1137
- return null;
1138
- }
1139
- }
1140
- function removeLockFile() {
1141
- try {
1142
- fs2.unlinkSync(LOCK_PATH);
1143
- } catch {
1144
- }
1404
+ };
1145
1405
  }
1146
1406
 
1147
1407
  // src/bin.ts
@@ -1155,7 +1415,7 @@ function parseArgs(argv) {
1155
1415
  const arg = argv[i];
1156
1416
  if (arg === "--port" && i + 1 < argv.length) {
1157
1417
  port = parseInt(argv[++i], 10);
1158
- if (isNaN(port) || port < 1 || port > 65535) {
1418
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
1159
1419
  console.error("Error: Invalid port number");
1160
1420
  process.exit(1);
1161
1421
  }
@@ -1177,7 +1437,8 @@ function parseArgs(argv) {
1177
1437
  return { port, command, noHooks, noOpen };
1178
1438
  }
1179
1439
  function printHelp() {
1180
- console.log(`
1440
+ console.log(
1441
+ `
1181
1442
  claude-code-dashboard - Real-time browser dashboard for Claude Code sessions
1182
1443
 
1183
1444
  Usage:
@@ -1200,7 +1461,8 @@ Quick mode (default):
1200
1461
  Install mode:
1201
1462
  Copies the server to ~/.claude/dashboard/ and installs persistent hooks.
1202
1463
  The dashboard auto-launches when a Claude Code session starts.
1203
- `.trim());
1464
+ `.trim()
1465
+ );
1204
1466
  }
1205
1467
  function openBrowser(url) {
1206
1468
  const cmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
@@ -1353,7 +1615,7 @@ function startDashboard(port, noHooks, noOpen) {
1353
1615
  cleanedUp = true;
1354
1616
  if (!noHooks) {
1355
1617
  try {
1356
- removeHooks();
1618
+ removeHooks(void 0, "quick");
1357
1619
  console.log("\nHooks removed from settings.json");
1358
1620
  } catch (err) {
1359
1621
  console.error("Warning: Failed to remove hooks:", err);
@@ -1395,9 +1657,7 @@ function startDashboard(port, noHooks, noOpen) {
1395
1657
  });
1396
1658
  dashboard.server.on("error", (err) => {
1397
1659
  if (err.code === "EADDRINUSE") {
1398
- console.error(
1399
- `Error: Port ${port} is already in use. Try --port <number>`
1400
- );
1660
+ console.error(`Error: Port ${port} is already in use. Try --port <number>`);
1401
1661
  process.exit(1);
1402
1662
  }
1403
1663
  throw err;