claude-mission-control 1.12.1 → 1.14.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
@@ -35,6 +35,15 @@ 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.14.0**
39
+
40
+ - Cost per commit in each project's slide-over: every commit made during a Claude session shows its share of that session's cost, with a summary of cost per commit and spend in sessions that committed nothing.
41
+
42
+ **New in 1.13.0**
43
+
44
+ - Background sessions from `claude --bg` and Agent View carry a `BG` chip, the name Agent View gives them, and its one-line summary of what they did.
45
+ - The unpushed strip warns when a background job's worktree holds uncommitted files or commits on no other branch. Deleting that session from Agent View's list would discard them.
46
+
38
47
  **New in 1.12.0**
39
48
 
40
49
  - 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.
@@ -155,6 +164,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
155
164
  - **Departures** — every live `claude` CLI session as a row on the board: start time, project, current task, model, cost so far with a `$/h` burn rate, a context-window meter, elapsed time, and a split-flap status cell. The cell flips to an amber **needs you** when a session is waiting on your input — readable from across the room. A second line under the title says what it needs: `?` for a question Claude asked (with its options), `🔒` for a tool call awaiting permission (with the command or file), or the last question in Claude's reply, or failing that the first line of its final paragraph.
156
165
  - **Mission Control** — the `▦` button in the header (or `⌘K` → Mission Control) tiles every live session side by side, each pane following its own transcript. Each pane head shows the model, cost and burn rate, the context meter, and how many tool calls the session has made (failures in amber). One screen for "what is everything doing right now".
157
166
  - **Agents** — a session running subagents or an agent team shows them under its Mission Control pane: name, type, model, and a pulsing `●` while an agent has written in the last two minutes, `○` once it has gone quiet (idle teammates stay alive, so quiet is not finished), with the team name when there is one. Finished sessions list them at the top of the transcript.
167
+ - **Background sessions** — sessions started with `claude --bg` or from Agent View (`claude agents`) carry a `BG` chip on the board and in the digest, go by the name Agent View gives them, and show Agent View's one-line summary of what they did. When a background job's worktree still holds work, the unpushed strip lists it: `●n` uncommitted files and `⑂n` commits on that branch and no other branch or remote. Deleting the session from Agent View's list (ctrl+x twice) discards that work, while `claude rm` refuses until it is committed and pushed, so merge or push first. Read from `~/.claude/jobs/`, which keeps finished jobs too.
158
168
  - **Pinned** — `☆ pin` on any transcript sticks that session to a strip under the departures board, so the thing you care about stays in view whether it's running or not.
159
169
  - **Digest** — what happened across every project, grouped by day. Each entry shows the session's recap (Claude's own "away summary" where one exists — click to expand), how many tasks it completed, and an open button. Switch the window between day / 3 days / week; collapses out of the way and remembers your choice.
160
170
  - **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.
@@ -188,6 +198,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
188
198
  ![Stats view: activity heatmap, busiest hours, weekly rhythm, 90-day spend, per-model costs, the most expensive sessions, and models by project](docs/screenshots/stats.png)
189
199
 
190
200
  - **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.
201
+ - **Cost per commit** — in each project's slide-over, every commit made during a Claude session carries a `≈$` figure: that session's cost split evenly over the commits it made (±5 minutes of its activity). Hover for which session it came from. The line above the list sums it up: cost per commit, how many of the recent commits came from a Claude session, and what was spent in sessions that committed nothing. It's an even split, so a one-line version bump counts the same as a feature.
191
202
  - **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.
192
203
 
