claude-mission-control 1.5.0 → 1.8.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 +6 -1
- package/bin/claude-dashboard.js +5 -2
- package/claude-dashboard.service +2 -1
- package/com.claude-dashboard.plist +1 -0
- package/lib/chats.js +103 -0
- package/lib/collector.js +24 -1
- package/lib/config.js +15 -1
- package/lib/demo.js +148 -0
- package/lib/gitlog.js +58 -0
- package/lib/update.js +110 -0
- package/package.json +1 -1
- package/public/index.html +372 -16
- package/server.js +139 -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.
|
|
@@ -112,6 +113,8 @@ The ⚙ gear in the header opens settings — no JSON editing required:
|
|
|
112
113
|
- **Notifications** on/off (writes `config.json`); per-project mute lives on each project's slide-over
|
|
113
114
|
- **Terminal** for open/new-session buttons: Ghostty, iTerm2, or Terminal.app, auto-detected (`config.json`)
|
|
114
115
|
- **Rename any project** (writes `names.json`) or **hide it** and its whole subtree (writes `ignore.json`), with an unhide list below
|
|
116
|
+
- **Theme**: Departures board (follows system light/dark), Phosphor, Amber CRT, Midnight, or Newsprint (`config.json`)
|
|
117
|
+
- **Updates**: "check for updates" asks GitHub only when you click; when a new release is out, **update now** pulls it in place (git or npm installs) and service installs restart themselves on the new version
|
|
115
118
|
|
|
116
119
|
Everything saves instantly; the underlying files stay hand-editable. Keyboard: `⌘K` for the palette, `/` for search, `Esc` closes anything.
|
|
117
120
|
|
|
@@ -141,6 +144,8 @@ Edit `ignore.json` — an array of absolute path prefixes. A project is hidden i
|
|
|
141
144
|
| `CLAUDE_DASH_DEV` | unset | `1` = re-read index.html on every request |
|
|
142
145
|
| `CLAUDE_DASH_NOTIFY` | unset | `0` = disable macOS notifications |
|
|
143
146
|
| `CLAUDE_DASH_HOST` | `127.0.0.1` | Bind address — see below before changing |
|
|
147
|
+
| `CLAUDE_DASH_DEMO` | unset | `1` = serve believable fake data (screenshots, trying it without Claude history) |
|
|
148
|
+
| `CLAUDE_DASH_CONFIG_DIR` | repo dir | Where config.json/names.json/ignore.json live (auto-falls back to `~/.config/claude-dashboard`) |
|
|
144
149
|
|
|
145
150
|
The server binds to `127.0.0.1` only by default.
|
|
146
151
|
|
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/claude-dashboard.service
CHANGED
|
@@ -14,5 +14,6 @@
|
|
|
14
14
|
<key>StandardErrorPath</key><string>__HOME__/Library/Logs/claude-dashboard.log</string>
|
|
15
15
|
<key>EnvironmentVariables</key><dict>
|
|
16
16
|
<key>CLAUDE_DASH_PORT</key><string>4517</string>
|
|
17
|
+
<key>CLAUDE_DASH_SERVICE</key><string>1</string>
|
|
17
18
|
</dict>
|
|
18
19
|
</dict></plist>
|
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,18 @@ 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
|
+
|
|
38
|
+
// The one theme list: updateConfig validates against it and GET /api/config
|
|
39
|
+
// serves it, so the settings dropdown can never offer a value the server
|
|
40
|
+
// would drop. Adding a theme = one entry here + its CSS block in index.html.
|
|
41
|
+
const THEMES = [
|
|
42
|
+
{ id: 'board', label: 'Departures board' },
|
|
43
|
+
{ id: 'phosphor', label: 'Phosphor' },
|
|
44
|
+
{ id: 'amber', label: 'Amber CRT' },
|
|
45
|
+
{ id: 'midnight', label: 'Midnight' },
|
|
46
|
+
{ id: 'newsprint', label: 'Newsprint' },
|
|
47
|
+
];
|
|
37
48
|
|
|
38
49
|
function readJson(file, fallback) {
|
|
39
50
|
try {
|
|
@@ -59,6 +70,7 @@ function updateConfig(patch) {
|
|
|
59
70
|
if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
|
|
60
71
|
next.weeklyBudget = Math.round(patch.weeklyBudget);
|
|
61
72
|
}
|
|
73
|
+
if (THEMES.some((t) => t.id === patch.theme)) next.theme = patch.theme;
|
|
62
74
|
writeJson(CONFIG_FILE, next);
|
|
63
75
|
return next;
|
|
64
76
|
}
|
|
@@ -187,7 +199,9 @@ function resolvedTerminal() {
|
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
module.exports = {
|
|
202
|
+
THEMES,
|
|
190
203
|
readConfig,
|
|
204
|
+
configDir: () => CONFIG_DIR,
|
|
191
205
|
findOnPath,
|
|
192
206
|
detectClaudeApp,
|
|
193
207
|
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/lib/update.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Self-update: figure out how this copy of the dashboard was installed, and
|
|
3
|
+
// run the matching update command. The server never takes a path or command
|
|
4
|
+
// from the client — everything derives from the app's own location.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { canonicalize } = require('./paths');
|
|
10
|
+
|
|
11
|
+
const PKG_NAME = require('../package.json').name;
|
|
12
|
+
|
|
13
|
+
// Pure: appDir + "does .git exist there" -> 'git' | 'npm' | 'npx' | 'brew' | 'unknown'.
|
|
14
|
+
// Location signals (brew Cellar, npx cache, non-npm package managers) win over
|
|
15
|
+
// a .git dir: those are package-manager-owned trees we must never git-pull in.
|
|
16
|
+
// pnpm/yarn/volta/bun globals also live under node_modules, but running
|
|
17
|
+
// `npm install -g` there installs a second copy under npm's own prefix while
|
|
18
|
+
// the running copy stays old — so they get manual instructions instead.
|
|
19
|
+
function detectInstallKind(appDir, hasGitDir) {
|
|
20
|
+
const p = canonicalize(appDir).toLowerCase();
|
|
21
|
+
if (p.includes('/cellar/') || p.includes('/homebrew/')) return 'brew';
|
|
22
|
+
if (p.includes('/_npx/')) return 'npx';
|
|
23
|
+
if (['/pnpm/', '/yarn/', '/.yarn/', '/volta/', '/.volta/', '/.bun/'].some((sig) => p.includes(sig))) return 'unknown';
|
|
24
|
+
if (hasGitDir) return 'git';
|
|
25
|
+
if (p.includes('/node_modules/')) return 'npm';
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure: install kind -> either a fixed command to run, or instructions to
|
|
30
|
+
// show. The command list is closed — nothing here ever comes from a request.
|
|
31
|
+
function updatePlan(kind, pkgName) {
|
|
32
|
+
if (kind === 'git') return { type: 'run', cmd: 'git', args: ['pull', '--ff-only'] };
|
|
33
|
+
if (kind === 'npm') return { type: 'run', cmd: 'npm', args: ['install', '-g', `${pkgName}@latest`] };
|
|
34
|
+
if (kind === 'brew') return { type: 'manual', message: `Run: brew upgrade ${pkgName}` };
|
|
35
|
+
if (kind === 'npx') return { type: 'manual', message: `Quit the dashboard and re-run npx ${pkgName} — npx fetches the newest release each time.` };
|
|
36
|
+
return { type: 'manual', message: 'Update with the tool you installed with, or download from https://github.com/JonImmsWordpressDev/claude-dashboard/releases' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure: where npm's CLI JS lives relative to the node binary's directory —
|
|
40
|
+
// the unix prefix layout and the Windows layout. Running it via our own
|
|
41
|
+
// process.execPath sidesteps both the service manager's minimal PATH
|
|
42
|
+
// (launchd/systemd ship no PATH, so bare 'npm' is ENOENT) and Windows,
|
|
43
|
+
// where 'npm' is npm.cmd and can't be spawned without a shell.
|
|
44
|
+
function npmCliCandidates(nodeDir) {
|
|
45
|
+
return [
|
|
46
|
+
path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
47
|
+
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// launchd (KeepAlive) and systemd (Restart) relaunch us after an update exit;
|
|
52
|
+
// a terminal-run process must not be killed. Signals, most reliable first:
|
|
53
|
+
// our own service definitions set CLAUDE_DASH_SERVICE=1; systemd sets
|
|
54
|
+
// INVOCATION_ID for every unit, which covers units deployed before that var
|
|
55
|
+
// existed; ppid 1 on macOS covers launchd agents installed before it (kept
|
|
56
|
+
// mac-only so an orphaned `dashboard &` run on Linux isn't misread).
|
|
57
|
+
function serviceManaged() {
|
|
58
|
+
if (process.env.CLAUDE_DASH_SERVICE === '1') return true;
|
|
59
|
+
if (process.env.INVOCATION_ID) return true;
|
|
60
|
+
return process.platform === 'darwin' && process.ppid === 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readPkgVersion(appDir) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).version || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runSelfUpdate(appDir) {
|
|
72
|
+
const kind = detectInstallKind(appDir, fs.existsSync(path.join(appDir, '.git')));
|
|
73
|
+
const plan = updatePlan(kind, PKG_NAME);
|
|
74
|
+
if (plan.type === 'manual') {
|
|
75
|
+
return Promise.resolve({ ok: false, kind, manual: plan.message });
|
|
76
|
+
}
|
|
77
|
+
let cmd = plan.cmd;
|
|
78
|
+
let args = plan.args;
|
|
79
|
+
if (kind === 'npm') {
|
|
80
|
+
const cli = npmCliCandidates(path.dirname(process.execPath)).find((p) => fs.existsSync(p));
|
|
81
|
+
if (cli) {
|
|
82
|
+
cmd = process.execPath;
|
|
83
|
+
args = [cli, ...plan.args];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const before = readPkgVersion(appDir);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
execFile(cmd, args, { cwd: appDir, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
resolve({ ok: false, kind, error: String(stderr || err.message).slice(0, 300) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// The command exiting 0 isn't proof anything changed: npm can re-fetch
|
|
94
|
+
// the same version while a release is still publishing, and git can
|
|
95
|
+
// pull nothing. Only a version change on disk earns a restart.
|
|
96
|
+
const after = readPkgVersion(appDir);
|
|
97
|
+
const changed = Boolean(after && before && after !== before);
|
|
98
|
+
resolve({
|
|
99
|
+
ok: true,
|
|
100
|
+
kind,
|
|
101
|
+
version: after || undefined,
|
|
102
|
+
unchanged: !changed,
|
|
103
|
+
willRestart: changed && serviceManaged(),
|
|
104
|
+
output: String(stdout).slice(0, 300),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { detectInstallKind, updatePlan, npmCliCandidates, runSelfUpdate };
|
package/package.json
CHANGED