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 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 prints the URL. Like it? Install it for real below.
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
  ![Stats view: activity heatmap, busiest hours, weekly rhythm, 90-day spend, and per-model cost breakdowns](docs/screenshots/stats.png)
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
 
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // npx claude-dashboard / global install entry point. The server starts on
4
- // require and prints its URL.
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');
@@ -4,7 +4,8 @@ After=default.target
4
4
 
5
5
  [Service]
6
6
  ExecStart=__NODE_PATH__ __APP_DIR__/server.js
7
- Restart=on-failure
7
+ Environment=CLAUDE_DASH_SERVICE=1
8
+ Restart=always
8
9
  RestartSec=3
9
10
 
10
11
  [Install]
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.5.0",
3
+ "version": "1.8.0",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status — all your projects in one place.",
5
5
  "main": "server.js",
6
6
  "bin": {