claude-mission-control 1.11.0 → 1.12.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
@@ -1,8 +1,8 @@
1
- # Claude Dashboard
1
+ # Claude Mission Control
2
2
 
3
3
  One web page that shows you everything Claude Code is doing on your machine.
4
4
 
5
- Claude Code runs in a terminal. Once you have two or three sessions going, you lose track. Which one is waiting for an answer? What did that session change while you were away? How much has this week cost? Claude Dashboard answers those questions on one screen, updated live, from the files Claude Code already writes to `~/.claude`.
5
+ Claude Code runs in a terminal. Once you have two or three sessions going, you lose track. Which one is waiting for an answer? What did that session change while you were away? How much has this week cost? Claude Mission Control answers those questions on one screen, updated live, from the files Claude Code already writes to `~/.claude`.
6
6
 
7
7
  **What you get**
8
8
 
@@ -13,6 +13,7 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
13
13
  - Cost per session, project, model, and week, estimated from the tokens in your transcripts, with a breakdown by model and cache use.
14
14
  - Per project, the plugins, agents, skills, and MCP servers Claude Code would actually use there, and the exact setting to switch each one on or off.
15
15
  - A macOS notification the moment a session needs you, and a reminder if you leave it waiting.
16
+ - When you will hit your 5-hour or weekly limit at your current pace, before you hit it.
16
17
 
17
18
  **Why you would want it**
18
19
 
@@ -26,10 +27,17 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
26
27
 
27
28
  **Requirements:** macOS or Linux (Windows experimental, see below), Node.js ≥ 18, Claude Code.
28
29
 
