claude-mission-control 1.11.1 → 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 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
+ ![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
@@ -129,7 +164,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
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.
@@ -163,6 +198,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
163
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.
164
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.
165
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.
166
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.
167
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.
168
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 },
@@ -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.1",
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 {
@@ -986,15 +987,36 @@ function weeklyChart(weeks) {
986
987
  aria-label="${esc(costWording().noun)} per week, last 8 weeks">${bars}</svg>`;
987
988
  }
988
989
 
989
- function meter(label, pct, title) {
990
+ function meter(label, pct, title, extra = '') {
990
991
  const cls = pct >= 90 ? 'crit' : pct >= 75 ? 'hot' : '';
991
992
  return `<div class="meter" title="${esc(title || '')}">
992
993
  <span>${esc(label)}</span>
993
994
  <span class="bar"><span class="fill ${cls}" style="width:${Math.min(100, pct)}%"></span></span>
994
- <span>${Math.round(pct)}%</span>
995
+ <span>${Math.round(pct)}%</span>${extra}
995
996
  </div>`;
996
997
  }
997
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
+
998
1020
  // One display line for what a waiting session needs: glyph + 80-char text,
999
1021
  // plus the untruncated version for tooltips.
1000
1022
  function reasonLine(r) {
@@ -1335,8 +1357,8 @@ function render(state) {
1335
1357
  if (q) {
1336
1358
  qEl.className = q.stale ? 'stale' : '';
1337
1359
  patch(qEl, spendChip +
1338
- (q.utilization != null ? meter('session', q.utilization, q.resetsAt ? 'resets ' + q.resetsAt : '') : '') +
1339
- (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) : '') +
1340
1362
  (q.costUsed != null && q.costLimit ? `<span title="extra usage">$${q.costUsed.toFixed(0)}/$${q.costLimit.toFixed(0)}</span>` : ''));
1341
1363
  } else {
1342
1364
  qEl.className = '';
@@ -1367,7 +1389,7 @@ function render(state) {
1367
1389
  patch($('#grid'), active.length || dormant.length
1368
1390
  ? active.map(projectCard).join('')
1369
1391
  : `<div id="welcome">
1370
- <h2><span class="tilde">❯</span> Welcome to Claude Dashboard</h2>
1392
+ <h2><span class="tilde">❯</span> Welcome to Claude Mission Control</h2>
1371
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>
1372
1394
  <p>Tip: press <code>⌘K</code> to jump anywhere, <code>/</code> to search, and click ⚙ for settings.</p>
1373
1395
  </div>`);
@@ -1384,9 +1406,9 @@ function render(state) {
1384
1406
  : 'Settings';
1385
1407
 
1386
1408
  const waiting = live.filter((s) => s.status === 'waiting').length;
1387
- document.title = waiting ? `(${waiting} waiting) Claude Dashboard`
1388
- : live.length ? `(${live.length} running) Claude Dashboard`
1389
- : 'Claude Dashboard';
1409
+ document.title = waiting ? `(${waiting} waiting) Claude Mission Control`
1410
+ : live.length ? `(${live.length} running) Claude Mission Control`
1411
+ : 'Claude Mission Control';
1390
1412
  }
1391
1413
 
1392
1414
  function toast(msg) {
@@ -2903,7 +2925,7 @@ async function openWeekReport() {
2903
2925
  ['Cache hit rate', w.cacheHitRatio != null ? `${Math.round(w.cacheHitRatio * 100)}% of input tokens served from cache` : 'not enough data yet'],
2904
2926
  ['Your typical reply time', w.typicalWait ? `${w.typicalWait} (${waitTotal} prompts measured)` : 'not enough data yet'],
2905
2927
  ];
2906
- weekReport = { w, b, rows };
2928
+ weekReport = { w, b, rows, days: (s.costDays || []).slice(-7) };
2907
2929
  const table = `<table class="stat-table">${rows.map(([k, v]) =>
2908
2930
  `<tr><td>${esc(k)}</td><td style="text-align:right">${esc(v)}</td></tr>`).join('')}</table>`;
2909
2931
  const projRows = (w.projects || []).map((p) =>
@@ -2911,12 +2933,119 @@ async function openWeekReport() {
2911
2933
  $('#d-body').innerHTML =
2912
2934
  dSection('The week', table) +
2913
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>') +
2914
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(() => {});
2915
2941
  } catch {
2916
2942
  $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load the report.</div>';
2917
2943
  }
2918
2944
  }
2919
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
+
2920
3049
  document.addEventListener('click', (e) => {
2921
3050
  if (e.target.closest('[data-week]')) { openWeekReport(); return; }
2922
3051
  if (!e.target.closest('[data-week-export]') || !weekReport) return;
@@ -2942,7 +3071,7 @@ document.addEventListener('click', async (e) => {
2942
3071
  try {
2943
3072
  const r = await fetch(`/api/session?id=${encodeURIComponent(exportableSession.id)}`);
2944
3073
  const t = await r.json();
2945
- 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()}_`, ''];
2946
3075
  for (const ev of t.events) {
2947
3076
  if (ev.kind === 'user') lines.push(`**You:** ${ev.text}`, '');
2948
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"