claude-mission-control 1.17.0 → 1.17.1

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
@@ -35,6 +35,10 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
35
35
 
36
36
  *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
37
37
 
38
+ **New in 1.17.1**
39
+
40
+ - Burn rate is now your pace over the last 30 minutes. It used to divide lifetime cost by process age, which read in the thousands per hour right after resuming an old session.
41
+
38
42
  **New in 1.17.0**
39
43
 
40
44
  - Hive view (⬢ in the header): every live session as a character card. Each gets a generated face, a personality earned from what it has done ("the delegator", "the big spender", "the new kid"), a rank by spend, and its latest tool call on a live ticker.
@@ -226,7 +230,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
226
230
  - Worktree sessions (`.claude/worktrees/…`) are folded into their parent project and badged `⎇`.
227
231
  - **Notifications** — the moment any session flips to "waiting for input", you get a macOS notification (with sound) naming the project and what it needs — the question, the command awaiting permission, or Claude's last line. Fires once per wait, never on server restart. Mute a single noisy project from its slide-over (the bell button at the top), or disable everything with `CLAUDE_DASH_NOTIFY=0` in the plist. Notifications arrive via Script Editor/osascript — if you don't see them, allow it under System Settings → Notifications.
228
232
  - **Cost estimates** — every session, project, and live row carries a `≈$` figure computed from the token counts in its transcript. On an API-key login it is labelled *estimated cost*; on a subscription it is *list-price value*, a relative weight rather than a bill (hover a cost chip for the wording). Rates are Anthropic list prices bundled with the dashboard (cached 2026-10-06). If your organization deploys Claude Code's `modelPricing` managed setting, the dashboard reads it and reports at your contracted rates; you can also put the same `modelPricing` block in `config.json` (⚙ settings → *copy skeleton*). A model the table doesn't know is priced at the Opus tier and marked with an amber `?` so a stale table is never silent. Subagent tokens are included.
229
- - **Live cost** — running sessions show their cost so far on the departures board and in Mission Control, with a `$/h` burn rate once the session is five minutes old.
233
+ - **Live cost** — running sessions show their cost so far on the departures board and in Mission Control, with a `$/h` burn rate: the pace over the last 30 minutes, shown after five minutes of watching. A resumed session's earlier history doesn't count toward it.
230
234
  - **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.
231
235
  - **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.
232
236
  - **Most expensive sessions** — in the stats view: the ten costliest sessions all time, click to open.
package/lib/collector.js CHANGED
@@ -15,7 +15,7 @@ const { worktreeRoot } = require('./paths');
15
15
  const { sendNotification, newlyWaiting, overdueWaits, isProjectMuted, trackWaits } = require('./notify');
16
16
  const { readConfig, readPricing } = require('./config');
17
17
  const { hiveRoster } = require('./hive');
18
- const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
18
+ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, recordCostSample, topByCost } = require('./pricing');
19
19
  const { readPlan } = require('./plan');
20
20
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
21
21
  const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
@@ -39,6 +39,7 @@ class Collector {
39
39
  this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
40
40
  this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null, metaBySession: new Map(), teams: [] };
41
41
  this.waitingSince = new Map(); // sessionId -> when it entered 'waiting'
42
+ this.costSamples = new Map(); // sessionId -> [[t, usd], ...] for burn rate
42
43
  this.idleNotified = new Set(); // nagged once for the current wait
43
44
  this.listeners = new Set();
44
45
  this.events = []; // notification history for the catch-up bell, newest first
