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 +46 -7
- package/bin/claude-dashboard.js +8 -2
- package/claude-dashboard.service +1 -1
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/lib/collector.js +42 -2
- package/lib/demo.js +27 -2
- package/lib/forecast.js +93 -0
- package/lib/notify.js +1 -1
- package/lib/pricing.js +19 -1
- package/lib/statusline.js +82 -0
- package/menubar/claude-dash.15s.sh +2 -2
- package/package.json +1 -1
- package/public/index.html +149 -14
- package/public/manifest.webmanifest +2 -2
- package/uninstall.ps1 +1 -1
- package/uninstall.sh +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Claude
|
|
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
|
|
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
|
-

|
|
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
|
-

|
|
125
160
|
|
|
126
|
-

|
|
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
|
-

|
|
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
|
+

|
|
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
|

|
|
@@ -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
|
|
package/bin/claude-dashboard.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
+
}
|
package/claude-dashboard.service
CHANGED
package/install.ps1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install Claude
|
|
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
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
|
|
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:
|
|
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) {
|
package/lib/forecast.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
3
|
-
# <swiftbar.title>Claude
|
|
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
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
|
|
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 ?
|
|
1337
|
-
(q.weeklyUtilization != null ?
|
|
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
|
|
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
|
|
1386
|
-
: live.length ? `(${live.length} running) Claude
|
|
1387
|
-
: 'Claude
|
|
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)}
|
|
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
|
|
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
|
|
3
|
-
"short_name": "
|
|
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
|
|
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