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 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.
@@ -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
 
@@ -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');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.5.0",
3
+ "version": "1.7.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": {
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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: 'Oswald', 'Arial Narrow', sans-serif;
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:; connect-src 'self'",
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
- collector.start().then(() => {
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 http://${HOST}:${PORT}`);
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
  });