193
204
  ![Toolset in the project slide-over: plugins, agents, skills and MCP servers marked on or off with their source and deciding settings scope, an amber warning for a plugin enabled but not installed, and buttons for the project's settings files](docs/screenshots/toolset.png)
package/lib/collector.js CHANGED
@@ -18,6 +18,7 @@ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate,
18
18
  const { readPlan } = require('./plan');
19
19
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
20
20
  const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
21
+ const { readJobs, worktreeStatus, jobAttention } = require('./jobs');
21
22
 
22
23
  // A busy session whose transcript hasn't grown for this long may be stalled.
23
24
  const QUIET_FLAG_MS = 10 * 60 * 1000;
@@ -179,6 +180,12 @@ class Collector {
179
180
  async refreshGit() {
180
181
  const paths = this.projectPaths().filter((p) => existsDir(p));
181
182
  this.raw.git = await collectGitStatus(paths);
183
+ // Background jobs' worktrees: work that removing the session would delete.
184
+ const jobWt = new Map();
185
+ for (const job of readJobs()) {
186
+ if (job.worktreePath) jobWt.set(job.jobId, await worktreeStatus(job));
187
+ }
188
+ this.raw.jobWt = jobWt;
182
189
  }
183
190
 
184
191
  async refreshQuota() {
@@ -357,9 +364,13 @@ class Collector {
357
364
  }
358
365
  this.stuckNotified = this.stuckNotified || new Set();
359
366
 
367
+ const jobs = readJobs();
368
+ const jobBySession = new Map(jobs.map((j) => [j.sessionId, j]));
369
+
360
370
  const nagMs = (cfg.idleNagMinutes || 0) * 60000;
361
371
  const liveSessions = this.raw.live.map((s) => {
362
372
  const { root, worktree } = worktreeRoot(s.cwd);
373
+ const job = jobBySession.get(s.sessionId) || null;
363
374
  // "Quiet" = busy but the transcript hasn't grown. Long quiet spells
364
375
  // usually mean the session is stalled (weak signal: it may just be
365
376
  // waiting on slow background work).
@@ -409,7 +420,11 @@ class Collector {
409
420
  assumed: report.assumedModels.length > 0,
410
421
  context: contextBySession.get(s.sessionId) || null,
411
422
  tools: toolsBySession.get(s.sessionId) || null,
412
- title: titleBySession.get(s.sessionId) || s.name,
423
+ // Background sessions go by the name Agent View shows.
424
+ title: (job && job.name) || titleBySession.get(s.sessionId) || s.name,
425
+ background: s.kind === 'bg' || job
426
+ ? { jobId: job ? job.jobId : null, detail: job ? job.detail : null, state: job ? job.state : null }
427
+ : null,
413
428
  status: s.status,
414
429
  waitingFor: s.waitingFor,
415
430
  waitingReason: s.status === 'waiting' ? this.reasonFor(s) : null,
@@ -481,7 +496,22 @@ class Collector {
481
496
 
482
497
  const forecast = this.limitForecast();
483
498
 
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) };
499
+ // Background jobs, live or finished, with any worktree work at risk.
500
+ const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
501
+ const jobWt = this.raw.jobWt || new Map();
502
+ const jobsOut = jobs.map((j) => ({
503
+ jobId: j.jobId,
504
+ sessionId: j.sessionId,
505
+ name: j.name || j.intent || j.jobId,
506
+ state: j.state,
507
+ detail: j.detail,
508
+ projectName: j.cwd ? friendlyName(worktreeRoot(j.cwd).root) : null,
509
+ updatedAt: j.updatedAt,
510
+ live: liveIds.has(j.sessionId),
511
+ atRisk: jobAttention(j, jobWt.get(j.jobId)),
512
+ }));
513
+
514
+ const next = { quota: this.raw.quota, forecast, jobs: jobsOut, plan: readPlan(), theme: readConfig().theme || 'board', liveSessions, projects, weeklyCost, budget, pinned, events: this.events.slice(0, 50), errors: this.state.errors.slice(-5) };
485
515
  const fp = JSON.stringify(next);
486
516
  if (fp !== this.fingerprint) {
487
517
  this.fingerprint = fp;
package/lib/demo.js CHANGED
@@ -55,6 +55,11 @@ function demoState() {
55
55
  generatedAt: now,
56
56
  plan: { tier: 'Max 20x' },
57
57
  quota: { utilization: 64, weeklyUtilization: 52, resetsAt: '2pm', weeklyResetsAt: 'Wed 9am' },
58
+ jobs: [
59
+ { jobId: 'b7e2c901', sessionId: S2, name: 'Migrate posts to the new block format', state: 'working', detail: 'converting shortcodes post by post; 41 of 120 done', projectName: 'Blog Engine', updatedAt: now - MIN, live: true, atRisk: null },
60
+ { jobId: 'a41f0d3e', sessionId: 'demo5555-5555-4555-8555-555555555555', name: 'Fix the flaky checkout test', state: 'done', detail: 'race in the cart fixture; fixed and committed on the branch, not merged', projectName: 'Acme Storefront', updatedAt: now - 2 * HOUR, live: false,
61
+ atRisk: { jobId: 'a41f0d3e', sessionId: 'demo5555-5555-4555-8555-555555555555', name: 'Fix the flaky checkout test', worktree: 'fix-flaky-checkout', branch: 'worktree-fix-flaky-checkout', changes: 1, onlyHere: 2 } },
62
+ ],
58
63
  forecast: {
59
64
  session: { hitAt: now + 70 * MIN, projected: 128, resetsAt: now + 3 * HOUR, utilization: 64, basis: 'recent' },
60
65
  week: { hitAt: null, projected: 81, resetsAt: now + 3 * 24 * HOUR, utilization: 52, basis: 'average' },
@@ -87,6 +92,7 @@ function demoState() {
87
92
  projectName: 'Blog Engine', isWorktree: false, model: 'claude-fable-5',
88
93
  estCost: 61.2, burnRate: 19, assumed: false, context: { tokens: 731000, window: 1000000, pct: 73 }, tools: { total: 388, errors: 0 },
89
94
  title: 'Migrate posts to the new block format', status: 'busy',
95
+ background: { jobId: 'b7e2c901', detail: 'converting shortcodes post by post; 41 of 120 done', state: 'working' },
90
96
  waitingFor: null, waitingReason: null, startedAt: now - 3 * HOUR - 12 * MIN, statusUpdatedAt: now - MIN, quietMin: null, waitingMin: null,
91
97
  currentTask: { activeForm: 'Converting legacy shortcodes' },
92
98
  tasksSummary: { completed: 7, inProgress: 1, pending: 3 },
@@ -457,5 +463,29 @@ function demoToolset() {
457
463
  };
458
464
  }
459
465
 
460
- module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset };
466
+ // Project slide-over for the demo: commits with their cost split from the
467
+ // sessions that made them (see commitCosts in lib/gitlog.js).
468
+ function demoProject() {
469
+ const now = Date.now();
470
+ const c = (sha, ago, subject, stat, cost, title, n) => ({
471
+ sha, ts: now - ago, subject, author: 'Demo', claude: cost != null, stat,
472
+ ...(cost != null ? { cost, costFrom: [{ sessionId: S1, title, commits: n }], sessionId: S1, sessionTitle: title } : {}),
473
+ });
474
+ return {
475
+ path: '/demo/acme-storefront', sessions: [], muted: false,
476
+ claudeMd: [], memory: [], skills: [], agents: [], commands: [], settings: {},
477
+ toolset: demoToolset(),
478
+ commits: [
479
+ c('9f3c2a1', 6 * MIN, 'Checkout: create the payment intent server-side', '3 files changed, 64 insertions(+), 9 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
480
+ c('41be07d', 22 * MIN, 'Cart: call the checkout endpoint and redirect', '2 files changed, 31 insertions(+), 4 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
481
+ c('c0d9e55', 41 * MIN, 'Remove the legacy checkout page', '1 file changed, 0 insertions(+), 212 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
482
+ c('77a1f30', 3 * HOUR, 'Onboarding: account and store screens', '6 files changed, 240 insertions(+), 12 deletions(-)', 9.1, 'Design the onboarding flow', 2),
483
+ c('e52b8c4', 3 * HOUR + 40 * MIN, 'Onboarding: first-product screen', '3 files changed, 118 insertions(+), 3 deletions(-)', 9.1, 'Design the onboarding flow', 2),
484
+ c('1a6d9f2', 26 * HOUR, 'Bump dependencies', '2 files changed, 14 insertions(+), 14 deletions(-)', null),
485
+ ],
486
+ commitSummary: { total: 6, withSession: 5, perCommit: 8.56, noCommitSpend: 3.4 },
487
+ };
488
+ }
489
+
490
+ module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject };
461
491
 
package/lib/gitlog.js CHANGED
@@ -44,6 +44,47 @@ function linkCommitsToSessions(commits, sessions) {
44
44
  });
45
45
  }
46
46
 
47
+ // Cost per commit: each session's cost is split evenly across the commits
48
+ // made inside its activity window (same padded window as above). A commit
49
+ // inside two overlapping sessions gets a share of both. Sessions that cost
50
+ // something but committed nothing, since the oldest listed commit, are
51
+ // reported separately rather than spread over unrelated commits.
52
+ const round2 = (n) => Math.round(n * 100) / 100;
53
+ function commitCosts(commits, sessions) {
54
+ const cost = new Map(); // sha -> usd
55
+ const from = new Map(); // sha -> [{ sessionId, title, commits }]
56
+ const oldest = commits.length ? Math.min(...commits.map((c) => c.ts)) : Infinity;
57
+ let attributed = 0;
58
+ let noCommit = 0;
59
+ for (const s of sessions || []) {
60
+ const usd = s.estCost || 0;
61
+ if (usd < 0.005 || !s.lastActivityAt) continue;
62
+ const start = (s.startedAt || s.lastActivityAt) - PAD;
63
+ const end = s.lastActivityAt + PAD;
64
+ const inside = commits.filter((c) => c.ts >= start && c.ts <= end);
65
+ if (!inside.length) {
66
+ if (s.lastActivityAt >= oldest) noCommit += usd;
67
+ continue;
68
+ }
69
+ attributed += usd;
70
+ for (const c of inside) {
71
+ cost.set(c.sha, (cost.get(c.sha) || 0) + usd / inside.length);
72
+ from.set(c.sha, [...(from.get(c.sha) || []), { sessionId: s.sessionId, title: s.title || null, commits: inside.length }]);
73
+ }
74
+ }
75
+ const out = commits.map((c) => (cost.has(c.sha) ? { ...c, cost: round2(cost.get(c.sha)), costFrom: from.get(c.sha) } : c));
76
+ const withSession = cost.size;
77
+ return {
78
+ commits: out,
79
+ summary: {
80
+ total: commits.length,
81
+ withSession,
82
+ perCommit: withSession ? round2(attributed / withSession) : null,
83
+ noCommitSpend: round2(noCommit),
84
+ },
85
+ };
86
+ }
87
+
47
88
  function recentCommits(projectPath, limit = 30) {
48
89
  return new Promise((resolve) => {
49
90
  execFile(
@@ -55,4 +96,4 @@ function recentCommits(projectPath, limit = 30) {
55
96
  });
56
97
  }
57
98
 
58
- module.exports = { parseGitLog, linkCommitsToSessions, recentCommits };
99
+ module.exports = { parseGitLog, linkCommitsToSessions, commitCosts, recentCommits };
package/lib/jobs.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ // Background sessions (`claude --bg`, Agent View) from ~/.claude/jobs/<id>/
3
+ // state.json: Agent View's name and one-line summary, the job's state, and
4
+ // its worktree. The files outlive the session, so finished jobs show too.
5
+ //
6
+ // For a job whose worktree still exists we also count work that would be
7
+ // lost if the session were deleted from Agent View's list, which discards
8
+ // the worktree: uncommitted files, and commits on no other branch or remote.
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execFile } = require('child_process');
12
+ const { CLAUDE_DIR } = require('./paths');
13
+ const { parsePorcelainV2 } = require('./gitstatus');
14
+
15
+ const JOBS_DIR = path.join(CLAUDE_DIR, 'jobs');
16
+ const KEEP_MS = 7 * 24 * 60 * 60 * 1000; // finished jobs older than this drop off
17
+ const MAX_JOBS = 30;
18
+
19
+ const cache = new Map(); // jobId -> { mtimeMs, job }
20
+
21
+ function ms(v) {
22
+ if (typeof v === 'number') return v;
23
+ const t = Date.parse(v);
24
+ return Number.isFinite(t) ? t : null;
25
+ }
26
+
27
+ function normalizeJob(jobId, raw) {
28
+ if (!raw || !raw.sessionId) return null;
29
+ return {
30
+ jobId,
31
+ sessionId: raw.sessionId,
32
+ name: raw.name || null,
33
+ state: raw.state || null,
34
+ detail: raw.detail ? String(raw.detail).slice(0, 200) : null,
35
+ intent: raw.intent ? String(raw.intent).slice(0, 200) : null,
36
+ cwd: raw.cwd || null,
37
+ worktreePath: raw.worktreePath || null,
38
+ worktreeBranch: raw.worktreeBranch || null,
39
+ createdAt: ms(raw.createdAt),
40
+ updatedAt: ms(raw.updatedAt),
41
+ tokens: typeof raw.tokens === 'number' ? raw.tokens : null,
42
+ };
43
+ }
44
+
45
+ // Recent jobs, newest first. Cached per file by mtime.
46
+ function readJobs(now = Date.now()) {
47
+ let ids;
48
+ try {
49
+ ids = fs.readdirSync(JOBS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
50
+ } catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const id of ids) {
55
+ const file = path.join(JOBS_DIR, id, 'state.json');
56
+ let st;
57
+ try {
58
+ st = fs.statSync(file);
59
+ } catch {
60
+ continue;
61
+ }
62
+ let hit = cache.get(id);
63
+ if (!hit || hit.mtimeMs !== st.mtimeMs) {
64
+ let job = null;
65
+ try {
66
+ job = normalizeJob(id, JSON.parse(fs.readFileSync(file, 'utf8')));
67
+ } catch { /* partial write: try again next poll */ }
68
+ hit = { mtimeMs: st.mtimeMs, job };
69
+ cache.set(id, hit);
70
+ }
71
+ if (hit.job && now - (hit.job.updatedAt || st.mtimeMs) <= KEEP_MS) out.push(hit.job);
72
+ }
73
+ out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
74
+ return out.slice(0, MAX_JOBS);
75
+ }
76
+
77
+ function git(args) {
78
+ return new Promise((resolve) => {
79
+ execFile('git', args, { timeout: 4000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
80
+ });
81
+ }
82
+
83
+ // Args for "commits reachable from HEAD but from no other branch or remote".
84
+ // --exclude patterns for --branches must leave off the refs/heads/ prefix.
85
+ function onlyHereArgs(worktreePath, branch) {
86
+ return ['-C', worktreePath, 'rev-list', '--count', 'HEAD', '--not',
87
+ ...(branch ? [`--exclude=${branch}`] : []), '--branches', '--remotes'];
88
+ }
89
+
90
+ async function worktreeStatus(job) {
91
+ if (!job.worktreePath || !fs.existsSync(path.join(job.worktreePath, '.git'))) return { exists: false };
92
+ const [status, count] = await Promise.all([
93
+ git(['-C', job.worktreePath, 'status', '--porcelain=v2', '--branch']),
94
+ git(onlyHereArgs(job.worktreePath, job.worktreeBranch)),
95
+ ]);
96
+ if (status == null) return { exists: true, error: true };
97
+ const s = parsePorcelainV2(status);
98
+ const n = Number.parseInt(count, 10);
99
+ return { exists: true, changes: s.dirty + s.untracked, onlyHere: Number.isFinite(n) ? n : 0 };
100
+ }
101
+
102
+ // What the unpushed strip should say about a job's worktree, or null.
103
+ function jobAttention(job, wt) {
104
+ if (!wt || !wt.exists || wt.error) return null;
105
+ if (!wt.changes && !wt.onlyHere) return null;
106
+ return {
107
+ jobId: job.jobId,
108
+ sessionId: job.sessionId,
109
+ name: job.name || job.jobId,
110
+ worktree: path.basename(job.worktreePath),
111
+ branch: job.worktreeBranch,
112
+ changes: wt.changes,
113
+ onlyHere: wt.onlyHere,
114
+ };
115
+ }
116
+
117
+ module.exports = { readJobs, normalizeJob, worktreeStatus, jobAttention, onlyHereArgs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.12.1",
3
+ "version": "1.14.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
@@ -351,6 +351,7 @@
351
351
  .badge.dirty { color: var(--warn); }
352
352
  .badge.ahead { color: var(--accent); }
353
353
  .badge.err { color: var(--bad); }
354
+ .badge.bg { color: var(--accent); letter-spacing: 0.06em; }
354
355
  .badge.missing { color: var(--bad); }
355
356
  .note-mark { color: var(--warn); cursor: default; font-size: 12px; flex: none; }
356
357
  #note-editor { margin: 10px 0 4px; display: flex; flex-direction: column; gap: 6px; }
@@ -1099,6 +1100,8 @@ function copyBtn(cmd) {
1099
1100
 
1100
1101
  // Live sessions are already open somewhere; only offer open on idle ones.
1101
1102
  let liveIds = new Set();
1103
+ let jobBySession = new Map(); // background jobs (Agent View), by session id
1104
+ const BG_TIP = 'Background session (claude --bg or Agent View)';
1102
1105
  function renameBtn(sessionId, title) {
1103
1106
  return `<button class="copy rename-btn" data-rename-session="${esc(sessionId)}" data-title="${esc(title || '')}" title="Rename this session">✎</button>`;
1104
1107
  }
@@ -1161,13 +1164,21 @@ function liveCard(s) {
1161
1164
  const reason = rl
1162
1165
  ? `<div class="dep-reason" title="${esc(rl.full)}">${rl.glyph ? `<span class="glyph">${rl.glyph}</span>` : ''}${esc(rl.short)}</div>`
1163
1166
  : '';
1167
+ // Agent View's one-line summary of a background session, when nothing more specific shows.
1168
+ const bgDetail = s.background && s.background.detail && !rl && !task
1169
+ ? `<div class="dep-task mono" title="Agent View's summary"><span class="prompt-ch">↳</span>${esc(s.background.detail)}</div>`
1170
+ : '';
1171
+ const kindBadge = s.background
1172
+ ? ` <span class="badge bg" title="${BG_TIP}">BG</span>`
1173
+ : s.isWorktree ? ' <span class="badge">worktree</span>' : '';
1164
1174
  return `<div class="dep-row ${s.status === 'waiting' ? 'waiting' : ''}">
1165
1175
  <span class="dep-time" title="session started">${esc(dep)}</span>
1166
- <span class="dep-proj disp">${esc(s.projectName)}${s.isWorktree ? ' <span class="badge">worktree</span>' : ''}</span>
1176
+ <span class="dep-proj disp">${esc(s.projectName)}${kindBadge}</span>
1167
1177
  <div class="dep-mid">
1168
1178
  <div class="dep-title" data-session="${esc(s.sessionId)}" title="Click to watch the transcript live">${esc(s.title || '')}</div>
1169
1179
  ${task}
1170
1180
  ${reason}
1181
+ ${bgDetail}
1171
1182
  </div>
1172
1183
  <div class="dep-right">
1173
1184
  ${modelChip(s.model)}
@@ -1246,17 +1257,27 @@ function renderGitAttention(state) {
1246
1257
  if (!changes && !ahead) continue;
1247
1258
  items.push({ name: p.name, changes, ahead, when: p.lastActivityAt });
1248
1259
  }
1249
- if (!items.length) {
1260
+ // Background jobs' worktrees: Agent View deletes the worktree with the session.
1261
+ const atRisk = (state.jobs || []).filter((j) => j.atRisk).map((j) => ({ ...j.atRisk, projectName: j.projectName, when: j.updatedAt }));
1262
+ if (!items.length && !atRisk.length) {
1250
1263
  el.hidden = true;
1251
1264
  return;
1252
1265
  }
1253
1266
  items.sort((a, b) => (b.when || 0) - (a.when || 0));
1267
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
1254
1268
  el.hidden = false;
1255
1269
  patch(el,
1256
- `<span class="ga-label">Unpushed work — ${items.length} repo${items.length === 1 ? '' : 's'}</span>` +
1270
+ `<span class="ga-label">Unpushed work — ${[items.length && plural(items.length, 'repo'), atRisk.length && plural(atRisk.length, 'worktree')].filter(Boolean).join(', ')}</span>` +
1257
1271
  items.map((it) => `<span class="ga-item">${esc(it.name)}${
1258
1272
  it.changes ? `<span class="badge dirty" title="${it.changes} uncommitted/untracked files">●${it.changes}</span>` : ''}${
1259
- it.ahead ? `<span class="badge ahead" title="${it.ahead} commits not pushed">↑${it.ahead}</span>` : ''}</span>`).join(''));
1273
+ it.ahead ? `<span class="badge ahead" title="${it.ahead} commits not pushed">↑${it.ahead}</span>` : ''}</span>`).join('') +
1274
+ atRisk.map((w) => {
1275
+ const parts = [w.onlyHere && `${plural(w.onlyHere, 'commit')} on ${w.branch || 'this branch'} and no other branch`, w.changes && plural(w.changes, 'uncommitted file')].filter(Boolean);
1276
+ const tip = `Background job "${w.name}": ${parts.join(', ')}. Deleting the session from Agent View's list (ctrl+x twice) discards this; claude rm refuses until it is committed and pushed. Merge or push first.`;
1277
+ return `<span class="ga-item" title="${esc(tip)}">${esc(w.projectName || '')} <span class="wt mono">⎇ ${esc(w.worktree)}</span><span class="badge bg" title="${BG_TIP}">BG</span>${
1278
+ w.changes ? `<span class="badge dirty">●${w.changes}</span>` : ''}${
1279
+ w.onlyHere ? `<span class="badge dirty">⑂${w.onlyHere}</span>` : ''}</span>`;
1280
+ }).join(''));
1260
1281
  }
1261
1282
 
1262
1283
  // ---------- digest: what happened recently, across every project ----------
@@ -1289,11 +1310,14 @@ function digestItem(it) {
1289
1310
  const t = it.tasksSummary;
1290
1311
  const total = t ? t.completed + t.inProgress + t.pending : 0;
1291
1312
  const running = liveIds.has(it.sessionId);
1313
+ const job = jobBySession.get(it.sessionId);
1314
+ const summary = it.awaySummary || (job && job.detail);
1292
1315
  const time = new Date(it.lastActivityAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
1293
1316
  return `<div class="digest-item">
1294
1317
  <div class="di-top">
1295
1318
  <span class="di-proj">${esc(it.project)}</span>
1296
1319
  <span class="di-title" data-session="${esc(it.sessionId)}" title="Click to read the transcript">${it.worktree ? `<span class="wt mono">⎇</span> ` : ''}${esc(it.title)}</span>${noteMark(it)}
1320
+ ${job ? `<span class="badge bg" title="${BG_TIP}">BG</span>` : ''}
1297
1321
  ${running ? '<span class="flap disp st st-busy">running</span>' : ''}
1298
1322
  ${modelChip(it.model)}
1299
1323
  ${changesChip(it)}
@@ -1301,7 +1325,7 @@ function digestItem(it) {
1301
1325
  <span class="di-when">${time}</span>
1302
1326
  ${openBtn(it.sessionId)}
1303
1327
  </div>
1304
- ${it.awaySummary ? `<div class="di-summary" title="Click to expand">${esc(it.awaySummary)}</div>` : ''}
1328
+ ${summary ? `<div class="di-summary" title="Click to expand">${esc(summary)}</div>` : ''}
1305
1329
  ${total ? `<div class="di-tasks"><span class="done">${t.completed}</span>/${total} tasks done${
1306
1330
  t.inProgress ? `, ${t.inProgress} in progress` : ''}${
1307
1331
  t.pending ? `, ${t.pending} left` : ''}</div>` : ''}
@@ -1337,6 +1361,7 @@ function render(state) {
1337
1361
  if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
1338
1362
  else delete document.documentElement.dataset.theme;
1339
1363
  liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
1364
+ jobBySession = new Map((state.jobs || []).map((j) => [j.sessionId, j]));
1340
1365
  // A transcript being followed live keeps its header cost in step with the board.
1341
1366
  if (follow.id && exportableSession?.id === follow.id && exportableSession.usage) {
1342
1367
  const live = (state.liveSessions || []).find((s) => s.sessionId === follow.id);
@@ -1582,6 +1607,18 @@ function renderMd(raw) {
1582
1607
  return `<div class="md">${out.join('')}</div>`;
1583
1608
  }
1584
1609
 
1610
+ // "≈$3.40 per commit · 18 of 30 made during a Claude session · ≈$12 in sessions with no commit"
1611
+ function commitSummaryLine(s) {
1612
+ if (!s || !s.withSession) return '';
1613
+ const w = costWording();
1614
+ const parts = [
1615
+ `${costChip(s.perCommit, { cls: '' })} per commit`,
1616
+ `${s.withSession} of ${s.total} made during a Claude session`,
1617
+ fmtCost(s.noCommitSpend) ? `${costChip(s.noCommitSpend, { cls: '' })} in sessions with no commit` : '',
1618
+ ].filter(Boolean);
1619
+ return `<div class="set-sub" style="margin:0 0 8px" title="${esc(`Each session's ${w.noun} is split evenly over the commits made while it was active (±5 minutes). Commits outside any session are left out.`)}">${parts.join(' · ')}</div>`;
1620
+ }
1621
+
1585
1622
  function dSection(title, inner, empty) {
1586
1623
  return `<details class="d-section" open><summary>${esc(title)}</summary>${
1587
1624
  inner || `<div class="d-empty">${esc(empty)}</div>`}</details>`;
@@ -1672,6 +1709,8 @@ function renderDetail(d, name) {
1672
1709
  ${esc(c.subject)}
1673
1710
  </span>
1674
1711
  ${c.sessionId ? `<button class="copy" data-session="${esc(c.sessionId)}" title="${esc(c.sessionTitle || '')}">session →</button>` : ''}
1712
+ ${c.cost != null ? `<span title="${esc((c.costFrom || []).map((f) =>
1713
+ `${f.title || 'session'}: split over ${f.commits} commit${f.commits === 1 ? '' : 's'}`).join('\n'))}">${costChip(c.cost)}</span>` : ''}
1675
1714
  <span class="s-when">${esc(statCompact(c.stat))}</span>
1676
1715
  <span class="s-when">${relTime(c.ts)}</span>
1677
1716
  </li>`).join('');
@@ -1683,7 +1722,7 @@ function renderDetail(d, name) {
1683
1722
  $('#d-body').innerHTML =
1684
1723
  muteBtn +
1685
1724
  dSection(`Sessions — ${d.sessions.length}`, sessions, 'No sessions recorded.') +
1686
- dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
1725
+ dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? commitSummaryLine(d.commitSummary) + `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
1687
1726
  dSection('CLAUDE.md', d.claudeMd.length ? dFiles(d.claudeMd, 'name') : null, 'No CLAUDE.md — sessions here start without project instructions.') +
1688
1727
  dSection(`Memory — ${d.memory.length} file${d.memory.length === 1 ? '' : 's'}`, d.memory.length ? dFiles(d.memory, 'name') : null, 'No memory yet.') +
1689
1728
  dSection(`Skills, agents & commands — ${caps.length}`, caps.length ? dFiles(caps, 'name') : null, 'None in .claude/skills, .claude/agents, or .claude/commands.') +
package/server.js CHANGED
@@ -15,9 +15,9 @@ const { sessionTitle } = require('./lib/transcripts');
15
15
  const { friendlyName } = require('./lib/names');
16
16
  const cfg = require('./lib/config');
17
17
  const { isProjectMuted } = require('./lib/notify');
18
- const { recentCommits, linkCommitsToSessions } = require('./lib/gitlog');
18
+ const { recentCommits, linkCommitsToSessions, commitCosts } = require('./lib/gitlog');
19
19
  const { sessionChangeList, sessionFileDiff } = require('./lib/changes');
20
- const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset } = require('./lib/demo');
20
+ const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject } = require('./lib/demo');
21
21
  const { runSelfUpdate } = require('./lib/update');
22
22
  const { readInventory } = require('./lib/inventory');
23
23
  const { readToolset } = require('./lib/toolset');
@@ -133,13 +133,7 @@ const server = http.createServer((req, res) => {
133
133
  }
134
134
  if (url === '/api/search') return json(res, 200, { q: '', prompts: [], titles: [], notes: [], transcripts: null });
135
135
  if (url === '/api/reveal-file') return json(res, 200, { ok: true });
136
- if (url === '/api/project') {
137
- return json(res, 200, {
138
- path: '/demo/acme-storefront', sessions: [], commits: [], muted: false,
139
- claudeMd: [], memory: [], skills: [], agents: [], commands: [], settings: {},
140
- toolset: demoToolset(),
141
- });
142
- }
136
+ if (url === '/api/project') return json(res, 200, demoProject());
143
137
  if (url === '/api/config' && req.method === 'GET') {
144
138
  return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, idleNagMinutes: 60, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], themes: cfg.THEMES, version: VERSION, errors: [], pricingSource: null, unpricedModels: [] });
145
139
  }
@@ -258,7 +252,9 @@ const server = http.createServer((req, res) => {
258
252
  Promise.all([projectDetail(known), recentCommits(known), readInventory(collector.projectPaths())])
259
253
  .then(async ([detail, commits, inventory]) => {
260
254
  detail.sessions = collector.allSessions(known);
261
- detail.commits = linkCommitsToSessions(commits, detail.sessions);
255
+ const costed = commitCosts(linkCommitsToSessions(commits, detail.sessions), detail.sessions);
256
+ detail.commits = costed.commits;
257
+ detail.commitSummary = costed.summary;
262
258
  detail.muted = isProjectMuted(known, cfg.readConfig().mutedProjects);
263
259
  detail.toolset = await readToolset(known, inventory);
264
260
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });