claude-mission-control 1.5.0 → 1.7.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.
- package/README.md +4 -1
- package/bin/claude-dashboard.js +5 -2
- package/lib/chats.js +103 -0
- package/lib/collector.js +24 -1
- package/lib/config.js +3 -1
- package/lib/demo.js +148 -0
- package/lib/gitlog.js +58 -0
- package/package.json +1 -1
- package/public/index.html +244 -15
- package/server.js +116 -7
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Everything is read from local files under `~/.claude`, and it never writes to Cl
|
|
|
14
14
|
npx claude-mission-control
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
That downloads nothing permanent, starts the server, and
|
|
17
|
+
That downloads nothing permanent, starts the server, and opens the dashboard in your browser (add `--no-open` to skip that). Run it again while it's already up and it just opens the tab. Like it? Install it for real below.
|
|
18
18
|
|
|
19
19
|
Homebrew works too:
|
|
20
20
|
|
|
@@ -87,6 +87,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
|
|
|
87
87
|
- **New session** — the `⊕` button on a project card opens a fresh terminal window in that project running `claude`.
|
|
88
88
|
- **Cost trend** — the small bar chart in the header is estimated cost per week for the last 8 weeks (hover for numbers). Costs include subagent tokens.
|
|
89
89
|
- **Models everywhere** — every session shows which model ran it (live cards, digest, session lists), and the stats view breaks down usage per model and per project.
|
|
90
|
+
- **Claude.ai chats** — import the official export from claude.ai (Settings → Privacy → Export data, then feed `conversations.json` to ⚙ settings here) and your chats become browsable (`⌘K` → Claude.ai chats) and full-text searchable next to your coding sessions. Stored slimmed in your local config dir, gitignored, never uploaded anywhere.
|
|
90
91
|
|
|
91
92
|

