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