@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.
- package/dist/bin.js +973 -713
- 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/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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) + ' · ' + 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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) + ' · ' + timeAgo(s.updatedAt) + '</span></div>' +
|
|
1049
|
+
'</div>' +
|
|
1050
|
+
'</div>';
|
|
1051
|
+
}).join('') + '</div>';
|
|
910
1052
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
932
1057
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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 (
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
954
|
-
|
|
1078
|
+
|
|
1079
|
+
function setButtonsEnabled(enabled) {
|
|
1080
|
+
btnStop.disabled = !enabled;
|
|
1081
|
+
btnRestart.disabled = !enabled;
|
|
955
1082
|
}
|
|
956
|
-
|
|
957
|
-
function
|
|
958
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
991
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1017
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
1018
|
-
req.write(data);
|
|
1019
|
-
req.end();
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1165
|
+
};
|
|
1022
1166
|
|
|
1023
|
-
function
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
startServer();
|
|
1282
|
+
`);
|
|
1283
|
+
sseClients.add(res);
|
|
1284
|
+
req.on("close", () => {
|
|
1285
|
+
sseClients.delete(res);
|
|
1286
|
+
});
|
|
1287
|
+
return;
|
|
1068
1288
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
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;
|