|
|
92
93
|
- **Project details** — click any project's name for a slide-over with its full session list, rendered CLAUDE.md, per-project memory files, skills/agents/commands from `.claude/`, and settings (permissions, MCP servers, allowed tools). Read-only; also a quick audit of which projects are missing instructions or memory. Esc closes.
|
|
@@ -141,6 +142,8 @@ Edit `ignore.json` — an array of absolute path prefixes. A project is hidden i
|
|
|
141
142
|
| `CLAUDE_DASH_DEV` | unset | `1` = re-read index.html on every request |
|
|
142
143
|
| `CLAUDE_DASH_NOTIFY` | unset | `0` = disable macOS notifications |
|
|
143
144
|
| `CLAUDE_DASH_HOST` | `127.0.0.1` | Bind address — see below before changing |
|
|
145
|
+
| `CLAUDE_DASH_DEMO` | unset | `1` = serve believable fake data (screenshots, trying it without Claude history) |
|
|
146
|
+
| `CLAUDE_DASH_CONFIG_DIR` | repo dir | Where config.json/names.json/ignore.json live (auto-falls back to `~/.config/claude-dashboard`) |
|
|
144
147
|
|
|
145
148
|
The server binds to `127.0.0.1` only by default.
|
|
146
149
|
|
package/bin/claude-dashboard.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
// npx claude-
|
|
4
|
-
//
|
|
3
|
+
// npx claude-mission-control / global install entry point.
|
|
4
|
+
// Starts the server and opens the dashboard in the default browser.
|
|
5
|
+
// Pass --no-open to skip the browser (services use `node server.js` directly
|
|
6
|
+
// and never auto-open).
|
|
7
|
+
if (!process.argv.includes('--no-open')) process.env.CLAUDE_DASH_OPEN = '1';
|
|
5
8
|
require('../server.js');
|
package/lib/chats.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Claude.ai chats, imported from the official data export
|
|
3
|
+
// (claude.ai → Settings → Privacy → Export data → conversations.json).
|
|
4
|
+
// Stored normalized and slimmed in the config dir as chats.json —
|
|
5
|
+
// read-only afterwards, refreshed by mtime like every other user file.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { configDir } = require('./config');
|
|
9
|
+
|
|
10
|
+
const CHATS_FILE = path.join(configDir(), 'chats.json');
|
|
11
|
+
|
|
12
|
+
// The export replaces artifacts/rich blocks with this placeholder — drop it.
|
|
13
|
+
const PLACEHOLDER = /^This block is not supported on your current device yet\.?$/;
|
|
14
|
+
|
|
15
|
+
function msgText(m) {
|
|
16
|
+
if (m.text && m.text.trim()) return m.text.trim();
|
|
17
|
+
const parts = (m.content || [])
|
|
18
|
+
.filter((p) => p && p.type === 'text' && p.text && p.text.trim() && !PLACEHOLDER.test(p.text.trim()))
|
|
19
|
+
.map((p) => p.text.trim());
|
|
20
|
+
return parts.join('\n\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Raw export array -> slim [{id, name, createdAt, updatedAt, count, messages}]
|
|
24
|
+
function normalizeChats(raw) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const c of Array.isArray(raw) ? raw : []) {
|
|
27
|
+
const messages = (c.chat_messages || [])
|
|
28
|
+
.map((m) => ({
|
|
29
|
+
who: m.sender === 'human' ? 'you' : 'claude',
|
|
30
|
+
text: msgText(m),
|
|
31
|
+
ts: m.created_at ? Date.parse(m.created_at) || null : null,
|
|
32
|
+
}))
|
|
33
|
+
.filter((m) => m.text);
|
|
34
|
+
if (!messages.length) continue;
|
|
35
|
+
const name = (c.name || '').trim() || messages[0].text.slice(0, 80);
|
|
36
|
+
out.push({
|
|
37
|
+
id: c.uuid,
|
|
38
|
+
name,
|
|
39
|
+
createdAt: Date.parse(c.created_at) || null,
|
|
40
|
+
updatedAt: Date.parse(c.updated_at) || Date.parse(c.created_at) || 0,
|
|
41
|
+
count: messages.length,
|
|
42
|
+
messages,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_RESULTS = 40;
|
|
50
|
+
function searchChats(q, chats) {
|
|
51
|
+
const needle = q.toLowerCase();
|
|
52
|
+
if (!needle) return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const c of chats || []) {
|
|
55
|
+
if (out.length >= MAX_RESULTS) break;
|
|
56
|
+
if (c.name.toLowerCase().includes(needle)) {
|
|
57
|
+
out.push({ chatId: c.id, name: c.name, snippet: c.name, ts: c.updatedAt });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const m of c.messages || []) {
|
|
61
|
+
const i = m.text.toLowerCase().indexOf(needle);
|
|
62
|
+
if (i === -1) continue;
|
|
63
|
+
const start = Math.max(0, i - 60);
|
|
64
|
+
out.push({
|
|
65
|
+
chatId: c.id,
|
|
66
|
+
name: c.name,
|
|
67
|
+
snippet: (start > 0 ? '…' : '') + m.text.slice(start, i + needle.length + 140).replace(/\s+/g, ' '),
|
|
68
|
+
ts: m.ts || c.updatedAt,
|
|
69
|
+
});
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Cached by mtime, like config.json.
|
|
77
|
+
let cache = { mtimeMs: 0, chats: [] };
|
|
78
|
+
function readChats() {
|
|
79
|
+
let st;
|
|
80
|
+
try {
|
|
81
|
+
st = fs.statSync(CHATS_FILE);
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (st.mtimeMs !== cache.mtimeMs) {
|
|
86
|
+
try {
|
|
87
|
+
cache = { mtimeMs: st.mtimeMs, chats: JSON.parse(fs.readFileSync(CHATS_FILE, 'utf8')) };
|
|
88
|
+
} catch {
|
|
89
|
+
cache = { mtimeMs: st.mtimeMs, chats: [] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return cache.chats;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Normalize the raw export and persist. Returns the imported count.
|
|
96
|
+
function saveChats(raw) {
|
|
97
|
+
const chats = normalizeChats(raw);
|
|
98
|
+
fs.writeFileSync(CHATS_FILE, JSON.stringify(chats));
|
|
99
|
+
cache = { mtimeMs: 0, chats: [] }; // next read picks up the new file
|
|
100
|
+
return chats.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { normalizeChats, searchChats, readChats, saveChats };
|
package/lib/collector.js
CHANGED
|
@@ -35,6 +35,7 @@ class Collector {
|
|
|
35
35
|
this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
|
|
36
36
|
this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null };
|
|
37
37
|
this.listeners = new Set();
|
|
38
|
+
this.events = []; // notification history for the catch-up bell, newest first
|
|
38
39
|
this.clientCount = 0;
|
|
39
40
|
this.timers = [];
|
|
40
41
|
this.fingerprint = '';
|
|
@@ -100,12 +101,20 @@ class Collector {
|
|
|
100
101
|
this.notifyTransitions(live);
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// Everything notification-worthy lands here too, muted or not — the bell
|
|
105
|
+
// is how you catch up on what the toasts said while you were away.
|
|
106
|
+
logEvent(kind, project, body) {
|
|
107
|
+
this.events.unshift({ at: Date.now(), kind, project, body: String(body).slice(0, 200) });
|
|
108
|
+
if (this.events.length > 50) this.events.length = 50;
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
notifyTransitions(live) {
|
|
104
112
|
const next = new Map(live.map((s) => [s.sessionId, s.status]));
|
|
105
113
|
const prev = this.prevLiveStatus ?? null;
|
|
106
114
|
for (const id of newlyWaiting(prev, next)) {
|
|
107
115
|
const s = live.find((x) => x.sessionId === id);
|
|
108
116
|
const root = worktreeRoot(s.cwd).root;
|
|
117
|
+
this.logEvent('needs you', friendlyName(root), s.waitingFor || 'Claude was waiting for your input');
|
|
109
118
|
if (isProjectMuted(root, readConfig().mutedProjects)) continue;
|
|
110
119
|
const project = friendlyName(root);
|
|
111
120
|
sendNotification({
|
|
@@ -256,6 +265,7 @@ class Collector {
|
|
|
256
265
|
if (q >= QUIET_FLAG_MS) quietMin = Math.floor(q / 60000);
|
|
257
266
|
if (q >= QUIET_NOTIFY_MS && !this.stuckNotified.has(s.sessionId)) {
|
|
258
267
|
this.stuckNotified.add(s.sessionId);
|
|
268
|
+
this.logEvent('stuck', friendlyName(root), `Busy with no output for ${quietMin} minutes`);
|
|
259
269
|
if (!isProjectMuted(root, readConfig().mutedProjects)) sendNotification({
|
|
260
270
|
title: `${friendlyName(root)} may be stuck`,
|
|
261
271
|
body: `Busy with no output for ${quietMin} minutes — worth a look.`,
|
|
@@ -330,6 +340,7 @@ class Collector {
|
|
|
330
340
|
const level = budgetLevel(weeklyCost[7], budgetLimit);
|
|
331
341
|
if (level > (this.budgetAlerted || 0)) {
|
|
332
342
|
this.budgetAlerted = level;
|
|
343
|
+
this.logEvent('budget', 'Weekly budget', `${level >= 100 ? 'Over' : `${level}% of`} your $${budgetLimit} budget (≈$${weeklyCost[7].toFixed(0)})`);
|
|
333
344
|
sendNotification({
|
|
334
345
|
title: 'Claude weekly budget',
|
|
335
346
|
body: `7-day estimated spend ≈$${weeklyCost[7].toFixed(0)} — ${level >= 100 ? 'over' : `${level}% of`} your $${budgetLimit} budget.`,
|
|
@@ -340,7 +351,7 @@ class Collector {
|
|
|
340
351
|
}
|
|
341
352
|
const budget = budgetLimit ? { limit: budgetLimit, spent: weeklyCost[7], level } : null;
|
|
342
353
|
|
|
343
|
-
const next = { quota: this.raw.quota, plan: readPlan(), liveSessions, projects, weeklyCost, budget, pinned, errors: this.state.errors.slice(-5) };
|
|
354
|
+
const next = { quota: this.raw.quota, plan: readPlan(), theme: readConfig().theme || 'board', liveSessions, projects, weeklyCost, budget, pinned, events: this.events.slice(0, 50), errors: this.state.errors.slice(-5) };
|
|
344
355
|
const fp = JSON.stringify(next);
|
|
345
356
|
if (fp !== this.fingerprint) {
|
|
346
357
|
this.fingerprint = fp;
|
|
@@ -411,6 +422,8 @@ class Collector {
|
|
|
411
422
|
const sessionDays = [];
|
|
412
423
|
let sessionTotal = 0;
|
|
413
424
|
let costTotal = 0;
|
|
425
|
+
const dayCut = Date.now() - 24 * 3600000;
|
|
426
|
+
const timeline = [];
|
|
414
427
|
const weekDayKey = dayKey(weekCut);
|
|
415
428
|
let weekSessions = 0;
|
|
416
429
|
let weekCost = 0;
|
|
@@ -424,6 +437,15 @@ class Collector {
|
|
|
424
437
|
for (const m of g.sessions) {
|
|
425
438
|
const mDays = combinedDays(m);
|
|
426
439
|
sessionDays.push(mDays);
|
|
440
|
+
if (m.lastActivityAt >= dayCut) {
|
|
441
|
+
timeline.push({
|
|
442
|
+
project: friendlyName(g.path),
|
|
443
|
+
sessionId: m.sessionId,
|
|
444
|
+
title: sessionTitle(m).title,
|
|
445
|
+
start: Math.max(m.startedAt || m.lastActivityAt, dayCut),
|
|
446
|
+
end: m.lastActivityAt,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
427
449
|
if (m.lastActivityAt >= weekCut) {
|
|
428
450
|
weekSessions++;
|
|
429
451
|
if (m.waits) for (let i = 0; i < 4; i++) weekWaits[i] += m.waits[i];
|
|
@@ -458,6 +480,7 @@ class Collector {
|
|
|
458
480
|
hours,
|
|
459
481
|
heat: weekHourHeat(allTimestamps),
|
|
460
482
|
costDays: dailyCostSeries(sessionDays, 90),
|
|
483
|
+
timeline,
|
|
461
484
|
week: {
|
|
462
485
|
sessions: weekSessions,
|
|
463
486
|
prompts: weekPrompts,
|
package/lib/config.js
CHANGED
|
@@ -33,7 +33,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
|
33
33
|
const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
|
|
34
34
|
const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
|
|
35
35
|
|
|
36
|
-
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [] };
|
|
36
|
+
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], theme: 'board' };
|
|
37
37
|
|
|
38
38
|
function readJson(file, fallback) {
|
|
39
39
|
try {
|
|
@@ -59,6 +59,7 @@ function updateConfig(patch) {
|
|
|
59
59
|
if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
|
|
60
60
|
next.weeklyBudget = Math.round(patch.weeklyBudget);
|
|
61
61
|
}
|
|
62
|
+
if (patch.theme === 'board' || patch.theme === 'phosphor') next.theme = patch.theme;
|
|
62
63
|
writeJson(CONFIG_FILE, next);
|
|
63
64
|
return next;
|
|
64
65
|
}
|
|
@@ -188,6 +189,7 @@ function resolvedTerminal() {
|
|
|
188
189
|
|
|
189
190
|
module.exports = {
|
|
190
191
|
readConfig,
|
|
192
|
+
configDir: () => CONFIG_DIR,
|
|
191
193
|
findOnPath,
|
|
192
194
|
detectClaudeApp,
|
|
193
195
|
resolvedTerminal,
|
package/lib/demo.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// CLAUDE_DASH_DEMO=1: serve believable fake data instead of reading ~/.claude.
|
|
3
|
+
// For screenshots, demos, and trying the dashboard without any Claude history.
|
|
4
|
+
// Times are computed relative to now on every call so the board looks alive.
|
|
5
|
+
|
|
6
|
+
const MIN = 60000;
|
|
7
|
+
const HOUR = 3600000;
|
|
8
|
+
const DAY = 24 * HOUR;
|
|
9
|
+
|
|
10
|
+
const S1 = 'demo1111-1111-4111-8111-111111111111';
|
|
11
|
+
const S2 = 'demo2222-2222-4222-8222-222222222222';
|
|
12
|
+
const S3 = 'demo3333-3333-4333-8333-333333333333';
|
|
13
|
+
|
|
14
|
+
function demoState() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const mk = (name, path, ago, git, spark, spend) => ({
|
|
17
|
+
path,
|
|
18
|
+
name,
|
|
19
|
+
lastActivityAt: now - ago,
|
|
20
|
+
isLive: ago < 5 * MIN,
|
|
21
|
+
git,
|
|
22
|
+
spend7d: spend,
|
|
23
|
+
activity: { counts: spark },
|
|
24
|
+
sessions: [],
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
generatedAt: now,
|
|
28
|
+
plan: { tier: 'Max 20x' },
|
|
29
|
+
quota: { utilization: 34, weeklyUtilization: 52, resetsAt: '2pm', weeklyResetsAt: 'Wed 9am' },
|
|
30
|
+
weeklyCost: [31, 48, 22, 75, 61, 90, 84, 143],
|
|
31
|
+
budget: { limit: 200, spent: 143, level: 75 },
|
|
32
|
+
pinned: [
|
|
33
|
+
{ sessionId: S3, title: 'Design the onboarding flow', projectName: 'Acme Storefront', model: 'claude-opus-5', lastActivityAt: now - 3 * HOUR },
|
|
34
|
+
],
|
|
35
|
+
events: [
|
|
36
|
+
{ at: now - 4 * MIN, kind: 'needs you', project: 'Acme Storefront', body: 'Which payment provider should checkout use?' },
|
|
37
|
+
{ at: now - 38 * MIN, kind: 'stuck', project: 'Data Pipeline', body: 'Busy with no output for 20 minutes' },
|
|
38
|
+
{ at: now - 2 * HOUR, kind: 'budget', project: 'Weekly budget', body: '75% of your $200 budget (≈$143)' },
|
|
39
|
+
],
|
|
40
|
+
liveSessions: [
|
|
41
|
+
{
|
|
42
|
+
sessionId: S1, pid: 1111, cwd: '/demo/acme-storefront', projectPath: '/demo/acme-storefront',
|
|
43
|
+
projectName: 'Acme Storefront', isWorktree: false, model: 'claude-opus-5',
|
|
44
|
+
title: 'Wire Stripe checkout into the cart', status: 'waiting',
|
|
45
|
+
waitingFor: 'Which payment provider should checkout use?',
|
|
46
|
+
startedAt: now - 47 * MIN, statusUpdatedAt: now - 4 * MIN, quietMin: null,
|
|
47
|
+
currentTask: null, tasksSummary: null, subagents: null, resumeCommand: 'claude --resume demo',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
sessionId: S2, pid: 2222, cwd: '/demo/blog-engine', projectPath: '/demo/blog-engine',
|
|
51
|
+
projectName: 'Blog Engine', isWorktree: false, model: 'claude-fable-5',
|
|
52
|
+
title: 'Migrate posts to the new block format', status: 'busy',
|
|
53
|
+
waitingFor: null, startedAt: now - 3 * HOUR - 12 * MIN, statusUpdatedAt: now - MIN, quietMin: null,
|
|
54
|
+
currentTask: { activeForm: 'Converting legacy shortcodes' },
|
|
55
|
+
tasksSummary: { completed: 7, inProgress: 1, pending: 3 },
|
|
56
|
+
subagents: { count: 4, mtok: 2.3 }, resumeCommand: 'claude --resume demo',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
projects: [
|
|
60
|
+
mk('Acme Storefront', '/demo/acme-storefront', 2 * MIN,
|
|
61
|
+
{ isRepo: true, branch: 'feature/checkout', dirty: 4, untracked: 1, ahead: 2, behind: 0 },
|
|
62
|
+
[2, 5, 3, 8, 6, 9, 4, 7, 11, 6, 8, 12, 9, 14], 89.4),
|
|
63
|
+
mk('Blog Engine', '/demo/blog-engine', 1 * MIN,
|
|
64
|
+
{ isRepo: true, branch: 'main', dirty: 0, untracked: 0, ahead: 0, behind: 0 },
|
|
65
|
+
[0, 3, 1, 4, 2, 6, 3, 5, 2, 7, 4, 6, 8, 5], 31.7),
|
|
66
|
+
mk('Data Pipeline', '/demo/data-pipeline', 5 * HOUR,
|
|
67
|
+
{ isRepo: true, branch: 'main', dirty: 2, untracked: 0, ahead: 1, behind: 0 },
|
|
68
|
+
[1, 0, 2, 1, 3, 0, 2, 4, 1, 3, 2, 0, 5, 2], 18.2),
|
|
69
|
+
mk('Dotfiles', '/demo/dotfiles', 3 * DAY,
|
|
70
|
+
{ isRepo: true, branch: 'main', dirty: 0, untracked: 0, ahead: 0, behind: 0 },
|
|
71
|
+
[0, 0, 1, 0, 0, 2, 0, 1, 0, 0, 1, 0, 0, 1], 2.1),
|
|
72
|
+
],
|
|
73
|
+
errors: [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function demoStats() {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const days = [];
|
|
80
|
+
const costDays = [];
|
|
81
|
+
for (let i = 181; i >= 0; i--) {
|
|
82
|
+
const t = now - i * DAY;
|
|
83
|
+
const c = Math.max(0, Math.round(6 + 6 * Math.sin(i / 3) + (i % 7 === 0 ? -6 : 0)));
|
|
84
|
+
days.push({ t, c });
|
|
85
|
+
}
|
|
86
|
+
for (let i = 89; i >= 0; i--) {
|
|
87
|
+
const t = now - i * DAY;
|
|
88
|
+
const cost = Math.max(0, Math.round((12 + 10 * Math.sin(i / 4)) * 100) / 100);
|
|
89
|
+
costDays.push({ t, cost, tokens: Math.round(cost * 800000) });
|
|
90
|
+
}
|
|
91
|
+
const hours = [0, 0, 0, 0, 0, 0, 1, 3, 8, 14, 18, 22, 19, 16, 20, 17, 12, 9, 6, 4, 3, 2, 1, 0];
|
|
92
|
+
const heat = Array.from({ length: 7 }, (_, d) =>
|
|
93
|
+
hours.map((h) => Math.max(0, Math.round(h * (d === 0 || d === 6 ? 0.3 : 1) * (0.7 + (d % 3) * 0.2)))));
|
|
94
|
+
return {
|
|
95
|
+
days,
|
|
96
|
+
hours,
|
|
97
|
+
heat,
|
|
98
|
+
costDays,
|
|
99
|
+
timeline: [
|
|
100
|
+
{ project: 'Acme Storefront', sessionId: S1, title: 'Wire Stripe checkout into the cart', start: now - 47 * MIN, end: now },
|
|
101
|
+
{ project: 'Blog Engine', sessionId: S2, title: 'Migrate posts to the new block format', start: now - 3 * HOUR, end: now },
|
|
102
|
+
{ project: 'Data Pipeline', sessionId: S3, title: 'Backfill the analytics warehouse', start: now - 9 * HOUR, end: now - 5 * HOUR },
|
|
103
|
+
],
|
|
104
|
+
week: {
|
|
105
|
+
sessions: 18, prompts: 84, cost: 143.2,
|
|
106
|
+
projects: [
|
|
107
|
+
{ name: 'Acme Storefront', cost: 89.4 },
|
|
108
|
+
{ name: 'Blog Engine', cost: 31.7 },
|
|
109
|
+
{ name: 'Data Pipeline', cost: 18.2 },
|
|
110
|
+
],
|
|
111
|
+
models: ['claude-opus-5', 'claude-fable-5', 'claude-haiku-4-5'],
|
|
112
|
+
busiestDay: 'Tuesday', busiestHour: 11,
|
|
113
|
+
waits: [22, 31, 18, 4], typicalWait: 'under 2m',
|
|
114
|
+
},
|
|
115
|
+
byModel: {
|
|
116
|
+
'claude-opus-5': { tokens: 310e6, cost: 412.5 },
|
|
117
|
+
'claude-fable-5': { tokens: 120e6, cost: 388.1 },
|
|
118
|
+
'claude-haiku-4-5': { tokens: 42e6, cost: 9.8 },
|
|
119
|
+
},
|
|
120
|
+
perProject: [
|
|
121
|
+
{ name: 'Acme Storefront', total: 501.3, models: { 'claude-opus-5': 402.2, 'claude-fable-5': 99.1 } },
|
|
122
|
+
{ name: 'Blog Engine', total: 214.6, models: { 'claude-fable-5': 214.6 } },
|
|
123
|
+
],
|
|
124
|
+
totals: { sessions: 212, prompts: 1841, cost: 810.4 },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function demoSession() {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
return {
|
|
131
|
+
sessionId: S1,
|
|
132
|
+
title: 'Wire Stripe checkout into the cart',
|
|
133
|
+
projectName: 'Acme Storefront',
|
|
134
|
+
truncatedTurns: 0,
|
|
135
|
+
nextOffset: 0,
|
|
136
|
+
events: [
|
|
137
|
+
{ kind: 'user', ts: now - 47 * MIN, text: 'Add Stripe checkout to the cart page. Keep the guest flow working.' },
|
|
138
|
+
{ kind: 'assistant', ts: now - 46 * MIN, text: "I'll start with the payment intent endpoint, then wire the cart button to it." },
|
|
139
|
+
{ kind: 'tool', ts: now - 45 * MIN, name: 'Read', input: 'src/cart/CartPage.tsx' },
|
|
140
|
+
{ kind: 'tool', ts: now - 44 * MIN, name: 'Edit', input: 'src/api/checkout.ts' },
|
|
141
|
+
{ kind: 'assistant', ts: now - 40 * MIN, text: 'Endpoint is in with tests. The cart button now creates a payment intent and redirects.' },
|
|
142
|
+
{ kind: 'user', ts: now - 12 * MIN, text: 'Nice. What about Apple Pay?' },
|
|
143
|
+
{ kind: 'assistant', ts: now - 5 * MIN, text: 'Two options: Stripe Payment Request Button (fastest) or a native integration. Which payment provider should checkout use for the wallet flow?' },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { demoState, demoStats, demoSession };
|
package/lib/gitlog.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Recent commits for the project drawer — read-only `git log`, execFile
|
|
3
|
+
// arg arrays only (paths contain spaces).
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
|
|
6
|
+
// %x1e opens each record, %x1f separates fields:
|
|
7
|
+
// sha, committer epoch, subject, author, Co-Authored-By trailer values.
|
|
8
|
+
const FMT = '%x1e%h%x1f%ct%x1f%s%x1f%an%x1f%(trailers:key=Co-Authored-By,valueonly,separator=%x20)';
|
|
9
|
+
|
|
10
|
+
function parseGitLog(raw) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const rec of String(raw).split('\x1e')) {
|
|
13
|
+
if (!rec.trim()) continue;
|
|
14
|
+
const nl = rec.indexOf('\n');
|
|
15
|
+
const head = nl === -1 ? rec : rec.slice(0, nl);
|
|
16
|
+
const rest = nl === -1 ? '' : rec.slice(nl + 1);
|
|
17
|
+
const [sha, ct, subject, author, coauthors] = head.split('\x1f');
|
|
18
|
+
if (!sha) continue;
|
|
19
|
+
const statLine = rest
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map((l) => l.trim())
|
|
22
|
+
.find((l) => /files? changed/.test(l));
|
|
23
|
+
out.push({
|
|
24
|
+
sha,
|
|
25
|
+
ts: (Number(ct) || 0) * 1000,
|
|
26
|
+
subject: subject || '',
|
|
27
|
+
author: author || '',
|
|
28
|
+
claude: /claude/i.test(coauthors || ''),
|
|
29
|
+
stat: statLine || null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A Claude commit made while a session was active in the same repo probably
|
|
36
|
+
// came from that session — honest heuristic, windows padded 5 minutes.
|
|
37
|
+
const PAD = 5 * 60000;
|
|
38
|
+
function linkCommitsToSessions(commits, sessions) {
|
|
39
|
+
return commits.map((c) => {
|
|
40
|
+
const s = (sessions || []).find(
|
|
41
|
+
(x) => c.ts >= ((x.startedAt || x.lastActivityAt) - PAD) && c.ts <= (x.lastActivityAt + PAD)
|
|
42
|
+
);
|
|
43
|
+
return s ? { ...c, sessionId: s.sessionId, sessionTitle: s.title } : c;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recentCommits(projectPath, limit = 30) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
execFile(
|
|
50
|
+
'git',
|
|
51
|
+
['-C', projectPath, 'log', `--format=${FMT}`, '--shortstat', '-n', String(limit)],
|
|
52
|
+
{ timeout: 8000, maxBuffer: 1024 * 1024 },
|
|
53
|
+
(err, stdout) => resolve(err ? [] : parseGitLog(stdout))
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { parseGitLog, linkCommitsToSessions, recentCommits };
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Claude Dashboard</title>
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
7
8
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
9
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
9
10
|
<meta name="theme-color" content="#0d1215" media="(prefers-color-scheme: dark)">
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
broken. Oswald condensed caps carry names; JetBrains Mono carries data.
|
|
36
37
|
Light theme is the same timetable, printed on paper. */
|
|
37
38
|
:root {
|
|
39
|
+
--disp-font: 'Oswald', 'Arial Narrow', sans-serif;
|
|
38
40
|
--bg: #f5f4f0;
|
|
39
41
|
--surface: #ecebe5;
|
|
40
42
|
--surface2: #e0dfd8;
|
|
@@ -76,6 +78,29 @@
|
|
|
76
78
|
--amber-ink: #0d1215;
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
/* Phosphor theme: green-on-black terminal — a deliberate single-look skin,
|
|
82
|
+
so it overrides both light and dark palettes. */
|
|
83
|
+
:root[data-theme="phosphor"] {
|
|
84
|
+
--disp-font: 'JetBrains Mono', ui-monospace, monospace;
|
|
85
|
+
--bg: #050a06;
|
|
86
|
+
--surface: #0a120c;
|
|
87
|
+
--surface2: #122015;
|
|
88
|
+
--ink: #8fdf9a;
|
|
89
|
+
--muted: #55a061;
|
|
90
|
+
--faint: #35603d;
|
|
91
|
+
--line: #16301c;
|
|
92
|
+
--accent: #46e07a;
|
|
93
|
+
--warn: #ffb020;
|
|
94
|
+
--bad: #ff5f45;
|
|
95
|
+
--chip-bg: #122015;
|
|
96
|
+
--warn-bg: rgba(255, 176, 32, 0.10);
|
|
97
|
+
--accent-bg: rgba(70, 224, 122, 0.08);
|
|
98
|
+
--glow: 0 0 8px;
|
|
99
|
+
--flap: #0a120c;
|
|
100
|
+
--flap-hi: #0e1810;
|
|
101
|
+
--flap-split: rgba(0, 0, 0, 0.5);
|
|
102
|
+
--amber-ink: #050a06;
|
|
103
|
+
}
|
|
79
104
|
* { box-sizing: border-box; margin: 0; }
|
|
80
105
|
html { -webkit-text-size-adjust: 100%; }
|
|
81
106
|
body {
|
|
@@ -89,7 +114,7 @@
|
|
|
89
114
|
|
|
90
115
|
/* Display face: condensed caps, the board's letter-painted voice. */
|
|
91
116
|
.disp {
|
|
92
|
-
font-family:
|
|
117
|
+
font-family: var(--disp-font);
|
|
93
118
|
text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500;
|
|
94
119
|
}
|
|
95
120
|
/* Split-flap cell: gradient face with the horizontal split line. */
|
|
@@ -116,7 +141,7 @@
|
|
|
116
141
|
background: var(--surface);
|
|
117
142
|
}
|
|
118
143
|
header h1 {
|
|
119
|
-
font-family:
|
|
144
|
+
font-family: var(--disp-font);
|
|
120
145
|
font-size: 19px; font-weight: 600; letter-spacing: 0.10em;
|
|
121
146
|
text-transform: uppercase; white-space: nowrap;
|
|
122
147
|
}
|
|
@@ -152,7 +177,7 @@
|
|
|
152
177
|
border-bottom: 2px solid var(--line);
|
|
153
178
|
}
|
|
154
179
|
#live .rail-label {
|
|
155
|
-
font-family:
|
|
180
|
+
font-family: var(--disp-font);
|
|
156
181
|
font-size: 12px; text-transform: uppercase; letter-spacing: 0.22em;
|
|
157
182
|
color: var(--faint); margin-bottom: 10px;
|
|
158
183
|
}
|
|
@@ -217,7 +242,7 @@
|
|
|
217
242
|
}
|
|
218
243
|
.card .head { display: flex; align-items: center; gap: 8px; }
|
|
219
244
|
.card .name {
|
|
220
|
-
font-family:
|
|
245
|
+
font-family: var(--disp-font);
|
|
221
246
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
222
247
|
font-weight: 500; font-size: 15px;
|
|
223
248
|
}
|
|
@@ -267,11 +292,18 @@
|
|
|
267
292
|
}
|
|
268
293
|
#search:focus { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
269
294
|
#search::placeholder { color: var(--faint); }
|
|
270
|
-
#gear, #mc-btn {
|
|
295
|
+
#gear, #mc-btn, #bell-btn {
|
|
271
296
|
border: none; background: none; color: var(--muted); cursor: pointer;
|
|
272
297
|
font-size: 15px; padding: 2px 6px; border-radius: 2px; margin-left: 4px;
|
|
273
298
|
}
|
|
274
|
-
#gear:hover, #mc-btn:hover { background: var(--surface2); color: var(--ink); }
|
|
299
|
+
#gear:hover, #mc-btn:hover, #bell-btn:hover { background: var(--surface2); color: var(--ink); }
|
|
300
|
+
#bell-btn { position: relative; }
|
|
301
|
+
#bell-badge {
|
|
302
|
+
position: absolute; top: -3px; right: -3px;
|
|
303
|
+
background: var(--warn); color: var(--amber-ink);
|
|
304
|
+
font: 700 9.5px 'JetBrains Mono', monospace;
|
|
305
|
+
border-radius: 8px; padding: 1px 4px; min-width: 10px; text-align: center;
|
|
306
|
+
}
|
|
275
307
|
|
|
276
308
|
/* settings drawer */
|
|
277
309
|
.set-row {
|
|
@@ -325,7 +357,7 @@
|
|
|
325
357
|
/* ---------- pinned strip ---------- */
|
|
326
358
|
#pinned { margin-bottom: 16px; }
|
|
327
359
|
#pinned .pin-label {
|
|
328
|
-
font-family:
|
|
360
|
+
font-family: var(--disp-font);
|
|
329
361
|
font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.22em;
|
|
330
362
|
color: var(--faint); margin-bottom: 6px;
|
|
331
363
|
}
|
|
@@ -335,6 +367,23 @@
|
|
|
335
367
|
padding: 7px 14px; margin-bottom: 4px;
|
|
336
368
|
}
|
|
337
369
|
#pinned .pin-row .star { color: var(--warn); flex: none; }
|
|
370
|
+
|
|
371
|
+
/* ---------- day timeline ---------- */
|
|
372
|
+
.tl-lane { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
373
|
+
.tl-name {
|
|
374
|
+
flex: none; width: 110px; font-size: 12.5px;
|
|
375
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
376
|
+
}
|
|
377
|
+
.tl-track {
|
|
378
|
+
position: relative; flex: 1; height: 16px;
|
|
379
|
+
background: var(--surface); border-radius: 2px; overflow: hidden;
|
|
380
|
+
}
|
|
381
|
+
.tl-bar {
|
|
382
|
+
position: absolute; top: 3px; bottom: 3px; border-radius: 2px;
|
|
383
|
+
background: var(--accent); opacity: 0.8; cursor: pointer; min-width: 3px;
|
|
384
|
+
}
|
|
385
|
+
.tl-bar:hover { opacity: 1; }
|
|
386
|
+
.tl-tick { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--warn); }
|
|
338
387
|
.s-role { color: var(--faint); margin-right: 4px; }
|
|
339
388
|
|
|
340
389
|
/* ---------- git attention strip ---------- */
|
|
@@ -357,7 +406,7 @@
|
|
|
357
406
|
}
|
|
358
407
|
#digest summary::-webkit-details-marker { display: none; }
|
|
359
408
|
#digest-label {
|
|
360
|
-
font-family:
|
|
409
|
+
font-family: var(--disp-font);
|
|
361
410
|
font-size: 12px; text-transform: uppercase; letter-spacing: 0.22em;
|
|
362
411
|
color: var(--faint);
|
|
363
412
|
}
|
|
@@ -375,7 +424,7 @@
|
|
|
375
424
|
#digest-range button.sel { background: var(--surface2); color: var(--ink); }
|
|
376
425
|
#digest-body { margin-top: 10px; }
|
|
377
426
|
.digest-day {
|
|
378
|
-
font-family:
|
|
427
|
+
font-family: var(--disp-font);
|
|
379
428
|
color: var(--faint); font-size: 11.5px; text-transform: uppercase;
|
|
380
429
|
letter-spacing: 0.22em; margin: 16px 0 6px;
|
|
381
430
|
}
|
|
@@ -385,7 +434,7 @@
|
|
|
385
434
|
}
|
|
386
435
|
.digest-item .di-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
387
436
|
.digest-item .di-proj {
|
|
388
|
-
font-family:
|
|
437
|
+
font-family: var(--disp-font);
|
|
389
438
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
390
439
|
font-weight: 500; font-size: 14.5px;
|
|
391
440
|
}
|
|
@@ -405,7 +454,7 @@
|
|
|
405
454
|
/* ---------- dormant ---------- */
|
|
406
455
|
#dormant { margin-top: 26px; }
|
|
407
456
|
#dormant summary {
|
|
408
|
-
font-family:
|
|
457
|
+
font-family: var(--disp-font);
|
|
409
458
|
cursor: pointer; color: var(--faint); font-size: 12px;
|
|
410
459
|
text-transform: uppercase; letter-spacing: 0.22em; user-select: none;
|
|
411
460
|
}
|
|
@@ -426,7 +475,7 @@
|
|
|
426
475
|
background: var(--surface); flex: none;
|
|
427
476
|
}
|
|
428
477
|
.mc-title {
|
|
429
|
-
font-family:
|
|
478
|
+
font-family: var(--disp-font);
|
|
430
479
|
font-size: 16px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
|
|
431
480
|
}
|
|
432
481
|
.mc-sub { color: var(--faint); font-size: 12px; }
|
|
@@ -449,7 +498,7 @@
|
|
|
449
498
|
min-width: 0;
|
|
450
499
|
}
|
|
451
500
|
.mc-pane-head .mc-proj {
|
|
452
|
-
font-family:
|
|
501
|
+
font-family: var(--disp-font);
|
|
453
502
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
454
503
|
font-weight: 500; font-size: 15px; cursor: pointer;
|
|
455
504
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
@@ -485,7 +534,7 @@
|
|
|
485
534
|
padding: 16px 22px 12px; border-bottom: 1px solid var(--line);
|
|
486
535
|
}
|
|
487
536
|
#drawer .d-head h2 {
|
|
488
|
-
font-family:
|
|
537
|
+
font-family: var(--disp-font);
|
|
489
538
|
font-size: 17px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
|
|
490
539
|
}
|
|
491
540
|
#drawer .d-head .d-path {
|
|
@@ -621,6 +670,7 @@
|
|
|
621
670
|
<div id="quota"></div>
|
|
622
671
|
<span id="clock" class="flap" title="board time"></span>
|
|
623
672
|
<button id="mc-btn" title="Mission Control — every live session at once">▦</button>
|
|
673
|
+
<button id="bell-btn" title="Catch-up — what happened while you were away">🔔<span id="bell-badge" hidden></span></button>
|
|
624
674
|
<button id="gear" title="Settings">⚙</button>
|
|
625
675
|
</header>
|
|
626
676
|
|
|
@@ -995,6 +1045,8 @@ function renderDigest(state) {
|
|
|
995
1045
|
}
|
|
996
1046
|
|
|
997
1047
|
function render(state) {
|
|
1048
|
+
if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
|
|
1049
|
+
else delete document.documentElement.dataset.theme;
|
|
998
1050
|
liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
|
|
999
1051
|
// quota
|
|
1000
1052
|
const q = state.quota;
|
|
@@ -1035,6 +1087,7 @@ function render(state) {
|
|
|
1035
1087
|
|
|
1036
1088
|
renderGitAttention(state);
|
|
1037
1089
|
renderPinned(state);
|
|
1090
|
+
if (typeof renderBell === 'function') renderBell(state);
|
|
1038
1091
|
if (typeof mcSync === 'function') mcSync();
|
|
1039
1092
|
renderDigest(state);
|
|
1040
1093
|
|
|
@@ -1244,9 +1297,26 @@ function renderDetail(d, name) {
|
|
|
1244
1297
|
}
|
|
1245
1298
|
const muteBtn = `<div style="margin-bottom:10px"><button class="copy" data-mute="${esc(d.path)}" data-muted="${d.muted ? '1' : ''}" title="Per-project desktop notifications">${
|
|
1246
1299
|
d.muted ? '🔕 muted — click to unmute' : '🔔 notifying — click to mute'}</button></div>`;
|
|
1300
|
+
const statCompact = (stat) => {
|
|
1301
|
+
const ins = /(\d+) insertion/.exec(stat || '');
|
|
1302
|
+
const del = /(\d+) deletion/.exec(stat || '');
|
|
1303
|
+
return [ins && `+${ins[1]}`, del && `−${del[1]}`].filter(Boolean).join(' ');
|
|
1304
|
+
};
|
|
1305
|
+
const commitRows = (d.commits || []).map((c) => `
|
|
1306
|
+
<li>
|
|
1307
|
+
<span class="s-title" title="${esc(c.stat || '')}">
|
|
1308
|
+
<code class="mono" style="color:var(--faint)">${esc(c.sha)}</code>
|
|
1309
|
+
${c.claude ? '<span class="badge" title="Co-authored by Claude">✳ claude</span>' : ''}
|
|
1310
|
+
${esc(c.subject)}
|
|
1311
|
+
</span>
|
|
1312
|
+
${c.sessionId ? `<button class="copy" data-session="${esc(c.sessionId)}" title="${esc(c.sessionTitle || '')}">session →</button>` : ''}
|
|
1313
|
+
<span class="s-when">${esc(statCompact(c.stat))}</span>
|
|
1314
|
+
<span class="s-when">${relTime(c.ts)}</span>
|
|
1315
|
+
</li>`).join('');
|
|
1247
1316
|
$('#d-body').innerHTML =
|
|
1248
1317
|
muteBtn +
|
|
1249
1318
|
dSection(`Sessions — ${d.sessions.length}`, sessions, 'No sessions recorded.') +
|
|
1319
|
+
dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
|
|
1250
1320
|
dSection('CLAUDE.md', d.claudeMd.length ? dFiles(d.claudeMd, 'name') : null, 'No CLAUDE.md — sessions here start without project instructions.') +
|
|
1251
1321
|
dSection(`Memory — ${d.memory.length} file${d.memory.length === 1 ? '' : 's'}`, d.memory.length ? dFiles(d.memory, 'name') : null, 'No memory yet.') +
|
|
1252
1322
|
dSection(`Skills, agents & commands — ${caps.length}`, caps.length ? dFiles(caps, 'name') : null, 'None in .claude/skills, .claude/agents, or .claude/commands.') +
|
|
@@ -1565,10 +1635,17 @@ async function runSearch(q, deep) {
|
|
|
1565
1635
|
<span class="s-when">${esc(p.projectName)}</span>
|
|
1566
1636
|
<span class="s-when">${relTime(p.timestamp)}</span>
|
|
1567
1637
|
</li>`).join('');
|
|
1638
|
+
const chatRows = (d.chats || []).map((m) => `
|
|
1639
|
+
<li>
|
|
1640
|
+
<span class="s-title" data-chat="${esc(m.chatId)}" data-hl="${esc(hl)}" title="Click to open at this match">${esc(m.snippet)}</span>
|
|
1641
|
+
<span class="s-when">${esc(m.name.slice(0, 40))}</span>
|
|
1642
|
+
<span class="s-when">${m.ts ? relTime(m.ts) : ''}</span>
|
|
1643
|
+
</li>`).join('');
|
|
1568
1644
|
$('#d-body').innerHTML =
|
|
1569
1645
|
dSection(`Session titles — ${d.titles.length}`, titleRows ? `<ul class="d-sessions">${titleRows}</ul>` : null, 'No title matches.') +
|
|
1570
1646
|
dSection(`Prompts — ${d.prompts.length}`, promptRows ? `<ul class="d-sessions">${promptRows}</ul>` : null, 'No prompt matches.') +
|
|
1571
|
-
deepSection
|
|
1647
|
+
deepSection +
|
|
1648
|
+
(d.chats ? dSection(`Claude.ai chats — ${d.chats.length}`, chatRows ? `<ul class="d-sessions">${chatRows}</ul>` : null, 'No chat matches.') : '');
|
|
1572
1649
|
} catch {
|
|
1573
1650
|
$('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Search failed.</div>';
|
|
1574
1651
|
}
|
|
@@ -1624,11 +1701,22 @@ async function openSettings() {
|
|
|
1624
1701
|
<span class="set-label">Usage meters<span class="set-sub">fetches your own usage from Anthropic's API using your Claude Code sign-in (kept in memory) — the dashboard's only automatic network call</span></span>
|
|
1625
1702
|
<input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
|
|
1626
1703
|
</div>
|
|
1704
|
+
<div class="set-row">
|
|
1705
|
+
<span class="set-label">Theme<span class="set-sub">Departures board follows light/dark; Phosphor is always-dark green terminal</span></span>
|
|
1706
|
+
<select id="set-theme">
|
|
1707
|
+
<option value="board" ${c.theme !== 'phosphor' ? 'selected' : ''}>Departures board</option>
|
|
1708
|
+
<option value="phosphor" ${c.theme === 'phosphor' ? 'selected' : ''}>Phosphor</option>
|
|
1709
|
+
</select>
|
|
1710
|
+
</div>
|
|
1627
1711
|
<div class="set-row">
|
|
1628
1712
|
<span class="set-label">Weekly budget<span class="set-sub">estimated $ per trailing 7 days — header meter plus alerts at 75/90/100%; 0 turns it off</span></span>
|
|
1629
1713
|
<input type="text" id="set-budget" inputmode="numeric" style="width:70px;text-align:right"
|
|
1630
1714
|
value="${c.weeklyBudget || 0}" placeholder="0">
|
|
1631
1715
|
</div>
|
|
1716
|
+
<div class="set-row">
|
|
1717
|
+
<span class="set-label">Claude.ai chats<span class="set-sub">import conversations.json from your claude.ai export (Settings → Privacy → Export data)</span></span>
|
|
1718
|
+
<input type="file" id="set-chats" accept=".json,application/json" style="max-width:210px;font-size:11px;color:var(--muted)">
|
|
1719
|
+
</div>
|
|
1632
1720
|
<div class="set-row">
|
|
1633
1721
|
<span class="set-label">Version ${esc(c.version || '?')}<span class="set-sub">checks GitHub only when you click — never automatically</span></span>
|
|
1634
1722
|
<span id="set-update-result" style="color:var(--faint);font-size:12px"></span>
|
|
@@ -1673,6 +1761,21 @@ async function openSettings() {
|
|
|
1673
1761
|
saveSetting('/api/config', { notifications: e.target.checked }, e.target.checked ? 'Notifications on' : 'Notifications off'));
|
|
1674
1762
|
$('#set-usage').addEventListener('change', (e) =>
|
|
1675
1763
|
saveSetting('/api/config', { usageApi: e.target.checked }, e.target.checked ? 'Usage meters on' : 'Usage meters off — header falls back to the statusline cache'));
|
|
1764
|
+
$('#set-chats').addEventListener('change', async (e) => {
|
|
1765
|
+
const file = e.target.files[0];
|
|
1766
|
+
if (!file) return;
|
|
1767
|
+
toast('Importing chats…');
|
|
1768
|
+
try {
|
|
1769
|
+
const body = await file.text();
|
|
1770
|
+
const r = await fetch('/api/chats-import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
1771
|
+
const out = await r.json();
|
|
1772
|
+
toast(out.ok ? `Imported ${out.count} chats — find them in ⌘K` : `Import failed: ${out.error}`);
|
|
1773
|
+
} catch {
|
|
1774
|
+
toast("Couldn't import that file");
|
|
1775
|
+
}
|
|
1776
|
+
});
|
|
1777
|
+
$('#set-theme').addEventListener('change', (e) =>
|
|
1778
|
+
saveSetting('/api/config', { theme: e.target.value }, `Theme: ${e.target.selectedOptions[0].text}`));
|
|
1676
1779
|
$('#set-budget').addEventListener('change', (e) => {
|
|
1677
1780
|
const n = Math.max(0, Math.round(Number(e.target.value) || 0));
|
|
1678
1781
|
e.target.value = n;
|
|
@@ -1853,6 +1956,129 @@ document.addEventListener('click', (e) => {
|
|
|
1853
1956
|
if (ds) runSearch(ds.dataset.deepSearch, true);
|
|
1854
1957
|
});
|
|
1855
1958
|
|
|
1959
|
+
// ---------- claude.ai chats (imported) ----------
|
|
1960
|
+
async function openChats() {
|
|
1961
|
+
openDrawerShell('Claude.ai chats', '');
|
|
1962
|
+
try {
|
|
1963
|
+
const r = await fetch('/api/chats');
|
|
1964
|
+
if (!r.ok) throw new Error();
|
|
1965
|
+
const chats = await r.json();
|
|
1966
|
+
$('#d-path').textContent = chats.length
|
|
1967
|
+
? `${chats.length} imported chats — update anytime from Settings`
|
|
1968
|
+
: '';
|
|
1969
|
+
const rows = chats.map((c) => `
|
|
1970
|
+
<li>
|
|
1971
|
+
<span class="s-title" data-chat="${esc(c.id)}" title="Click to read">${esc(c.name)}</span>
|
|
1972
|
+
<span class="s-when">${c.count} msgs</span>
|
|
1973
|
+
<span class="s-when">${relTime(c.updatedAt)}</span>
|
|
1974
|
+
</li>`).join('');
|
|
1975
|
+
$('#d-body').innerHTML = rows
|
|
1976
|
+
? `<ul class="d-sessions" style="margin-top:12px">${rows}</ul>`
|
|
1977
|
+
: `<div class="d-empty" style="padding:20px 0">No chats imported yet. Export from claude.ai
|
|
1978
|
+
(Settings → Privacy → Export data), then import <code class="mono">conversations.json</code>
|
|
1979
|
+
from the ⚙ settings panel here.</div>`;
|
|
1980
|
+
} catch {
|
|
1981
|
+
$('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load chats.</div>';
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function openChat(id, highlight) {
|
|
1986
|
+
openDrawerShell('Chat', '');
|
|
1987
|
+
try {
|
|
1988
|
+
const r = await fetch(`/api/chat?id=${encodeURIComponent(id)}`);
|
|
1989
|
+
if (!r.ok) throw new Error();
|
|
1990
|
+
const c = await r.json();
|
|
1991
|
+
$('#d-title').textContent = c.name;
|
|
1992
|
+
$('#d-path').textContent = `claude.ai · ${c.count} messages · ${relTime(c.updatedAt)}`;
|
|
1993
|
+
$('#d-actions').innerHTML = '<button class="copy" data-chats-back>← all chats</button>';
|
|
1994
|
+
const st = { lastDay: '' };
|
|
1995
|
+
$('#d-body').innerHTML = transcriptHtml(
|
|
1996
|
+
c.messages.map((m) => ({ kind: m.who === 'you' ? 'user' : 'assistant', ts: m.ts, text: m.text })), st);
|
|
1997
|
+
if (highlight) {
|
|
1998
|
+
const needle = highlight.toLowerCase();
|
|
1999
|
+
const hit = [...$('#d-body').querySelectorAll('.t-turn')]
|
|
2000
|
+
.find((el) => el.textContent.toLowerCase().includes(needle));
|
|
2001
|
+
if (hit) {
|
|
2002
|
+
hit.scrollIntoView({ block: 'center' });
|
|
2003
|
+
hit.classList.add('hl-flash');
|
|
2004
|
+
setTimeout(() => hit.classList.remove('hl-flash'), 2400);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
} catch {
|
|
2008
|
+
$('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load this chat.</div>';
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
document.addEventListener('click', (e) => {
|
|
2013
|
+
const ch = e.target.closest('[data-chat]');
|
|
2014
|
+
if (ch) { openChat(ch.dataset.chat, ch.dataset.hl); return; }
|
|
2015
|
+
if (e.target.closest('[data-chats-back]')) openChats();
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// ---------- day timeline ----------
|
|
2019
|
+
async function openTimeline() {
|
|
2020
|
+
openDrawerShell('Last 24 hours', 'one lane per project · amber ticks = needed you');
|
|
2021
|
+
try {
|
|
2022
|
+
const r = await fetch('/api/stats');
|
|
2023
|
+
if (!r.ok) throw new Error();
|
|
2024
|
+
const s = await r.json();
|
|
2025
|
+
const now = Date.now();
|
|
2026
|
+
const t0 = now - 24 * 3600000;
|
|
2027
|
+
const pct = (t) => Math.max(0, Math.min(100, ((t - t0) / (now - t0)) * 100));
|
|
2028
|
+
const byProject = new Map();
|
|
2029
|
+
for (const row of s.timeline || []) {
|
|
2030
|
+
if (!byProject.has(row.project)) byProject.set(row.project, []);
|
|
2031
|
+
byProject.get(row.project).push(row);
|
|
2032
|
+
}
|
|
2033
|
+
const ticks = (lastState?.events || [])
|
|
2034
|
+
.filter((e) => e.kind === 'needs you' && e.at >= t0);
|
|
2035
|
+
const lanes = [...byProject.entries()].map(([proj, rows]) => {
|
|
2036
|
+
const bars = rows.map((row) => `<span class="tl-bar" data-session="${esc(row.sessionId)}"
|
|
2037
|
+
title="${esc(`${row.title} — ${new Date(row.start).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}–${new Date(row.end).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`)}"
|
|
2038
|
+
style="left:${pct(row.start).toFixed(1)}%;width:${Math.max(0.8, pct(row.end) - pct(row.start)).toFixed(1)}%"></span>`).join('');
|
|
2039
|
+
const marks = ticks.filter((e) => e.project === proj)
|
|
2040
|
+
.map((e) => `<span class="tl-tick" title="${esc(`needed you — ${relTime(e.at)}`)}" style="left:${pct(e.at).toFixed(1)}%"></span>`).join('');
|
|
2041
|
+
return `<div class="tl-lane"><span class="tl-name disp">${esc(proj)}</span><div class="tl-track">${bars}${marks}</div></div>`;
|
|
2042
|
+
}).join('');
|
|
2043
|
+
const hourMarks = [-24, -18, -12, -6, 0].map((h) =>
|
|
2044
|
+
`<span>${new Date(now + h * 3600000).toLocaleTimeString(undefined, { hour: 'numeric' })}</span>`).join('');
|
|
2045
|
+
$('#d-body').innerHTML = lanes
|
|
2046
|
+
? `<div style="margin-top:12px">${lanes}</div><div class="hours-labels" style="margin-left:120px">${hourMarks}</div>`
|
|
2047
|
+
: '<div class="d-empty" style="padding:20px 0">No sessions in the last 24 hours.</div>';
|
|
2048
|
+
} catch {
|
|
2049
|
+
$('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load the timeline.</div>';
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// ---------- catch-up bell ----------
|
|
2054
|
+
function seenAt() { return Number(localStorage.getItem('claudeDashSeenAt') || 0); }
|
|
2055
|
+
|
|
2056
|
+
function renderBell(state) {
|
|
2057
|
+
const unseen = (state.events || []).filter((e) => e.at > seenAt()).length;
|
|
2058
|
+
const b = $('#bell-badge');
|
|
2059
|
+
b.hidden = !unseen;
|
|
2060
|
+
if (unseen) b.textContent = unseen > 9 ? '9+' : String(unseen);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function openCatchUp() {
|
|
2064
|
+
openDrawerShell('Catch-up', 'notification history since the server started');
|
|
2065
|
+
const events = (lastState && lastState.events) || [];
|
|
2066
|
+
const since = seenAt();
|
|
2067
|
+
const rows = events.map((e) => `
|
|
2068
|
+
<li ${e.at > since ? 'style="border-left:2px solid var(--warn);padding-left:8px"' : ''}>
|
|
2069
|
+
<span class="flap disp st" style="flex:none">${esc(e.kind)}</span>
|
|
2070
|
+
<span class="s-title">${esc(e.project)} — ${esc(e.body)}</span>
|
|
2071
|
+
<span class="s-when">${relTime(e.at)}</span>
|
|
2072
|
+
</li>`).join('');
|
|
2073
|
+
$('#d-body').innerHTML = rows
|
|
2074
|
+
? `<ul class="d-sessions" style="margin-top:12px">${rows}</ul>`
|
|
2075
|
+
: '<div class="d-empty" style="padding:20px 0">Nothing yet — waiting, stuck, and budget events will show up here.</div>';
|
|
2076
|
+
localStorage.setItem('claudeDashSeenAt', String(Date.now()));
|
|
2077
|
+
renderBell(lastState || {});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
$('#bell-btn').addEventListener('click', openCatchUp);
|
|
2081
|
+
|
|
1856
2082
|
// ---------- weekly report ----------
|
|
1857
2083
|
let weekReport = null;
|
|
1858
2084
|
async function openWeekReport() {
|
|
@@ -1941,6 +2167,9 @@ function paletteItems(q) {
|
|
|
1941
2167
|
items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
|
|
1942
2168
|
items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
|
|
1943
2169
|
items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });
|
|
2170
|
+
items.push({ k: '━', label: 'Day timeline', hint: 'last 24h, lane per project', run: openTimeline });
|
|
2171
|
+
items.push({ k: '💬', label: 'Claude.ai chats', hint: 'imported from your export', run: openChats });
|
|
2172
|
+
items.push({ k: '🔔', label: 'Catch-up', hint: 'notification history', run: openCatchUp });
|
|
1944
2173
|
for (const l of st.liveSessions || []) {
|
|
1945
2174
|
items.push({ k: '●', label: `Watch live: ${l.projectName} — ${l.title || ''}`, hint: l.status, run: () => openTranscript(l.sessionId) });
|
|
1946
2175
|
}
|
package/server.js
CHANGED
|
@@ -9,11 +9,15 @@ const { Collector } = require('./lib/collector');
|
|
|
9
9
|
const { openSession, openNewSession } = require('./lib/opener');
|
|
10
10
|
const { projectDetail } = require('./lib/detail');
|
|
11
11
|
const { sessionTranscript } = require('./lib/transcript-view');
|
|
12
|
-
const { searchHistory, searchTitles, searchTranscripts } = require('./lib/search');
|
|
12
|
+
const { searchHistory, searchTitles, searchTranscripts, parseSearchQuery } = require('./lib/search');
|
|
13
|
+
const { readChats, saveChats, searchChats } = require('./lib/chats');
|
|
13
14
|
const { sessionTitle } = require('./lib/transcripts');
|
|
14
15
|
const { friendlyName } = require('./lib/names');
|
|
15
16
|
const cfg = require('./lib/config');
|
|
16
17
|
const { isProjectMuted } = require('./lib/notify');
|
|
18
|
+
const { recentCommits, linkCommitsToSessions } = require('./lib/gitlog');
|
|
19
|
+
const { demoState, demoStats, demoSession } = require('./lib/demo');
|
|
20
|
+
const DEMO = process.env.CLAUDE_DASH_DEMO === '1';
|
|
17
21
|
|
|
18
22
|
const PORT = Number(process.env.CLAUDE_DASH_PORT) || 4517;
|
|
19
23
|
// Default loopback-only. For remote access prefer `tailscale serve` (keeps
|
|
@@ -75,7 +79,7 @@ const server = http.createServer((req, res) => {
|
|
|
75
79
|
if (url === '/' || url === '/index.html') {
|
|
76
80
|
res.writeHead(200, {
|
|
77
81
|
'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
|
|
82
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: 'self'; connect-src 'self'",
|
|
79
83
|
});
|
|
80
84
|
res.end(indexHtml());
|
|
81
85
|
return;
|
|
@@ -96,6 +100,33 @@ const server = http.createServer((req, res) => {
|
|
|
96
100
|
return;
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
// Demo mode: canned data for every API route, static assets as normal.
|
|
104
|
+
if (DEMO && url.startsWith('/api/')) {
|
|
105
|
+
if (url === '/api/state') return json(res, 200, demoState());
|
|
106
|
+
if (url === '/api/health') return json(res, 200, { ok: true, demo: true, version: VERSION });
|
|
107
|
+
if (url === '/api/stats') return json(res, 200, demoStats());
|
|
108
|
+
if (url === '/api/session') return json(res, 200, demoSession());
|
|
109
|
+
if (url === '/api/search') return json(res, 200, { q: '', prompts: [], titles: [], transcripts: null });
|
|
110
|
+
if (url === '/api/project') {
|
|
111
|
+
return json(res, 200, {
|
|
112
|
+
path: '/demo/acme-storefront', sessions: [], commits: [], muted: false,
|
|
113
|
+
claudeMd: [], memory: [], skills: [], agents: [], commands: [], settings: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (url === '/api/config' && req.method === 'GET') {
|
|
117
|
+
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], version: VERSION, errors: [] });
|
|
118
|
+
}
|
|
119
|
+
if (url === '/api/events') {
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
121
|
+
const send = () => res.write(`data: ${JSON.stringify(demoState())}\n\n`);
|
|
122
|
+
send();
|
|
123
|
+
const t = setInterval(send, 5000);
|
|
124
|
+
req.on('close', () => clearInterval(t));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
return json(res, 200, { ok: false, error: 'not available in demo mode' });
|
|
128
|
+
}
|
|
129
|
+
|
|
99
130
|
// Bundled font files only — no traversal, extension whitelisted.
|
|
100
131
|
if (url.startsWith('/fonts/')) {
|
|
101
132
|
const name = path.basename(url);
|
|
@@ -176,9 +207,10 @@ const server = http.createServer((req, res) => {
|
|
|
176
207
|
res.end('{"error":"unknown project"}');
|
|
177
208
|
return;
|
|
178
209
|
}
|
|
179
|
-
projectDetail(known)
|
|
180
|
-
.then((detail) => {
|
|
210
|
+
Promise.all([projectDetail(known), recentCommits(known)])
|
|
211
|
+
.then(([detail, commits]) => {
|
|
181
212
|
detail.sessions = collector.allSessions(known);
|
|
213
|
+
detail.commits = linkCommitsToSessions(commits, detail.sessions);
|
|
182
214
|
detail.muted = isProjectMuted(known, cfg.readConfig().mutedProjects);
|
|
183
215
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
184
216
|
res.end(JSON.stringify(detail));
|
|
@@ -257,6 +289,38 @@ const server = http.createServer((req, res) => {
|
|
|
257
289
|
return;
|
|
258
290
|
}
|
|
259
291
|
|
|
292
|
+
// Claude.ai chats: imported once from the official export, then read-only.
|
|
293
|
+
if (url === '/api/chats-import' && req.method === 'POST') {
|
|
294
|
+
if (!sameOrigin(req)) return json(res, 403, { ok: false, error: 'forbidden' });
|
|
295
|
+
let body = '';
|
|
296
|
+
req.on('data', (c) => {
|
|
297
|
+
body += c;
|
|
298
|
+
if (body.length > 100 * 1024 * 1024) req.destroy(); // exports are ~tens of MB
|
|
299
|
+
});
|
|
300
|
+
req.on('end', () => {
|
|
301
|
+
try {
|
|
302
|
+
const raw = JSON.parse(body);
|
|
303
|
+
if (!Array.isArray(raw)) return json(res, 400, { ok: false, error: 'expected the conversations.json array' });
|
|
304
|
+
const count = saveChats(raw);
|
|
305
|
+
json(res, 200, { ok: true, count });
|
|
306
|
+
} catch (e) {
|
|
307
|
+
json(res, 400, { ok: false, error: String(e.message).slice(0, 200) });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (url === '/api/chats') {
|
|
314
|
+
return json(res, 200, readChats().map(({ messages, ...meta }) => meta));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (url === '/api/chat') {
|
|
318
|
+
const id = new URL(req.url, 'http://localhost').searchParams.get('id') || '';
|
|
319
|
+
const chat = readChats().find((c) => c.id === id);
|
|
320
|
+
if (!chat) return json(res, 404, { error: 'unknown chat' });
|
|
321
|
+
return json(res, 200, chat);
|
|
322
|
+
}
|
|
323
|
+
|
|
260
324
|
if (url === '/api/session') {
|
|
261
325
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
262
326
|
const id = params.get('id') || '';
|
|
@@ -296,8 +360,14 @@ const server = http.createServer((req, res) => {
|
|
|
296
360
|
for (const r of prompts) r.projectName = friendlyName(r.project);
|
|
297
361
|
for (const r of titles) r.projectName = friendlyName(r.project);
|
|
298
362
|
if (transcripts) for (const r of transcripts.matches) r.projectName = friendlyName(r.project);
|
|
363
|
+
let chats = null;
|
|
364
|
+
if (deep) {
|
|
365
|
+
const { text, since } = parseSearchQuery(q);
|
|
366
|
+
const pool = since ? readChats().filter((c) => c.updatedAt >= since) : readChats();
|
|
367
|
+
chats = searchChats(text, pool);
|
|
368
|
+
}
|
|
299
369
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
300
|
-
res.end(JSON.stringify({ q, prompts, titles, transcripts }));
|
|
370
|
+
res.end(JSON.stringify({ q, prompts, titles, transcripts, chats }));
|
|
301
371
|
})
|
|
302
372
|
.catch((e) => {
|
|
303
373
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -380,13 +450,52 @@ const pingTimer = setInterval(() => {
|
|
|
380
450
|
}, 25_000);
|
|
381
451
|
pingTimer.unref();
|
|
382
452
|
|
|
383
|
-
|
|
453
|
+
// Set by bin/claude-dashboard.js (the npx / global-install path). Services
|
|
454
|
+
// run this file directly and never auto-open a browser.
|
|
455
|
+
const OPEN = process.env.CLAUDE_DASH_OPEN === '1';
|
|
456
|
+
const DASH_URL = `http://127.0.0.1:${PORT}`;
|
|
457
|
+
|
|
458
|
+
function openBrowser(url) {
|
|
459
|
+
const { execFile } = require('child_process');
|
|
460
|
+
if (process.platform === 'darwin') execFile('open', [url], () => {});
|
|
461
|
+
else if (process.platform === 'win32') execFile('cmd', ['/c', 'start', '', url], () => {});
|
|
462
|
+
else execFile('xdg-open', [url], () => {});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
(DEMO ? Promise.resolve() : collector.start()).then(() => {
|
|
384
466
|
server.listen(PORT, HOST, () => {
|
|
385
|
-
console.log(`claude-dashboard listening on
|
|
467
|
+
console.log(`claude-dashboard listening on ${DASH_URL}${DEMO ? ' (demo mode)' : ''}`);
|
|
468
|
+
if (OPEN) {
|
|
469
|
+
console.log('opening it in your browser… (tip: the browser can install it as an app — Safari: File → Add to Dock; Chrome/Edge: the install icon in the address bar)');
|
|
470
|
+
openBrowser(DASH_URL);
|
|
471
|
+
}
|
|
386
472
|
});
|
|
387
473
|
});
|
|
388
474
|
|
|
389
475
|
server.on('error', (err) => {
|
|
476
|
+
// Someone ran npx while the dashboard is already up: just take them there.
|
|
477
|
+
if (err.code === 'EADDRINUSE') {
|
|
478
|
+
http.get(`${DASH_URL}/api/health`, (r) => {
|
|
479
|
+
let body = '';
|
|
480
|
+
r.on('data', (c) => (body += c));
|
|
481
|
+
r.on('end', () => {
|
|
482
|
+
let ok = false;
|
|
483
|
+
try { ok = JSON.parse(body).ok === true; } catch { /* not ours */ }
|
|
484
|
+
if (ok) {
|
|
485
|
+
console.log(`claude-dashboard is already running at ${DASH_URL}`);
|
|
486
|
+
if (OPEN) openBrowser(DASH_URL);
|
|
487
|
+
process.exit(0);
|
|
488
|
+
} else {
|
|
489
|
+
console.error(`port ${PORT} is in use by something else — set CLAUDE_DASH_PORT to pick another`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}).on('error', () => {
|
|
494
|
+
console.error(`port ${PORT} is in use by something else — set CLAUDE_DASH_PORT to pick another`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
390
499
|
console.error(`server error: ${err.message}`);
|
|
391
500
|
process.exit(1);
|
|
392
501
|
});
|