@@ -402,6 +403,8 @@ class Collector {
402
403
  // displayed digit would. Null until the transcript has been scanned.
403
404
  const report = costReport(usageBySession.get(s.sessionId) || {}, pricing);
404
405
  const estCost = report.usd >= 0.005 ? Math.round(report.usd * 100) / 100 : null;
406
+ const samples = this.costSamples.get(s.sessionId) || [];
407
+ this.costSamples.set(s.sessionId, recordCostSample(samples, report.usd));
405
408
  return {
406
409
  quietMin,
407
410
  waitingMin: (() => {
@@ -419,7 +422,7 @@ class Collector {
419
422
  name: s.name,
420
423
  model: modelBySession.get(s.sessionId) || null,
421
424
  estCost,
422
- burnRate: burnRate(estCost, s.startedAt),
425
+ burnRate: burnRate(samples),
423
426
  assumed: report.assumedModels.length > 0,
424
427
  context: contextBySession.get(s.sessionId) || null,
425
428
  tools: toolsBySession.get(s.sessionId) || null,
@@ -441,6 +444,8 @@ class Collector {
441
444
  resumeCommand: resumeCommand(s.cwd, s.sessionId),
442
445
  };
443
446
  });
447
+ const stillLive = new Set(liveSessions.map((s) => s.sessionId));
448
+ for (const id of this.costSamples.keys()) if (!stillLive.has(id)) this.costSamples.delete(id);
444
449
  const roster = hiveRoster(liveSessions);
445
450
  for (const s of liveSessions) s.hive = roster[s.sessionId];
446
451
 
package/lib/pricing.js CHANGED
@@ -165,16 +165,36 @@ function typicalWait(buckets) {
165
165
  return null;
166
166
  }
167
167
 
168
- // Whole dollars per hour for a running session, null under $1/h. Null for the
169
- // first five minutes too: one turn in a fresh session would otherwise read as
170
- // hundreds per hour. Whole dollars because a cents figure drifts with the clock
171
- // and would churn the SSE state fingerprint on every poll.
168
+ // Spend per hour at the current pace: the cost added over the last 30
169
+ // minutes of samples, not lifetime cost over process age. A resumed session
170
+ // carries its whole history in its transcript, so lifetime/age read in the
171
+ // thousands right after a resume and near zero for a long-lived process.
172
+ // Null until five minutes of samples exist, and under $1/h. Whole dollars so
173
+ // the SSE state fingerprint doesn't churn.
174
+ const BURN_WINDOW_MS = 30 * 60 * 1000;
172
175
  const BURN_MIN_MS = 5 * 60 * 1000;
173
- function burnRate(usd, startedAt, now = Date.now()) {
174
- if (!usd || !startedAt) return null;
175
- const ms = now - startedAt;
176
- if (ms < BURN_MIN_MS) return null;
177
- const perHour = Math.round(usd / (ms / 3600000));
176
+ const BURN_SAMPLE_MS = 30 * 1000;
177
+
178
+ // Appends [now, usd] to a session's samples (at most one per 30s unless the
179
+ // cost moved) and drops what has aged out of the window. A cost that went
180
+ // down means the transcript was rewritten: start over.
181
+ function recordCostSample(samples, usd, now = Date.now()) {
182
+ if (usd == null || !Number.isFinite(usd)) return samples;
183
+ const last = samples[samples.length - 1];
184
+ if (last && usd < last[1]) samples.length = 0;
185
+ const prev = samples[samples.length - 1];
186
+ if (!prev || usd !== prev[1] || now - prev[0] >= BURN_SAMPLE_MS) samples.push([now, usd]);
187
+ while (samples.length > 2 && samples[1][0] <= now - BURN_WINDOW_MS) samples.shift();
188
+ return samples;
189
+ }
190
+
191
+ function burnRate(samples, now = Date.now()) {
192
+ if (!samples || samples.length < 2) return null;
193
+ const first = samples[0];
194
+ const last = samples[samples.length - 1];
195
+ const span = last[0] - first[0];
196
+ if (span < BURN_MIN_MS || now - last[0] > BURN_WINDOW_MS) return null;
197
+ const perHour = Math.round((last[1] - first[1]) / (span / 3600000));
178
198
  return perHour >= 1 ? perHour : null;
179
199
  }
180
200
 
@@ -226,4 +246,4 @@ function planValue(plan, weekCost) {
226
246
  };
227
247
  }
228
248
 
229
- module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, cacheHitRatio, topByCost, contextWindow };
249
+ module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, recordCostSample, cacheHitRatio, topByCost, contextWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
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
@@ -1312,7 +1312,7 @@ function liveCard(s) {
1312
1312
  <div class="dep-right">
1313
1313
  ${modelChip(s.model)}
1314
1314
  ${s.estCost != null ? `<span class="dep-cost">${costChip(s.estCost, { cls: 'dep-cost-main', assumed: s.assumed })}${
1315
- s.burnRate ? `<span class="dep-burn" title="Estimated spend per hour at the current pace">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1315
+ s.burnRate ? `<span class="dep-burn" title="Spend per hour over the last 30 minutes">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1316
1316
  ${contextMeter(s.context)}
1317
1317
  ${s.waitingMin != null
1318
1318
  ? `<span class="dep-elapsed overdue" title="No reply for ${s.waitingMin} minutes — session started ${elapsed(s.startedAt)} ago">${waitLabel(s.waitingMin)}</span>`