29
- ![Claude Dashboard, dark mode: three live sessions, two of them waiting with the question shown, plus the pinned strip, unpushed work, and today's digest](docs/screenshots/dashboard-dark.png)
30
+ ![Claude Mission Control, dark mode: three live sessions with live cost, burn rate and context meters, two of them waiting with the question shown, plus the pinned strip, unpushed work, and today's digest](docs/screenshots/dashboard-dark.png)
30
31
 
31
32
  *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
32
33
 
34
+ **New in 1.12.0**
35
+
36
+ - A limit forecast on the session and week meters: an amber `→ 3:40 PM` when your current pace runs out before the reset, and one notification per window.
37
+ - A share card for your week: a PNG with totals, daily bars, and what your usage was worth against your plan's price. Totals only, no project names.
38
+ - A Claude Code plugin: `/plugin install mission-control --marketplace JonImmsWordpressDev/claude-dashboard`, then `/mission-control:open`.
39
+ - A statusline, `claude-dashboard statusline`: model, context, limits and cost, plus sessions waiting on you and the forecast when the dashboard is up.
40
+
33
41
  **New in 1.11.0**
34
42
 
35
43
  - Live cost and a `$/h` burn rate on every running session, and a cost breakdown by model, cache use, and subagents in each transcript.
@@ -65,6 +73,33 @@ brew install claude-dashboard
65
73
  brew services start claude-dashboard # always-on, starts at login
66
74
  ```
67
75
 
76
+ ## Claude Code plugin
77
+
78
+ Install it from inside Claude Code:
79
+
80
+ ```
81
+ /plugin install mission-control --marketplace JonImmsWordpressDev/claude-dashboard
82
+ ```
83
+
84
+ On Claude Code older than 2.1.275, add the marketplace first: `/plugin marketplace add JonImmsWordpressDev/claude-dashboard`, then `/plugin install mission-control@jonimms`.
85
+
86
+ - `/mission-control:open` starts the dashboard if it isn't running (your global install, or npx) and opens it.
87
+ - `/mission-control:statusline` sets up the statusline below. It checks your installed version first and shows the setting to add.
88
+
89
+ ## Statusline
90
+
91
+ `claude-dashboard statusline` prints one line for Claude Code's status bar:
92
+
93
+ ```
94
+ ❯ acme-storefront needs you · Opus 5.5 · ctx 41% · 5h 64% → 3:40 PM · $3.20
95
+ ```
96
+
97
+ Model, context use, your 5-hour limit, and this session's cost come from Claude Code itself. When the dashboard is running, the line also names other sessions waiting on you and shows the limit forecast in amber. If the dashboard is down, you get the rest of the line and no delay. It needs a global install (npm or Homebrew), not npx. Add it to `~/.claude/settings.json`:
98
+
99
+ ```json
100
+ { "statusLine": { "type": "command", "command": "claude-dashboard statusline", "padding": 0 } }
101
+ ```
102
+
68
103
  ## Install (always-on)
69
104
 
70
105
  ```bash
@@ -121,15 +156,15 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
121
156
  - **Project cards** — sorted by last activity. Branch chip, `●n` uncommitted changes, `↑n` unpushed commits. The sparkline is prompts per day for the last two weeks. Each session row has an `open ⬈` button that resumes the session in your terminal (new window, right directory) or imports it into the Claude desktop app when it's installed — the **Open in** selector in the header names whichever terminal you've configured (auto-detected on first run). The `⧉` button copies the `claude --resume` command instead.
122
157
  - **Header meters** — your plan and rate-limit tier (Free/Pro/Max/Enterprise/API, detected from Claude Code's local account cache), live session and weekly usage from Anthropic's usage API via your existing Claude Code sign-in, plus extra-usage spend. Set a **weekly budget** in settings and a third meter tracks estimated cost (list-price value on a subscription) against it, with one alert each at 75 / 90 / 100%. First run may show one macOS Keychain prompt — click Always Allow.
123
158
 
124
- ![Mission Control: every live session tiled side by side, each pane following its own transcript, with the waiting reason at the top of each pane and the agent team under the middle one](docs/screenshots/mission-control.png)
159
+ ![Mission Control: every live session tiled side by side, each pane following its own transcript, with cost, burn rate, context meter and tool counts in each header, the waiting reason under it, and the agent team under the middle one](docs/screenshots/mission-control.png)
125
160
 
126
- ![Transcript viewer live-following a running session: the agent team at the top, then prompts, replies as markdown, and tool calls as one-liners](docs/screenshots/transcript.png)
161
+ ![Transcript viewer on a running session with the cost panel open: value per model by input, output and cache, the subagent share, cache hit rate, context and tool calls, then the agent team and the conversation](docs/screenshots/transcript.png)
127
162
 
128
163
  - **Transcripts** — click any session title (cards, digest, search results, project drawer) to read the conversation: your prompts, Claude's replies rendered as markdown, tool calls as compact one-liners, and away-summaries highlighted. Long sessions show the newest ~1200 events. **Running sessions follow live** — a `● live` badge appears, new turns stream in every few seconds, and the view sticks to the bottom unless you've scrolled up to read.
129
164
  - **Search** — the header box searches every prompt you've ever sent plus all session titles (Enter to run, 2+ characters). Narrow with `project:name` or `since:7d` / `since:2026-08-01`. Clicking a prompt result opens the transcript scrolled to the matching turn. If the answer isn't in a prompt, **search inside full transcripts** runs the same query across the conversation bodies — slower, so it's a second click rather than the default.
130
165
  - **Command palette** — `⌘K` from anywhere: fuzzy-jump to any project or session, watch a live session, start a new one, open settings or stats. Arrow keys + Enter.
131
166
  - **Stats** — click the weekly bar chart in the header: a 26-week activity heatmap, your busiest hours, a weekly-rhythm grid (prompts by day of week and hour), estimated cost per day for the last 90 days (list-price value on a subscription), and an all-time per-model token/cost breakdown. The daily cost history is computed from your existing transcripts, so it's full from the first run.
132
- - **Your week with Claude** — a 7-day report (`⌘K`, or the button at the top of stats): sessions, prompts, estimated cost (list-price value on a subscription) against your budget, busiest day and hour, models used, how long you typically take to answer a question, and your cache hit rate. Exports as markdown.
167
+ - **Your week with Claude** — a 7-day report (`⌘K`, or the button at the top of stats): sessions, prompts, estimated cost (list-price value on a subscription) against your budget, busiest day and hour, models used, how long you typically take to answer a question, and your cache hit rate. Exports as markdown, or as a share card: a 1200×630 PNG with your week's totals, daily bars, and, on Pro or Max, how many times the plan's weekly price that usage was worth at list price. Download it or copy it straight to the clipboard. The card shows totals only, never project names or prompts.
133
168
  - **Day timeline** — `⌘K` → Day timeline: the last 24 hours as one lane per project, with amber ticks where a session needed you.
134
169
  - **Catch-up** — the `🔔` in the header keeps every notification the server has fired since it started, with an unread count. Useful after a morning away from the desk.
135
170
  - **Export** — any transcript downloads as clean markdown via the `export ⇩` button.
@@ -143,10 +178,13 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
143
178
  - **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.
144
179
  - **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.
145
180
 
146
- ![Stats view: activity heatmap, busiest hours, weekly rhythm, 90-day spend, and per-model cost breakdowns](docs/screenshots/stats.png)
181
+ ![Stats view: activity heatmap, busiest hours, weekly rhythm, 90-day spend, per-model costs, the most expensive sessions, and models by project](docs/screenshots/stats.png)
147
182
 
148
183
  - **Project details** — click any project's name for a slide-over with its full session list, recent git commits, rendered CLAUDE.md, per-project memory files, skills/agents/commands from `.claude/`, and settings (permissions, MCP servers, allowed tools), plus the resolved **Toolset** below. Read-only; also a quick audit of which projects are missing instructions or memory. Esc closes.
149
184
  - **Toolset** — inside the project slide-over: what Claude Code would actually use in that project. Every installed plugin, every agent and skill from the project, your user dir, and enabled plugins, and every MCP server that applies, each marked on or off with where it came from and which settings scope decided it, resolved with Claude Code's own precedence (managed › local › project › user, deny beats allow, project agents shadow user agents shadow plugin agents). An amber list at the top flags things worth a look: a plugin enabled but not installed or gone stale, a deny rule naming an agent that no longer exists, a server that needs auth, a settings file that doesn't parse. The `⧉` on a row copies the exact snippet to flip it, aimed at `.claude/settings.local.json`; MCP toggles live in Claude's own registry, so that recipe tells you to run `/mcp` in a session instead (servers from a project's `.mcp.json` also honour the settings keys `enabledMcpjsonServers`, `disabledMcpjsonServers` and `enableAllProjectMcpServers`, and their recipes use those). Buttons at the foot reveal the project's settings files in Finder. Read-only — the dashboard never edits Claude configuration.
185
+
186
+ ![Toolset in the project slide-over: plugins, agents, skills and MCP servers marked on or off with their source and deciding settings scope, an amber warning for a plugin enabled but not installed, and buttons for the project's settings files](docs/screenshots/toolset.png)
187
+
150
188
  - **Plugins & MCP** — `⌘K` → Plugins & MCP (or the button in settings) lists every installed plugin with its marketplace, version, on/off state and a `stale` flag when its marketplace was refreshed after the plugin was last updated, plus every MCP server with its scope, the projects that use it, an `off` chip for servers you have disabled in a project, and an amber `needs auth` when Claude Code says so. Read-only; server secrets never reach the browser.
151
189
 
152
190
  ![Plugins & MCP inventory: plugins with on, off, stale and missing states; MCP servers with scope, projects, disabled flags and needs-auth](docs/screenshots/inventory.png)
@@ -160,6 +198,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
160
198
  - **Session cost breakdown** — the `≈$ ▾` button in a transcript's header opens a table by model (input, output, cache read, cache write), the subagent share, and the session's cache hit rate.
161
199
  - **Context and tools** — each live session shows how full its context window was on the last turn (`412k / 1M`, amber from 70%, red from 90%), so you can see a compaction or a fresh session coming. Mission Control panes add the session's tool-call count with failures in amber, and the transcript's cost panel shows the same count in its footer. Read from the same incremental transcript scan as everything else; subagents are not included.
162
200
  - **Most expensive sessions** — in the stats view: the ten costliest sessions all time, click to open.
201
+ - **Limit forecast** — the session and week meters project your current pace forward. When it runs out before the window resets, the meter shows an amber `→ 3:40 PM` (or `→ Thu 2:15 PM` for the week), and the tooltip says how early. Otherwise the tooltip shows where you will land by the reset. Pace is your last 30 minutes (5-hour window) or 24 hours (week) once the dashboard has watched that long, and the average since the window opened before that. One notification per window, once you are past 50% and on pace to run out.
163
202
  - **Stuck flag** — a session that's "busy" but has written nothing to its transcript for 10+ minutes gets an amber `quiet Nm` cell; at 20 minutes you get one notification. It's a hint, not a verdict — a session waiting on slow background work can look the same.
164
203
  - **Idle reminder** — a session left on **needs you** for an hour (configurable in settings, `0` to turn off) gets one more notification and a `still waiting` entry in the catch-up bell, and its elapsed cell turns amber with the wait time. Nothing repeats until you answer it.
165
204
 
@@ -4,5 +4,11 @@
4
4
  // Starts the server and opens the dashboard in the default browser.
5
5
  // Pass --no-open to skip the browser (services use `node server.js` directly
6
6
  // and never auto-open).
7
- if (!process.argv.includes('--no-open')) process.env.CLAUDE_DASH_OPEN = '1';
8
- require('../server.js');
7
+ // `claude-dashboard statusline` prints one line for Claude Code's statusLine
8
+ // setting instead of starting anything.
9
+ if (process.argv[2] === 'statusline') {
10
+ require('../lib/statusline').runStatusline();
11
+ } else {
12
+ if (!process.argv.includes('--no-open')) process.env.CLAUDE_DASH_OPEN = '1';
13
+ require('../server.js');
14
+ }
@@ -1,5 +1,5 @@
1
1
  [Unit]
2
- Description=Claude Dashboard — local dashboard for Claude Code sessions
2
+ Description=Claude Mission Control — local dashboard for Claude Code sessions
3
3
  After=default.target
4
4
 
5
5
  [Service]
package/install.ps1 CHANGED
@@ -1,4 +1,4 @@
1
- # Install Claude Dashboard on Windows (EXPERIMENTAL — testers wanted).
1
+ # Install Claude Mission Control on Windows (EXPERIMENTAL — testers wanted).
2
2
  # Registers a logon Scheduled Task that runs the server hidden.
3
3
  # Run from the repo folder in PowerShell: .\install.ps1
4
4
  $ErrorActionPreference = 'Stop'
package/install.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # Install the Claude Dashboard as an always-on service that starts at login:
2
+ # Install the Claude Mission Control as an always-on service that starts at login:
3
3
  # a LaunchAgent on macOS, a systemd user unit on Linux.
4
4
  set -euo pipefail
5
5
 
package/lib/collector.js CHANGED
@@ -14,9 +14,10 @@ const { isIgnored } = require('./ignore');
14
14
  const { worktreeRoot } = require('./paths');
15
15
  const { sendNotification, newlyWaiting, overdueWaits, isProjectMuted, trackWaits } = require('./notify');
16
16
  const { readConfig, readPricing } = require('./config');
17
- const { estimateCost, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
17
+ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
18
18
  const { readPlan } = require('./plan');
19
19
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
20
+ const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
20
21
 
21
22
  // A busy session whose transcript hasn't grown for this long may be stalled.
22
23
  const QUIET_FLAG_MS = 10 * 60 * 1000;
@@ -42,6 +43,8 @@ class Collector {
42
43
  this.clientCount = 0;
43
44
  this.timers = [];
44
45
  this.fingerprint = '';
46
+ this.usageSamples = { session: [], week: [] };
47
+ this.forecastAlerted = { session: null, week: null }; // resetsAt last alerted on
45
48
  }
46
49
 
47
50
  onChange(fn) {
@@ -182,6 +185,40 @@ class Collector {
182
185
  // Live OAuth usage first (Keychain-authed, macOS); statusline cache as
183
186
  // the fallback for other platforms or when the toggle is off.
184
187
  this.raw.quota = (await fetchOauthUsage()) || (await readQuota());
188
+ const q = this.raw.quota;
189
+ if (q && !q.stale) {
190
+ const now = Date.now();
191
+ this.usageSamples.session = recordSample(this.usageSamples.session, q.utilization, q.resetsAt, now, WINDOWS.session.lookbackMs * 2);
192
+ this.usageSamples.week = recordSample(this.usageSamples.week, q.weeklyUtilization, q.weeklyResetsAt, now, WINDOWS.week.lookbackMs * 2);
193
+ }
194
+ }
195
+
196
+ // Forecast both usage windows, and notify once per window when the
197
+ // current pace runs out before the reset.
198
+ limitForecast() {
199
+ const q = this.raw.quota;
200
+ if (!q || q.stale) return null;
201
+ const now = Date.now();
202
+ const out = {};
203
+ const windows = [
204
+ ['session', q.utilization, q.resetsAt, '5-hour'],
205
+ ['week', q.weeklyUtilization, q.weeklyResetsAt, 'weekly'],
206
+ ];
207
+ for (const [key, utilization, resetsAt, label] of windows) {
208
+ const f = forecastWindow({ ...WINDOWS[key], utilization, resetsAt, samples: this.usageSamples[key], now });
209
+ out[key] = f;
210
+ if (!shouldAlert(f, this.forecastAlerted[key])) continue;
211
+ this.forecastAlerted[key] = f.resetsAt;
212
+ const when = clockLabel(f.hitAt, now);
213
+ const early = durationLabel(f.resetsAt - f.hitAt);
214
+ this.logEvent('forecast', `${label[0].toUpperCase()}${label.slice(1)} limit`, `At this pace you hit it ${when}, ${early} before it resets (${Math.round(f.utilization)}% now)`);
215
+ sendNotification({
216
+ title: `Claude ${label} limit`,
217
+ body: `At this pace you hit your ${label} limit ${when}, ${early} before it resets. ${Math.round(f.utilization)}% used.`,
218
+ sound: 'Basso',
219
+ });
220
+ }
221
+ return out;
185
222
  }
186
223
 
187
224
  projectPaths() {
@@ -442,7 +479,9 @@ class Collector {
442
479
  }
443
480
  const budget = budgetLimit ? { limit: budgetLimit, spent: weeklyCost[7], level } : null;
444
481
 
445
- 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) };
482
+ const forecast = this.limitForecast();
483
+
484
+ const next = { quota: this.raw.quota, forecast, plan: readPlan(), theme: readConfig().theme || 'board', liveSessions, projects, weeklyCost, budget, pinned, events: this.events.slice(0, 50), errors: this.state.errors.slice(-5) };
446
485
  const fp = JSON.stringify(next);
447
486
  if (fp !== this.fingerprint) {
448
487
  this.fingerprint = fp;
@@ -622,6 +661,7 @@ class Collector {
622
661
  waits: weekWaits,
623
662
  typicalWait: typicalWait(weekWaits),
624
663
  cacheHitRatio: weekCacheAll ? Math.round((weekCacheRead / weekCacheAll) * 100) / 100 : null,
664
+ planValue: planValue(readPlan(), weekCost),
625
665
  },
626
666
  byModel,
627
667
  perProject: perProject.slice(0, 15),
package/lib/demo.js CHANGED
@@ -54,7 +54,11 @@ function demoState() {
54
54
  return {
55
55
  generatedAt: now,
56
56
  plan: { tier: 'Max 20x' },
57
- quota: { utilization: 34, weeklyUtilization: 52, resetsAt: '2pm', weeklyResetsAt: 'Wed 9am' },
57
+ quota: { utilization: 64, weeklyUtilization: 52, resetsAt: '2pm', weeklyResetsAt: 'Wed 9am' },
58
+ forecast: {
59
+ session: { hitAt: now + 70 * MIN, projected: 128, resetsAt: now + 3 * HOUR, utilization: 64, basis: 'recent' },
60
+ week: { hitAt: null, projected: 81, resetsAt: now + 3 * 24 * HOUR, utilization: 52, basis: 'average' },
61
+ },
58
62
  weeklyCost: [31, 48, 22, 75, 61, 90, 84, 143],
59
63
  budget: { limit: 200, spent: 143, level: 75 },
60
64
  pinned: [
@@ -219,6 +223,7 @@ function demoStats() {
219
223
  busiestDay: 'Tuesday', busiestHour: 11,
220
224
  waits: [22, 31, 18, 4], typicalWait: 'under 2m',
221
225
  cacheHitRatio: 0.91,
226
+ planValue: { label: 'Max 20x', weeklyPrice: 46.15, multiple: 3.1 },
222
227
  },
223
228
  byModel: {
224
229
  'claude-opus-5': { tokens: 310e6, cost: 412.5 },
@@ -283,10 +288,30 @@ const DEMO_USAGE = {
283
288
  tools: { total: 388, errors: 0, byName: [{ name: 'Edit', count: 141, errors: 0 }, { name: 'Read', count: 122, errors: 0 }, { name: 'Bash', count: 79, errors: 0 }, { name: 'Grep', count: 46, errors: 0 }] },
284
289
  };
285
290
 
291
+ // Per-session usage, so a transcript's cost badge matches its row on the
292
+ // board (S1 Acme $24.60, S4 Data Pipeline $3.40); everything else gets the
293
+ // Blog Engine block.
294
+ const DEMO_USAGE_BY_ID = {
295
+ [S1]: {
296
+ cost: 24.6, tokens: 6_200_000, cacheHitRatio: 0.91,
297
+ byModel: [{ model: 'claude-opus-5', input: 140_000, output: 210_000, cacheRead: 5_400_000, cacheCreation: 450_000, cost: 24.6 }],
298
+ subagents: null, assumedModels: [],
299
+ context: { tokens: 412000, window: 1000000, pct: 41 },
300
+ tools: { total: 142, errors: 3, byName: [{ name: 'Read', count: 58, errors: 0 }, { name: 'Edit', count: 41, errors: 1 }, { name: 'Bash', count: 29, errors: 2 }, { name: 'Grep', count: 14, errors: 0 }] },
301
+ },
302
+ [S4]: {
303
+ cost: 3.4, tokens: 2_100_000, cacheHitRatio: 0.88,
304
+ byModel: [{ model: 'claude-sonnet-5', input: 60_000, output: 70_000, cacheRead: 1_850_000, cacheCreation: 120_000, cost: 3.4 }],
305
+ subagents: null, assumedModels: [],
306
+ context: { tokens: 186000, window: 1000000, pct: 19 },
307
+ tools: { total: 37, errors: 1, byName: [{ name: 'Bash', count: 21, errors: 1 }, { name: 'Read', count: 12, errors: 0 }, { name: 'Edit', count: 4, errors: 0 }] },
308
+ },
309
+ };
310
+
286
311
  // The usage block rides only on the initial load, like the real endpoint.
287
312
  function demoSession(id, after) {
288
313
  const t = demoSessionInner(id, after);
289
- return after ? t : { ...t, usage: DEMO_USAGE };
314
+ return after ? t : { ...t, usage: DEMO_USAGE_BY_ID[id] || DEMO_USAGE };
290
315
  }
291
316
 
292
317
  function blogSession(now, after) {
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ // Limit forecast: project the 5-hour and weekly usage meters forward and
3
+ // say when they will hit 100%, if that comes before the window resets.
4
+ //
5
+ // Pace comes from recent samples when we have enough history, otherwise
6
+ // from the average since the window opened (resetsAt - window length) —
7
+ // so a fresh server start still has a forecast.
8
+
9
+ const WINDOWS = {
10
+ session: { windowMs: 5 * 3600_000, lookbackMs: 30 * 60_000, minElapsedMs: 15 * 60_000 },
11
+ week: { windowMs: 7 * 24 * 3600_000, lookbackMs: 24 * 3600_000, minElapsedMs: 6 * 3600_000 },
12
+ };
13
+ const SAMPLE_GAP_MS = 5 * 60_000; // the usage API is polled at most every 5 min
14
+ const ROUND_MS = 5 * 60_000; // keep hitAt stable between polls
15
+ const ALERT_MIN_PCT = 50; // don't alarm on early, noisy extrapolation
16
+
17
+ function toMs(v) {
18
+ if (v == null || v === '') return null;
19
+ if (typeof v === 'number') return v > 1e12 ? v : v * 1000;
20
+ const n = Number(v);
21
+ if (Number.isFinite(n)) return n > 1e12 ? n : n * 1000;
22
+ const t = Date.parse(v);
23
+ return Number.isFinite(t) ? t : null;
24
+ }
25
+
26
+ // Returns a new samples array with this reading added. A changed resetsAt
27
+ // or a drop in utilization means a new window: start over.
28
+ function recordSample(samples, utilization, resetsAt, now, keepMs) {
29
+ if (utilization == null) return samples;
30
+ const reset = toMs(resetsAt);
31
+ let out = samples.filter((s) => s.resetsAt === reset && now - s.at <= keepMs);
32
+ const last = out[out.length - 1];
33
+ if (last && utilization < last.utilization) out = [];
34
+ else if (last && now - last.at < SAMPLE_GAP_MS) return out;
35
+ out.push({ at: now, utilization, resetsAt: reset });
36
+ return out;
37
+ }
38
+
39
+ // { hitAt, projected, resetsAt, utilization, basis } or null when there is
40
+ // nothing sensible to say. hitAt is null when the window resets first.
41
+ function forecastWindow({ utilization, resetsAt, windowMs, lookbackMs, minElapsedMs, samples = [], now = Date.now() }) {
42
+ const reset = toMs(resetsAt);
43
+ if (utilization == null || !reset || reset <= now) return null;
44
+
45
+ let rate = null; // percentage points per ms
46
+ let basis = 'recent';
47
+ const mine = samples.filter((s) => s.resetsAt === reset && s.at < now);
48
+ let anchor = null;
49
+ for (const s of mine) if (s.at <= now - lookbackMs) anchor = s;
50
+ if (!anchor && mine.length && now - mine[0].at >= lookbackMs / 2) anchor = mine[0];
51
+ if (anchor) rate = Math.max(0, (utilization - anchor.utilization) / (now - anchor.at));
52
+
53
+ if (rate == null) {
54
+ const elapsed = now - (reset - windowMs);
55
+ if (elapsed < minElapsedMs) return null;
56
+ rate = utilization / elapsed;
57
+ basis = 'average';
58
+ }
59
+
60
+ const projected = Math.min(999, Math.round(utilization + rate * (reset - now)));
61
+ let hitAt = null;
62
+ if (utilization >= 100) hitAt = Math.floor(now / ROUND_MS) * ROUND_MS;
63
+ else if (rate > 0) {
64
+ const t = now + (100 - utilization) / rate;
65
+ if (t < reset) hitAt = Math.floor(t / ROUND_MS) * ROUND_MS;
66
+ }
67
+ return { hitAt, projected, resetsAt: reset, utilization, basis };
68
+ }
69
+
70
+ // True when this forecast deserves a notification we haven't sent yet for
71
+ // this window. alertedFor is the resetsAt we last alerted on.
72
+ function shouldAlert(f, alertedFor) {
73
+ return !!(f && f.hitAt && f.utilization < 100 && f.utilization >= ALERT_MIN_PCT && f.resetsAt !== alertedFor);
74
+ }
75
+
76
+ // "around 3:40 PM" today, "Thu around 2:15 PM" on another day.
77
+ function clockLabel(ms, now = Date.now()) {
78
+ const d = new Date(ms);
79
+ const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
80
+ if (d.toDateString() === new Date(now).toDateString()) return `around ${time}`;
81
+ return `${d.toLocaleDateString(undefined, { weekday: 'short' })} around ${time}`;
82
+ }
83
+
84
+ // "35m", "1h 10m", "2d 4h".
85
+ function durationLabel(ms) {
86
+ const m = Math.max(0, Math.round(ms / 60_000));
87
+ if (m < 60) return `${m}m`;
88
+ const h = Math.floor(m / 60);
89
+ if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
90
+ return h % 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : `${Math.floor(h / 24)}d`;
91
+ }
92
+
93
+ module.exports = { WINDOWS, toMs, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel };
package/lib/notify.js CHANGED
@@ -31,7 +31,7 @@ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([
31
31
  $t = $xml.GetElementsByTagName('text')
32
32
  $null = $t.Item(0).AppendChild($xml.CreateTextNode('${esc(title)}'))
33
33
  $null = $t.Item(1).AppendChild($xml.CreateTextNode('${esc(body)}'))
34
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Dashboard').Show([Windows.UI.Notifications.ToastNotification]::new($xml))`;
34
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Mission Control').Show([Windows.UI.Notifications.ToastNotification]::new($xml))`;
35
35
  execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 8000 }, () => {});
36
36
  } else {
37
37
  execFile('notify-send', [title, body], { timeout: 5000 }, () => {});
package/lib/pricing.js CHANGED
@@ -208,4 +208,22 @@ function contextWindow(model) {
208
208
  return MILLION_WINDOW.some((p) => id.startsWith(p)) ? 1_000_000 : 200_000;
209
209
  }
210
210
 
211
- module.exports = { estimateCost, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, cacheHitRatio, topByCost, contextWindow };
211
+ // Subscription list prices (USD/month) for the share card's "worth N× your
212
+ // plan" line. Team and Enterprise vary by contract, so they get no multiple.
213
+ const PLAN_MONTHLY = { Pro: 20, 'Max 5x': 100, 'Max 20x': 200 };
214
+
215
+ // { label, weeklyPrice, multiple } on a known subscription, label only on an
216
+ // unknown one, null on API billing or no plan.
217
+ function planValue(plan, weekCost) {
218
+ if (!plan || !plan.label || plan.billing === 'api' || plan.label === 'API') return null;
219
+ const monthly = PLAN_MONTHLY[plan.label];
220
+ if (!monthly) return { label: plan.label, weeklyPrice: null, multiple: null };
221
+ const weekly = (monthly * 12) / 52;
222
+ return {
223
+ label: plan.label,
224
+ weeklyPrice: Math.round(weekly * 100) / 100,
225
+ multiple: weekCost > 0 ? Math.round((weekCost / weekly) * 10) / 10 : null,
226
+ };
227
+ }
228
+
229
+ module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, cacheHitRatio, topByCost, contextWindow };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ // `claude-dashboard statusline`: one line for Claude Code's statusLine
3
+ // setting. Reads Claude Code's JSON on stdin and, when the dashboard is up,
4
+ // adds what only it knows: other sessions waiting on you and the limit
5
+ // forecast. Must stay fast, so the dashboard gets a short timeout and is
6
+ // optional.
7
+ const http = require('http');
8
+
9
+ const AMBER = '\x1b[33m';
10
+ const DIM = '\x1b[2m';
11
+ const RESET = '\x1b[0m';
12
+ const STATE_TIMEOUT_MS = 400;
13
+
14
+ function clock(ms) {
15
+ return new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
16
+ }
17
+
18
+ // input: Claude Code's statusline JSON. state: the dashboard's /api/state,
19
+ // or null when it isn't running.
20
+ function formatStatusline(input, state, { color = true } = {}) {
21
+ const amber = (s) => (color ? AMBER + s + RESET : s);
22
+ const dim = (s) => (color ? DIM + s + RESET : s);
23
+ const parts = [];
24
+
25
+ if (state) {
26
+ const waiting = (state.liveSessions || []).filter((s) => s.status === 'waiting' && s.sessionId !== input.session_id);
27
+ if (waiting.length) {
28
+ const first = waiting[0].projectName || 'a session';
29
+ parts.push(amber(`❯ ${waiting.length === 1 ? first : `${waiting.length} sessions`} need${waiting.length === 1 ? 's' : ''} you`));
30
+ }
31
+ }
32
+
33
+ if (input.model && input.model.display_name) parts.push(input.model.display_name);
34
+
35
+ const ctx = input.context_window && input.context_window.used_percentage;
36
+ if (typeof ctx === 'number') {
37
+ const s = `ctx ${Math.round(ctx)}%`;
38
+ parts.push(ctx >= 70 ? amber(s) : s);
39
+ }
40
+
41
+ const limits = input.rate_limits || {};
42
+ const forecast = (state && state.forecast) || {};
43
+ for (const [key, label, f] of [['five_hour', '5h', forecast.session], ['seven_day', 'week', forecast.week]]) {
44
+ const pct = limits[key] && limits[key].used_percentage;
45
+ if (typeof pct !== 'number') continue;
46
+ if (f && f.hitAt && pct < 100) parts.push(amber(`${label} ${Math.round(pct)}% → ${clock(f.hitAt)}`));
47
+ else if (key === 'five_hour' || pct >= 50) parts.push(`${label} ${Math.round(pct)}%`);
48
+ }
49
+
50
+ const cost = input.cost && input.cost.total_cost_usd;
51
+ if (typeof cost === 'number' && cost >= 0.01) parts.push(dim(`$${cost.toFixed(2)}`));
52
+
53
+ return parts.join(dim(' · '));
54
+ }
55
+
56
+ function fetchState(port) {
57
+ return new Promise((resolve) => {
58
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/state', timeout: STATE_TIMEOUT_MS }, (res) => {
59
+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
60
+ let body = '';
61
+ res.setEncoding('utf8');
62
+ res.on('data', (d) => (body += d));
63
+ res.on('end', () => {
64
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
65
+ });
66
+ });
67
+ req.on('timeout', () => req.destroy());
68
+ req.on('error', () => resolve(null));
69
+ });
70
+ }
71
+
72
+ async function runStatusline() {
73
+ let raw = '';
74
+ process.stdin.setEncoding('utf8');
75
+ for await (const chunk of process.stdin) raw += chunk;
76
+ let input = {};
77
+ try { input = JSON.parse(raw); } catch { /* print what we can */ }
78
+ const state = await fetchState(Number(process.env.CLAUDE_DASH_PORT) || 4517);
79
+ process.stdout.write(formatStatusline(input, state) + '\n');
80
+ }
81
+
82
+ module.exports = { formatStatusline, runStatusline };
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
- # SwiftBar plugin for the Claude Dashboard. Refreshes every 15s (filename).
3
- # <swiftbar.title>Claude Dashboard</swiftbar.title>
2
+ # SwiftBar plugin for the Claude Mission Control. Refreshes every 15s (filename).
3
+ # <swiftbar.title>Claude Mission Control</swiftbar.title>
4
4
  # <swiftbar.hideAbout>true</swiftbar.hideAbout>
5
5
  # <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>
6
6
  # <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.11.0",
3
+ "version": "1.12.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
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Claude Dashboard</title>
6
+ <title>Claude Mission Control</title>
7
7
  <meta name="mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
9
  <link rel="manifest" href="/manifest.webmanifest">
@@ -241,6 +241,7 @@
241
241
  .meter .fill { height: 100%; border-radius: 3px; background: var(--accent); }
242
242
  .meter .fill.hot { background: var(--warn); }
243
243
  .meter .fill.crit { background: var(--bad); }
244
+ .meter .eta { color: var(--warn); white-space: nowrap; }
244
245
 
245
246
  /* ---------- live rail: the departures rows ---------- */
246
247
  #live {
@@ -603,10 +604,12 @@
603
604
  }
604
605
  .mc-pane.waiting { box-shadow: inset 0 2px 0 var(--warn); }
605
606
  .mc-pane-head {
606
- display: flex; align-items: center; gap: 10px;
607
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px;
607
608
  padding: 8px 12px; border-bottom: 1px solid var(--line); flex: none;
608
609
  min-width: 0;
609
610
  }
611
+ .mc-pane-head .mc-proj { min-width: 0; flex: 0 1 auto; }
612
+ .mc-stats { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; }
610
613
  .mc-pane-head .mc-proj {
611
614
  font-family: var(--disp-font);
612
615
  text-transform: uppercase; letter-spacing: 0.05em;
@@ -984,15 +987,36 @@ function weeklyChart(weeks) {
984
987
  aria-label="${esc(costWording().noun)} per week, last 8 weeks">${bars}</svg>`;
985
988
  }
986
989
 
987
- function meter(label, pct, title) {
990
+ function meter(label, pct, title, extra = '') {
988
991
  const cls = pct >= 90 ? 'crit' : pct >= 75 ? 'hot' : '';
989
992
  return `<div class="meter" title="${esc(title || '')}">
990
993
  <span>${esc(label)}</span>
991
994
  <span class="bar"><span class="fill ${cls}" style="width:${Math.min(100, pct)}%"></span></span>
992
- <span>${Math.round(pct)}%</span>
995
+ <span>${Math.round(pct)}%</span>${extra}
993
996
  </div>`;
994
997
  }
995
998
 
999
+ // Limit forecast for a usage meter: an amber "→ 3:40 PM" when the current
1000
+ // pace runs out before the reset, and the projection in the tooltip.
1001
+ function forecastMeter(label, pct, resetsRaw, f) {
1002
+ const sameDay = (ms) => new Date(ms).toDateString() === new Date().toDateString();
1003
+ const when = (ms) => (sameDay(ms) ? '' : new Date(ms).toLocaleDateString(undefined, { weekday: 'short' }) + ' ') + fmtWhen(ms);
1004
+ const parsed = Date.parse(resetsRaw);
1005
+ const resetAt = resetsRaw ? (Number.isFinite(parsed) ? when(parsed) : resetsRaw) : '';
1006
+ const resets = resetAt ? 'resets ' + resetAt : '';
1007
+ if (!f) return meter(label, pct, resets);
1008
+ const pace = f.basis === 'recent' ? 'at your recent pace' : 'at your average pace this window';
1009
+ if (!f.hitAt) {
1010
+ return meter(label, pct, `${resets} · ${pace}, about ${f.projected}% by then`);
1011
+ }
1012
+ const early = Math.max(0, Math.round((f.resetsAt - f.hitAt) / 60000));
1013
+ const earlyTxt = early >= 1440 ? `${Math.floor(early / 1440)}d ${Math.floor((early % 1440) / 60)}h` : early >= 60 ? `${Math.floor(early / 60)}h ${early % 60}m` : `${early}m`;
1014
+ const title = f.utilization >= 100
1015
+ ? `limit reached · ${resets}`
1016
+ : `${pace}, you hit 100% around ${when(f.hitAt)}, ${earlyTxt} before the reset${resetAt ? ' at ' + resetAt : ''}`;
1017
+ return meter(label, pct, title, `<span class="eta">→ ${esc(when(f.hitAt))}</span>`);
1018
+ }
1019
+
996
1020
  // One display line for what a waiting session needs: glyph + 80-char text,
997
1021
  // plus the untruncated version for tooltips.
998
1022
  function reasonLine(r) {
@@ -1333,8 +1357,8 @@ function render(state) {
1333
1357
  if (q) {
1334
1358
  qEl.className = q.stale ? 'stale' : '';
1335
1359
  patch(qEl, spendChip +
1336
- (q.utilization != null ? meter('session', q.utilization, q.resetsAt ? 'resets ' + q.resetsAt : '') : '') +
1337
- (q.weeklyUtilization != null ? meter('week', q.weeklyUtilization, q.weeklyResetsAt ? 'resets ' + q.weeklyResetsAt : '') : '') +
1360
+ (q.utilization != null ? forecastMeter('session', q.utilization, q.resetsAt, state.forecast && state.forecast.session) : '') +
1361
+ (q.weeklyUtilization != null ? forecastMeter('week', q.weeklyUtilization, q.weeklyResetsAt, state.forecast && state.forecast.week) : '') +
1338
1362
  (q.costUsed != null && q.costLimit ? `<span title="extra usage">$${q.costUsed.toFixed(0)}/$${q.costLimit.toFixed(0)}</span>` : ''));
1339
1363
  } else {
1340
1364
  qEl.className = '';
@@ -1365,7 +1389,7 @@ function render(state) {
1365
1389
  patch($('#grid'), active.length || dormant.length
1366
1390
  ? active.map(projectCard).join('')
1367
1391
  : `<div id="welcome">
1368
- <h2><span class="tilde">❯</span> Welcome to Claude Dashboard</h2>
1392
+ <h2><span class="tilde">❯</span> Welcome to Claude Mission Control</h2>
1369
1393
  <p>No Claude Code projects found yet. Run <code>claude</code> in any folder and it appears here automatically — live status, transcripts, git state, costs, the lot.</p>
1370
1394
  <p>Tip: press <code>⌘K</code> to jump anywhere, <code>/</code> to search, and click ⚙ for settings.</p>
1371
1395
  </div>`);
@@ -1382,9 +1406,9 @@ function render(state) {
1382
1406
  : 'Settings';
1383
1407
 
1384
1408
  const waiting = live.filter((s) => s.status === 'waiting').length;
1385
- document.title = waiting ? `(${waiting} waiting) Claude Dashboard`
1386
- : live.length ? `(${live.length} running) Claude Dashboard`
1387
- : 'Claude Dashboard';
1409
+ document.title = waiting ? `(${waiting} waiting) Claude Mission Control`
1410
+ : live.length ? `(${live.length} running) Claude Mission Control`
1411
+ : 'Claude Mission Control';
1388
1412
  }
1389
1413
 
1390
1414
  function toast(msg) {
@@ -2056,9 +2080,13 @@ function mcPaneHead(s) {
2056
2080
  ? `<span class="mc-meta" title="subagent transcripts · their tokens">⑂ ${s.subagents.count}${
2057
2081
  s.subagents.mtok ? ` · ${s.subagents.mtok}M tok` : ''}</span>`
2058
2082
  : '';
2083
+ // Two rows: identity and status on top, the numbers underneath, so a
2084
+ // narrow pane never pushes its status badge into the next one.
2085
+ const stats = `${s.estCost != null ? costChip(s.estCost, { cls: 'mc-meta', assumed: s.assumed, after: s.burnRate ? ` · $${s.burnRate}/h` : '' }) : ''}${contextMeter(s.context)}${s.tools ? `<span class="mc-meta" title="tool calls in this session">${s.tools.total} tools${s.tools.errors ? ` · <span class="failed">${s.tools.errors} failed</span>` : ''}</span>` : ''}${subs}`;
2059
2086
  return `<span class="mc-proj" data-session="${esc(s.sessionId)}" title="Open the full transcript">${esc(s.projectName)}</span>
2060
- ${modelChip(s.model)}${s.estCost != null ? costChip(s.estCost, { cls: 'mc-meta', assumed: s.assumed, after: s.burnRate ? ` · $${s.burnRate}/h` : '' }) : ''}${subs}${contextMeter(s.context)}${s.tools ? `<span class="mc-meta" title="tool calls in this session">${s.tools.total} tools${s.tools.errors ? ` · <span class="failed">${s.tools.errors} failed</span>` : ''}</span>` : ''}<span class="spacer"></span>
2061
- <span class="mc-meta${s.waitingMin != null ? ' overdue' : ''}">${s.waitingMin != null ? waitLabel(s.waitingMin) : elapsed(s.startedAt)}</span>${statusFlap(s, 'mc:')}`;
2087
+ ${modelChip(s.model)}<span class="spacer"></span>
2088
+ <span class="mc-meta${s.waitingMin != null ? ' overdue' : ''}">${s.waitingMin != null ? waitLabel(s.waitingMin) : elapsed(s.startedAt)}</span>${statusFlap(s, 'mc:')}${
2089
+ stats ? `<span class="mc-stats">${stats}</span>` : ''}`;
2062
2090
  }
2063
2091
 
2064
2092
  const AGENT_COLORS = { blue: '#4a90e2', green: 'var(--accent)', red: 'var(--bad)', yellow: 'var(--warn)', purple: '#a86ee0', cyan: '#2fb8c9', orange: '#e08a2f', pink: '#e06ea8', gray: 'var(--faint)', grey: 'var(--faint)' };
@@ -2897,7 +2925,7 @@ async function openWeekReport() {
2897
2925
  ['Cache hit rate', w.cacheHitRatio != null ? `${Math.round(w.cacheHitRatio * 100)}% of input tokens served from cache` : 'not enough data yet'],
2898
2926
  ['Your typical reply time', w.typicalWait ? `${w.typicalWait} (${waitTotal} prompts measured)` : 'not enough data yet'],
2899
2927
  ];
2900
- weekReport = { w, b, rows };
2928
+ weekReport = { w, b, rows, days: (s.costDays || []).slice(-7) };
2901
2929
  const table = `<table class="stat-table">${rows.map(([k, v]) =>
2902
2930
  `<tr><td>${esc(k)}</td><td style="text-align:right">${esc(v)}</td></tr>`).join('')}</table>`;
2903
2931
  const projRows = (w.projects || []).map((p) =>
@@ -2905,12 +2933,119 @@ async function openWeekReport() {
2905
2933
  $('#d-body').innerHTML =
2906
2934
  dSection('The week', table) +
2907
2935
  dSection('Where it went', projRows ? `<table class="stat-table">${projRows}</table>` : null, 'No spend recorded this week.') +
2936
+ dSection('Share card', '<img id="share-card" alt="Your week with Claude as an image" style="width:100%;border-radius:6px;border:1px solid var(--line)">' +
2937
+ '<div class="set-sub" style="margin-top:6px">Totals only. No project names or prompts.</div>' +
2938
+ '<div style="margin-top:10px;display:flex;gap:8px"><button class="copy" data-card-download>download PNG ⇩</button><button class="copy" data-card-copy>copy image</button></div>') +
2908
2939
  '<div style="margin-top:16px"><button class="copy" data-week-export>download as markdown ⇩</button></div>';
2940
+ renderShareCard().then((url) => { const img = $('#share-card'); if (img) img.src = url; }).catch(() => {});
2909
2941
  } catch {
2910
2942
  $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load the report.</div>';
2911
2943
  }
2912
2944
  }
2913
2945
 
2946
+ // ---------- share card: the week as a 1200×630 PNG ----------
2947
+ // Always drawn in the dark departures palette so shared cards look alike.
2948
+ const CARD = { w: 1200, h: 630, bg: '#0d1215', flap: '#151d22', flapHi: '#1c262c', split: 'rgba(0,0,0,0.45)', ink: '#e9e4d6', muted: '#93a09e', faint: '#5d6b6b', line: '#202b31', green: '#46c073', amber: '#ffb020' };
2949
+ let shareCanvas = null;
2950
+
2951
+ async function renderShareCard() {
2952
+ const { w, days } = weekReport;
2953
+ await Promise.all(['700 140px Oswald', '500 20px "JetBrains Mono"'].map((f) => document.fonts.load(f).catch(() => {})));
2954
+ const c = document.createElement('canvas');
2955
+ c.width = CARD.w * 2;
2956
+ c.height = CARD.h * 2;
2957
+ const g = c.getContext('2d');
2958
+ g.scale(2, 2);
2959
+ const oswald = (px, wt = 600) => `${wt} ${px}px Oswald, "Arial Narrow", sans-serif`;
2960
+ const mono = (px, wt = 500) => `${wt} ${px}px "JetBrains Mono", ui-monospace, monospace`;
2961
+ const text = (s, x, y, font, color, align = 'left', spacing = 0) => {
2962
+ g.font = font; g.fillStyle = color; g.textAlign = align;
2963
+ if ('letterSpacing' in g) g.letterSpacing = `${spacing}px`;
2964
+ g.fillText(s, x, y);
2965
+ if ('letterSpacing' in g) g.letterSpacing = '0px';
2966
+ };
2967
+ const flap = (x, y, fw, fh) => {
2968
+ g.fillStyle = CARD.flap; g.beginPath(); g.roundRect(x, y, fw, fh, 6); g.fill();
2969
+ g.fillStyle = CARD.flapHi; g.beginPath(); g.roundRect(x, y, fw, fh / 2, [6, 6, 0, 0]); g.fill();
2970
+ g.fillStyle = CARD.split; g.fillRect(x, y + fh / 2 - 0.5, fw, 1.5);
2971
+ };
2972
+
2973
+ g.fillStyle = CARD.bg; g.fillRect(0, 0, CARD.w, CARD.h);
2974
+
2975
+ // header
2976
+ const end = new Date();
2977
+ const start = new Date(end.getTime() - 6 * 86400000);
2978
+ const md = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
2979
+ text('❯ MY WEEK WITH CLAUDE CODE', 64, 92, oswald(30), CARD.amber, 'left', 2);
2980
+ text(`${md(start)} – ${md(end)}, ${end.getFullYear()}`, CARD.w - 64, 90, mono(18), CARD.muted, 'right');
2981
+ g.fillStyle = CARD.line; g.fillRect(64, 116, CARD.w - 128, 2);
2982
+
2983
+ // the number
2984
+ const cost = w.cost || 0;
2985
+ const money = cost >= 10 ? Math.round(cost).toLocaleString() : cost.toFixed(2);
2986
+ const pv = w.planValue;
2987
+ text(pv ? 'LIST-PRICE VALUE' : 'ESTIMATED COST', 64, 172, mono(16), CARD.muted, 'left', 2);
2988
+ text(`≈$${money}`, 58, 312, oswald(150, 700), CARD.ink);
2989
+ if (pv && pv.multiple >= 1.1) text(`${pv.multiple}× the ${pv.label} plan's weekly price`, 64, 358, mono(22), CARD.amber);
2990
+ else if (pv) text(`on the ${pv.label} plan`, 64, 358, mono(22), CARD.muted);
2991
+
2992
+ // daily bars
2993
+ const bx = 700, by = 150, bw = 436, bh = 200;
2994
+ const max = Math.max(...days.map((d) => d.cost), 0.01);
2995
+ const slot = bw / 7;
2996
+ const busiest = days.reduce((i, d, j) => (d.cost > days[i].cost ? j : i), 0);
2997
+ days.forEach((d, i) => {
2998
+ const x = bx + i * slot + 8;
2999
+ const colW = slot - 16;
3000
+ flap(x, by, colW, bh);
3001
+ const fh = d.cost > 0 ? Math.max(4, (d.cost / max) * (bh - 8)) : 0;
3002
+ g.fillStyle = i === busiest && d.cost > 0 ? CARD.amber : CARD.green;
3003
+ g.beginPath(); g.roundRect(x + 4, by + bh - 4 - fh, colW - 8, fh, 3); g.fill();
3004
+ text(new Date(d.t).toLocaleDateString(undefined, { weekday: 'narrow' }), x + colW / 2, by + bh + 28, mono(16), CARD.muted, 'center');
3005
+ });
3006
+
3007
+ // stat flaps
3008
+ const stats = [
3009
+ ['SESSIONS', String(w.sessions ?? 0)],
3010
+ ['PROMPTS', String(w.prompts ?? 0)],
3011
+ ['CACHE HITS', w.cacheHitRatio != null ? `${Math.round(w.cacheHitRatio * 100)}%` : '—'],
3012
+ ['BUSIEST', w.busiestDay ? w.busiestDay.slice(0, 3) + (w.busiestHour != null ? ` ${w.busiestHour}:00` : '') : '—'],
3013
+ ];
3014
+ const sw = (CARD.w - 128 - 3 * 20) / 4;
3015
+ stats.forEach(([label, value], i) => {
3016
+ const x = 64 + i * (sw + 20);
3017
+ flap(x, 410, sw, 110);
3018
+ text(value, x + 22, 476, oswald(46, 600), CARD.ink);
3019
+ text(label, x + 22, 506, mono(13), CARD.muted, 'left', 2);
3020
+ });
3021
+
3022
+ // footer
3023
+ text((w.models || []).map(modelShort).join(' · '), 64, 578, mono(16), CARD.faint);
3024
+ text('npx claude-mission-control', CARD.w - 64, 578, mono(16), CARD.muted, 'right');
3025
+
3026
+ shareCanvas = c;
3027
+ return c.toDataURL('image/png');
3028
+ }
3029
+
3030
+ document.addEventListener('click', async (e) => {
3031
+ if (!shareCanvas) return;
3032
+ if (e.target.closest('[data-card-download]')) {
3033
+ const a = document.createElement('a');
3034
+ a.href = shareCanvas.toDataURL('image/png');
3035
+ a.download = `week-with-claude-${new Date().toISOString().slice(0, 10)}.png`;
3036
+ a.click();
3037
+ toast('Card downloaded');
3038
+ } else if (e.target.closest('[data-card-copy]')) {
3039
+ try {
3040
+ const blob = await new Promise((r) => shareCanvas.toBlob(r, 'image/png'));
3041
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
3042
+ toast('Card copied, paste it anywhere');
3043
+ } catch {
3044
+ toast('Copy not supported here, use download');
3045
+ }
3046
+ }
3047
+ });
3048
+
2914
3049
  document.addEventListener('click', (e) => {
2915
3050
  if (e.target.closest('[data-week]')) { openWeekReport(); return; }
2916
3051
  if (!e.target.closest('[data-week-export]') || !weekReport) return;
@@ -2936,7 +3071,7 @@ document.addEventListener('click', async (e) => {
2936
3071
  try {
2937
3072
  const r = await fetch(`/api/session?id=${encodeURIComponent(exportableSession.id)}`);
2938
3073
  const t = await r.json();
2939
- const lines = [`# ${t.title}`, '', `_${t.projectName} — exported from Claude Dashboard, ${new Date().toLocaleString()}_`, ''];
3074
+ const lines = [`# ${t.title}`, '', `_${t.projectName} — exported from Claude Mission Control, ${new Date().toLocaleString()}_`, ''];
2940
3075
  for (const ev of t.events) {
2941
3076
  if (ev.kind === 'user') lines.push(`**You:** ${ev.text}`, '');
2942
3077
  else if (ev.kind === 'assistant') lines.push(ev.text, '');
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "Claude Dashboard",
3
- "short_name": "Claude Dash",
2
+ "name": "Claude Mission Control",
3
+ "short_name": "Mission Control",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status.",
5
5
  "start_url": "/",
6
6
  "display": "standalone",
package/uninstall.ps1 CHANGED
@@ -1,4 +1,4 @@
1
- # Remove the Claude Dashboard Scheduled Task (Windows).
1
+ # Remove the Claude Mission Control Scheduled Task (Windows).
2
2
  schtasks /End /TN 'ClaudeDashboard' 2>$null | Out-Null
3
3
  schtasks /Delete /TN 'ClaudeDashboard' /F 2>$null | Out-Null
4
4
  Get-Process -Name node -ErrorAction SilentlyContinue |
package/uninstall.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # Remove the Claude Dashboard service (LaunchAgent on macOS, systemd user unit on Linux).
2
+ # Remove the Claude Mission Control service (LaunchAgent on macOS, systemd user unit on Linux).
3
3
  set -euo pipefail
4
4
 
5
5
  LABEL="com.claude-dashboard"