claude-mission-control 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "Claude Dashboard",
3
+ "short_name": "Claude Dash",
4
+ "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status.",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#0d1215",
8
+ "theme_color": "#0d1215",
9
+ "icons": [
10
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
11
+ ]
12
+ }
package/server.js ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Claude Projects Dashboard — local-only server.
4
+ // Zero dependencies; Node >= 18.
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { Collector } = require('./lib/collector');
9
+ const { openSession, openNewSession } = require('./lib/opener');
10
+ const { projectDetail } = require('./lib/detail');
11
+ const { sessionTranscript } = require('./lib/transcript-view');
12
+ const { searchHistory, searchTitles, searchTranscripts } = require('./lib/search');
13
+ const { sessionTitle } = require('./lib/transcripts');
14
+ const { friendlyName } = require('./lib/names');
15
+ const cfg = require('./lib/config');
16
+ const { isProjectMuted } = require('./lib/notify');
17
+
18
+ const PORT = Number(process.env.CLAUDE_DASH_PORT) || 4517;
19
+ // Default loopback-only. For remote access prefer `tailscale serve` (keeps
20
+ // this binding); CLAUDE_DASH_HOST is the explicit opt-out.
21
+ const HOST = process.env.CLAUDE_DASH_HOST || '127.0.0.1';
22
+ const DEV = process.env.CLAUDE_DASH_DEV === '1';
23
+ const INDEX = path.join(__dirname, 'public', 'index.html');
24
+
25
+ const VERSION = require('./package.json').version;
26
+ const collector = new Collector();
27
+ const sseClients = new Set();
28
+
29
+ let indexCache = null;
30
+ function indexHtml() {
31
+ if (DEV || !indexCache) indexCache = fs.readFileSync(INDEX);
32
+ return indexCache;
33
+ }
34
+
35
+ function allowedHost(req) {
36
+ if (HOST !== '127.0.0.1') return true; // explicitly opted into remote access
37
+ const h = String(req.headers.host || '');
38
+ return h.startsWith('127.0.0.1') || h.startsWith('localhost');
39
+ }
40
+
41
+ function sameOrigin(req) {
42
+ const origin = req.headers.origin;
43
+ return !origin || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin) || HOST !== '127.0.0.1';
44
+ }
45
+
46
+ function readBody(req, res, cb) {
47
+ let body = '';
48
+ req.on('data', (c) => {
49
+ body += c;
50
+ if (body.length > 16384) req.destroy();
51
+ });
52
+ req.on('end', () => {
53
+ try {
54
+ cb(JSON.parse(body || '{}'));
55
+ } catch {
56
+ res.writeHead(400, { 'Content-Type': 'application/json' });
57
+ res.end('{"ok":false,"error":"bad json"}');
58
+ }
59
+ });
60
+ }
61
+
62
+ function json(res, code, value) {
63
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
64
+ res.end(JSON.stringify(value));
65
+ }
66
+
67
+ const server = http.createServer((req, res) => {
68
+ if (!allowedHost(req)) {
69
+ res.writeHead(403);
70
+ res.end('forbidden');
71
+ return;
72
+ }
73
+ const url = req.url.split('?')[0];
74
+
75
+ if (url === '/' || url === '/index.html') {
76
+ res.writeHead(200, {
77
+ 'Content-Type': 'text/html; charset=utf-8',
78
+ 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'",
79
+ });
80
+ res.end(indexHtml());
81
+ return;
82
+ }
83
+
84
+ // PWA assets, exact names only.
85
+ if (url === '/manifest.webmanifest' || url === '/icon.svg') {
86
+ const type = url === '/icon.svg' ? 'image/svg+xml' : 'application/manifest+json';
87
+ fs.readFile(path.join(__dirname, 'public', url.slice(1)), (err, buf) => {
88
+ if (err) {
89
+ res.writeHead(404);
90
+ res.end();
91
+ return;
92
+ }
93
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'max-age=86400' });
94
+ res.end(buf);
95
+ });
96
+ return;
97
+ }
98
+
99
+ // Bundled font files only — no traversal, extension whitelisted.
100
+ if (url.startsWith('/fonts/')) {
101
+ const name = path.basename(url);
102
+ if (!/^[\w-]+\.woff2$/.test(name)) {
103
+ res.writeHead(404);
104
+ res.end();
105
+ return;
106
+ }
107
+ fs.readFile(path.join(__dirname, 'public', 'fonts', name), (err, buf) => {
108
+ if (err) {
109
+ res.writeHead(404);
110
+ res.end();
111
+ return;
112
+ }
113
+ res.writeHead(200, { 'Content-Type': 'font/woff2', 'Cache-Control': 'max-age=86400' });
114
+ res.end(buf);
115
+ });
116
+ return;
117
+ }
118
+
119
+ if (url === '/api/state') {
120
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
121
+ res.end(JSON.stringify(collector.state));
122
+ return;
123
+ }
124
+
125
+ if (url === '/api/health') {
126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify({ ok: true, pid: process.pid, uptime: process.uptime(), version: VERSION }));
128
+ return;
129
+ }
130
+
131
+ // Manual only — the dashboard never phones home on its own. This runs
132
+ // when the user clicks "check for updates" in settings.
133
+ if (url === '/api/update-check') {
134
+ fetch('https://api.github.com/repos/JonImmsWordpressDev/claude-dashboard/releases/latest', {
135
+ headers: { 'User-Agent': 'claude-dashboard', Accept: 'application/vnd.github+json' },
136
+ })
137
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`GitHub responded ${r.status}`))))
138
+ .then((rel) => {
139
+ const latest = String(rel.tag_name || '').replace(/^v/, '');
140
+ json(res, 200, {
141
+ current: VERSION,
142
+ latest,
143
+ upToDate: !latest || latest === VERSION,
144
+ url: rel.html_url || 'https://github.com/JonImmsWordpressDev/claude-dashboard/releases',
145
+ });
146
+ })
147
+ .catch((e) => json(res, 502, { error: String(e.message).slice(0, 120) }));
148
+ return;
149
+ }
150
+
151
+ if (url === '/api/events') {
152
+ res.writeHead(200, {
153
+ 'Content-Type': 'text/event-stream',
154
+ 'Cache-Control': 'no-cache',
155
+ Connection: 'keep-alive',
156
+ });
157
+ res.write('retry: 3000\n\n');
158
+ res.write(`event: state\ndata: ${JSON.stringify(collector.state)}\n\n`);
159
+ sseClients.add(res);
160
+ collector.setClientCount(sseClients.size);
161
+ req.on('close', () => {
162
+ sseClients.delete(res);
163
+ collector.setClientCount(sseClients.size);
164
+ });
165
+ return;
166
+ }
167
+
168
+ if (url === '/api/project') {
169
+ // Path must be one of the known project roots — never an arbitrary path.
170
+ const requested = new URL(req.url, 'http://localhost').searchParams.get('path') || '';
171
+ const known = collector
172
+ .projectPaths()
173
+ .find((p) => p.toLowerCase() === requested.toLowerCase());
174
+ if (!known) {
175
+ res.writeHead(404, { 'Content-Type': 'application/json' });
176
+ res.end('{"error":"unknown project"}');
177
+ return;
178
+ }
179
+ projectDetail(known)
180
+ .then((detail) => {
181
+ detail.sessions = collector.allSessions(known);
182
+ detail.muted = isProjectMuted(known, cfg.readConfig().mutedProjects);
183
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
184
+ res.end(JSON.stringify(detail));
185
+ })
186
+ .catch((e) => {
187
+ res.writeHead(500, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ error: String(e.message).slice(0, 200) }));
189
+ });
190
+ return;
191
+ }
192
+
193
+ if (url === '/api/stats') {
194
+ json(res, 200, collector.statsSummary());
195
+ return;
196
+ }
197
+
198
+ if (url === '/api/config' && req.method === 'GET') {
199
+ json(res, 200, {
200
+ ...cfg.readConfig(),
201
+ version: VERSION,
202
+ errors: collector.state.errors || [],
203
+ terminals: cfg.detectTerminals(),
204
+ resolvedTerminal: cfg.resolvedTerminal(),
205
+ claudeApp: cfg.detectClaudeApp(),
206
+ names: cfg.readNames(),
207
+ ignores: cfg.readIgnores(),
208
+ });
209
+ return;
210
+ }
211
+
212
+ if (req.method === 'POST' && ['/api/config', '/api/names', '/api/ignore'].includes(url)) {
213
+ if (!sameOrigin(req)) {
214
+ json(res, 403, { ok: false, error: 'forbidden' });
215
+ return;
216
+ }
217
+ readBody(req, res, (payload) => {
218
+ try {
219
+ if (url === '/api/config') {
220
+ if (payload.pinSession !== undefined) {
221
+ const id = String(payload.pinSession || '');
222
+ if (!collector.findSessionFile(id)) return json(res, 404, { ok: false, error: 'unknown session' });
223
+ const pinned = cfg.togglePin(id);
224
+ collector.assemble();
225
+ return json(res, 200, { ok: true, pinned });
226
+ }
227
+ if (payload.mutePath !== undefined) {
228
+ const known = collector
229
+ .projectPaths()
230
+ .find((p) => p.toLowerCase() === String(payload.mutePath || '').toLowerCase());
231
+ if (!known) return json(res, 404, { ok: false, error: 'unknown project' });
232
+ cfg.setProjectMuted(known, payload.muted !== false);
233
+ } else {
234
+ cfg.updateConfig(payload);
235
+ }
236
+ } else if (url === '/api/names') {
237
+ const known = collector
238
+ .projectPaths()
239
+ .find((p) => p.toLowerCase() === String(payload.path || '').toLowerCase());
240
+ if (!known) return json(res, 404, { ok: false, error: 'unknown project' });
241
+ cfg.setName(known, String(payload.name || ''));
242
+ } else if (payload.remove) {
243
+ cfg.removeIgnore(String(payload.path || ''));
244
+ } else {
245
+ const known = collector
246
+ .projectPaths()
247
+ .find((p) => p.toLowerCase() === String(payload.path || '').toLowerCase());
248
+ if (!known) return json(res, 404, { ok: false, error: 'unknown project' });
249
+ cfg.addIgnore(known);
250
+ }
251
+ collector.assemble(); // reflect the change on the next state push
252
+ json(res, 200, { ok: true });
253
+ } catch (e) {
254
+ json(res, 500, { ok: false, error: String(e.message).slice(0, 200) });
255
+ }
256
+ });
257
+ return;
258
+ }
259
+
260
+ if (url === '/api/session') {
261
+ const params = new URL(req.url, 'http://localhost').searchParams;
262
+ const id = params.get('id') || '';
263
+ const after = Math.max(0, Number(params.get('after')) || 0);
264
+ const found = collector.findSessionFile(id);
265
+ if (!found) {
266
+ res.writeHead(404, { 'Content-Type': 'application/json' });
267
+ res.end('{"error":"unknown session"}');
268
+ return;
269
+ }
270
+ sessionTranscript(found.file, after)
271
+ .then((t) => {
272
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
273
+ res.end(JSON.stringify({ sessionId: id, title: found.title, projectName: found.projectName, ...t }));
274
+ })
275
+ .catch((e) => {
276
+ res.writeHead(500, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ error: String(e.message).slice(0, 200) }));
278
+ });
279
+ return;
280
+ }
281
+
282
+ if (url === '/api/search') {
283
+ const q = (new URL(req.url, 'http://localhost').searchParams.get('q') || '').trim();
284
+ if (q.length < 2) {
285
+ res.writeHead(400, { 'Content-Type': 'application/json' });
286
+ res.end('{"error":"query too short"}');
287
+ return;
288
+ }
289
+ const deep = new URL(req.url, 'http://localhost').searchParams.get('deep') === '1';
290
+ Promise.all([
291
+ searchHistory(q),
292
+ deep ? searchTranscripts(q, collector.raw.transcriptGroups) : Promise.resolve(null),
293
+ ])
294
+ .then(([prompts, transcripts]) => {
295
+ const titles = searchTitles(q, collector.raw.transcriptGroups, sessionTitle);
296
+ for (const r of prompts) r.projectName = friendlyName(r.project);
297
+ for (const r of titles) r.projectName = friendlyName(r.project);
298
+ if (transcripts) for (const r of transcripts.matches) r.projectName = friendlyName(r.project);
299
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
300
+ res.end(JSON.stringify({ q, prompts, titles, transcripts }));
301
+ })
302
+ .catch((e) => {
303
+ res.writeHead(500, { 'Content-Type': 'application/json' });
304
+ res.end(JSON.stringify({ error: String(e.message).slice(0, 200) }));
305
+ });
306
+ return;
307
+ }
308
+
309
+ if (url === '/api/open' && req.method === 'POST') {
310
+ // Same-origin only: browsers always send Origin on cross-origin POSTs.
311
+ const origin = req.headers.origin;
312
+ if (origin && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) {
313
+ res.writeHead(403, { 'Content-Type': 'application/json' });
314
+ res.end('{"ok":false,"error":"forbidden"}');
315
+ return;
316
+ }
317
+ let body = '';
318
+ req.on('data', (c) => {
319
+ body += c;
320
+ if (body.length > 4096) req.destroy();
321
+ });
322
+ req.on('end', async () => {
323
+ let payload;
324
+ try {
325
+ payload = JSON.parse(body);
326
+ } catch {
327
+ res.writeHead(400, { 'Content-Type': 'application/json' });
328
+ res.end('{"ok":false,"error":"bad json"}');
329
+ return;
330
+ }
331
+ // Fresh session in a known project dir (terminal only).
332
+ if (payload.newSession) {
333
+ const known = collector
334
+ .projectPaths()
335
+ .find((p) => p.toLowerCase() === String(payload.projectPath || '').toLowerCase());
336
+ if (!known) {
337
+ res.writeHead(404, { 'Content-Type': 'application/json' });
338
+ res.end('{"ok":false,"error":"unknown project"}');
339
+ return;
340
+ }
341
+ const result = await openNewSession(known);
342
+ res.writeHead(result.ok ? 200 : 500, { 'Content-Type': 'application/json' });
343
+ res.end(JSON.stringify(result));
344
+ return;
345
+ }
346
+
347
+ const found = collector.findSession(String(payload.sessionId || ''));
348
+ if (!found) {
349
+ res.writeHead(404, { 'Content-Type': 'application/json' });
350
+ res.end('{"ok":false,"error":"unknown session"}');
351
+ return;
352
+ }
353
+ if (found.live && payload.target === 'terminal') {
354
+ res.writeHead(409, { 'Content-Type': 'application/json' });
355
+ res.end('{"ok":false,"error":"session is already running"}');
356
+ return;
357
+ }
358
+ const result = await openSession({
359
+ sessionId: String(payload.sessionId),
360
+ cwd: found.cwd,
361
+ target: String(payload.target || ''),
362
+ });
363
+ res.writeHead(result.ok ? 200 : 500, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify(result));
365
+ });
366
+ return;
367
+ }
368
+
369
+ res.writeHead(404, { 'Content-Type': 'application/json' });
370
+ res.end('{"error":"not found"}');
371
+ });
372
+
373
+ collector.onChange((state) => {
374
+ const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
375
+ for (const res of sseClients) res.write(payload);
376
+ });
377
+
378
+ const pingTimer = setInterval(() => {
379
+ for (const res of sseClients) res.write(': ping\n\n');
380
+ }, 25_000);
381
+ pingTimer.unref();
382
+
383
+ collector.start().then(() => {
384
+ server.listen(PORT, HOST, () => {
385
+ console.log(`claude-dashboard listening on http://${HOST}:${PORT}`);
386
+ });
387
+ });
388
+
389
+ server.on('error', (err) => {
390
+ console.error(`server error: ${err.message}`);
391
+ process.exit(1);
392
+ });
393
+
394
+ process.on('SIGTERM', () => {
395
+ server.close(() => process.exit(0));
396
+ setTimeout(() => process.exit(0), 2000).unref();
397
+ });
package/uninstall.ps1 ADDED
@@ -0,0 +1,8 @@
1
+ # Remove the Claude Dashboard Scheduled Task (Windows).
2
+ schtasks /End /TN 'ClaudeDashboard' 2>$null | Out-Null
3
+ schtasks /Delete /TN 'ClaudeDashboard' /F 2>$null | Out-Null
4
+ Get-Process -Name node -ErrorAction SilentlyContinue |
5
+ Where-Object { $_.Path -and $_.CommandLine -match 'claude-dashboard' } |
6
+ Stop-Process -ErrorAction SilentlyContinue
7
+ Remove-Item -Path (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'run-hidden.vbs') -ErrorAction SilentlyContinue
8
+ Write-Host 'OK: claude-dashboard scheduled task removed.'
package/uninstall.sh ADDED
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # Remove the Claude Dashboard service (LaunchAgent on macOS, systemd user unit on Linux).
3
+ set -euo pipefail
4
+
5
+ LABEL="com.claude-dashboard"
6
+ PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
7
+
8
+ if [ "$(uname -s)" = "Linux" ]; then
9
+ systemctl --user disable --now claude-dashboard.service 2>/dev/null || true
10
+ rm -f "$HOME/.config/systemd/user/claude-dashboard.service"
11
+ systemctl --user daemon-reload
12
+ echo "✓ claude-dashboard systemd unit removed. (Repo left in place.)"
13
+ exit 0
14
+ fi
15
+
16
+ launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
17
+ rm -f "$PLIST"
18
+ echo "✓ claude-dashboard LaunchAgent removed. (Repo and logs left in place.)"