claude-mission-control 1.12.0 → 1.13.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 +14 -1
- package/lib/collector.js +32 -2
- package/lib/demo.js +6 -0
- package/lib/jobs.js +117 -0
- package/package.json +3 -2
- package/public/index.html +43 -9
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
One web page that shows you everything Claude Code is doing on your machine.
|
|
4
4
|
|
|
5
|
+
**[Try the live demo →](https://jonimmswordpressdev.github.io/claude-dashboard/)** Click around with invented projects and sessions before you install anything.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
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
10
|
|
|
7
11
|
**What you get**
|
|
@@ -27,10 +31,15 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
|
|
|
27
31
|
|
|
28
32
|
**Requirements:** macOS or Linux (Windows experimental, see below), Node.js ≥ 18, Claude Code.
|
|
29
33
|
|
|
30
|
-

|
|
34
|
+

|
|
31
35
|
|
|
32
36
|
*Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
|
|
33
37
|
|
|
38
|
+
**New in 1.13.0**
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
|
|
34
43
|
**New in 1.12.0**
|
|
35
44
|
|
|
36
45
|
- 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.
|
|
@@ -151,6 +160,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
|
|
|
151
160
|
- **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.
|
|
152
161
|
- **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".
|
|
153
162
|
- **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.
|
|
163
|
+
- **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.
|
|
154
164
|
- **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.
|
|
155
165
|
- **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.
|
|
156
166
|
- **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.
|
|
@@ -165,6 +175,9 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
|
|
|
165
175
|
- **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.
|
|
166
176
|
- **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.
|
|
167
177
|
- **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.
|
|
178
|
+
|
|
179
|
+

|
|
180
|
+
|
|
168
181
|
- **Day timeline** — `⌘K` → Day timeline: the last 24 hours as one lane per project, with amber ticks where a session needed you.
|
|
169
182
|
- **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.
|
|
170
183
|
- **Export** — any transcript downloads as clean markdown via the `export ⇩` button.
|
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
|
-
|
|
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
|
-
|
|
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 },
|
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.
|
|
3
|
+
"version": "1.13.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": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"start": "node server.js",
|
|
27
|
-
"test": "node --test test/pure-logic.test.js"
|
|
27
|
+
"test": "node --test test/pure-logic.test.js",
|
|
28
|
+
"demo:build": "node scripts/build-demo.js"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=18"
|
package/public/index.html
CHANGED
|
@@ -209,6 +209,7 @@
|
|
|
209
209
|
/* ---------- header: the station sign ---------- */
|
|
210
210
|
header {
|
|
211
211
|
display: flex; align-items: center; gap: 16px;
|
|
212
|
+
flex-wrap: wrap; row-gap: 10px;
|
|
212
213
|
padding: 12px 20px;
|
|
213
214
|
border-bottom: 2px solid var(--line);
|
|
214
215
|
background: var(--surface);
|
|
@@ -350,6 +351,7 @@
|
|
|
350
351
|
.badge.dirty { color: var(--warn); }
|
|
351
352
|
.badge.ahead { color: var(--accent); }
|
|
352
353
|
.badge.err { color: var(--bad); }
|
|
354
|
+
.badge.bg { color: var(--accent); letter-spacing: 0.06em; }
|
|
353
355
|
.badge.missing { color: var(--bad); }
|
|
354
356
|
.note-mark { color: var(--warn); cursor: default; font-size: 12px; flex: none; }
|
|
355
357
|
#note-editor { margin: 10px 0 4px; display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -394,6 +396,14 @@
|
|
|
394
396
|
}
|
|
395
397
|
#search:focus { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
396
398
|
#search::placeholder { color: var(--faint); }
|
|
399
|
+
/* A full set of meters needs ~1600px; tighten before the header wraps. */
|
|
400
|
+
@media (max-width: 1600px) {
|
|
401
|
+
header { gap: 12px; }
|
|
402
|
+
#search { width: 120px; }
|
|
403
|
+
#open-pref-wrap .open-label { display: none; }
|
|
404
|
+
#quota { gap: 12px; }
|
|
405
|
+
.meter .bar { width: 44px; }
|
|
406
|
+
}
|
|
397
407
|
#gear, #mc-btn, #bell-btn {
|
|
398
408
|
border: none; background: none; color: var(--muted); cursor: pointer;
|
|
399
409
|
font-size: 15px; padding: 2px 6px; border-radius: 2px; margin-left: 4px;
|
|
@@ -830,11 +840,11 @@
|
|
|
830
840
|
<body>
|
|
831
841
|
<header>
|
|
832
842
|
<div id="conn" title="connection"></div>
|
|
833
|
-
<h1>Claude<span class="tilde"> · </span>
|
|
834
|
-
<input id="search" type="search" placeholder="Search
|
|
843
|
+
<h1>Claude<span class="tilde"> · </span>Mission Control</h1>
|
|
844
|
+
<input id="search" type="search" placeholder="Search… ⏎" autocomplete="off">
|
|
835
845
|
<div class="spacer"></div>
|
|
836
|
-
<label id="open-pref-wrap">Open in
|
|
837
|
-
<select id="open-pref">
|
|
846
|
+
<label id="open-pref-wrap"><span class="open-label">Open in</span>
|
|
847
|
+
<select id="open-pref" aria-label="Open sessions in">
|
|
838
848
|
<option value="terminal">Terminal</option>
|
|
839
849
|
</select>
|
|
840
850
|
</label>
|
|
@@ -1090,6 +1100,8 @@ function copyBtn(cmd) {
|
|
|
1090
1100
|
|
|
1091
1101
|
// Live sessions are already open somewhere; only offer open on idle ones.
|
|
1092
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)';
|
|
1093
1105
|
function renameBtn(sessionId, title) {
|
|
1094
1106
|
return `<button class="copy rename-btn" data-rename-session="${esc(sessionId)}" data-title="${esc(title || '')}" title="Rename this session">✎</button>`;
|
|
1095
1107
|
}
|
|
@@ -1152,13 +1164,21 @@ function liveCard(s) {
|
|
|
1152
1164
|
const reason = rl
|
|
1153
1165
|
? `<div class="dep-reason" title="${esc(rl.full)}">${rl.glyph ? `<span class="glyph">${rl.glyph}</span>` : ''}${esc(rl.short)}</div>`
|
|
1154
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>' : '';
|
|
1155
1174
|
return `<div class="dep-row ${s.status === 'waiting' ? 'waiting' : ''}">
|
|
1156
1175
|
<span class="dep-time" title="session started">${esc(dep)}</span>
|
|
1157
|
-
<span class="dep-proj disp">${esc(s.projectName)}${
|
|
1176
|
+
<span class="dep-proj disp">${esc(s.projectName)}${kindBadge}</span>
|
|
1158
1177
|
<div class="dep-mid">
|
|
1159
1178
|
<div class="dep-title" data-session="${esc(s.sessionId)}" title="Click to watch the transcript live">${esc(s.title || '')}</div>
|
|
1160
1179
|
${task}
|
|
1161
1180
|
${reason}
|
|
1181
|
+
${bgDetail}
|
|
1162
1182
|
</div>
|
|
1163
1183
|
<div class="dep-right">
|
|
1164
1184
|
${modelChip(s.model)}
|
|
@@ -1237,17 +1257,27 @@ function renderGitAttention(state) {
|
|
|
1237
1257
|
if (!changes && !ahead) continue;
|
|
1238
1258
|
items.push({ name: p.name, changes, ahead, when: p.lastActivityAt });
|
|
1239
1259
|
}
|
|
1240
|
-
|
|
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) {
|
|
1241
1263
|
el.hidden = true;
|
|
1242
1264
|
return;
|
|
1243
1265
|
}
|
|
1244
1266
|
items.sort((a, b) => (b.when || 0) - (a.when || 0));
|
|
1267
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
1245
1268
|
el.hidden = false;
|
|
1246
1269
|
patch(el,
|
|
1247
|
-
`<span class="ga-label">Unpushed work — ${items.length
|
|
1270
|
+
`<span class="ga-label">Unpushed work — ${[items.length && plural(items.length, 'repo'), atRisk.length && plural(atRisk.length, 'worktree')].filter(Boolean).join(', ')}</span>` +
|
|
1248
1271
|
items.map((it) => `<span class="ga-item">${esc(it.name)}${
|
|
1249
1272
|
it.changes ? `<span class="badge dirty" title="${it.changes} uncommitted/untracked files">●${it.changes}</span>` : ''}${
|
|
1250
|
-
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(''));
|
|
1251
1281
|
}
|
|
1252
1282
|
|
|
1253
1283
|
// ---------- digest: what happened recently, across every project ----------
|
|
@@ -1280,11 +1310,14 @@ function digestItem(it) {
|
|
|
1280
1310
|
const t = it.tasksSummary;
|
|
1281
1311
|
const total = t ? t.completed + t.inProgress + t.pending : 0;
|
|
1282
1312
|
const running = liveIds.has(it.sessionId);
|
|
1313
|
+
const job = jobBySession.get(it.sessionId);
|
|
1314
|
+
const summary = it.awaySummary || (job && job.detail);
|
|
1283
1315
|
const time = new Date(it.lastActivityAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
1284
1316
|
return `<div class="digest-item">
|
|
1285
1317
|
<div class="di-top">
|
|
1286
1318
|
<span class="di-proj">${esc(it.project)}</span>
|
|
1287
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>` : ''}
|
|
1288
1321
|
${running ? '<span class="flap disp st st-busy">running</span>' : ''}
|
|
1289
1322
|
${modelChip(it.model)}
|
|
1290
1323
|
${changesChip(it)}
|
|
@@ -1292,7 +1325,7 @@ function digestItem(it) {
|
|
|
1292
1325
|
<span class="di-when">${time}</span>
|
|
1293
1326
|
${openBtn(it.sessionId)}
|
|
1294
1327
|
</div>
|
|
1295
|
-
${
|
|
1328
|
+
${summary ? `<div class="di-summary" title="Click to expand">${esc(summary)}</div>` : ''}
|
|
1296
1329
|
${total ? `<div class="di-tasks"><span class="done">${t.completed}</span>/${total} tasks done${
|
|
1297
1330
|
t.inProgress ? `, ${t.inProgress} in progress` : ''}${
|
|
1298
1331
|
t.pending ? `, ${t.pending} left` : ''}</div>` : ''}
|
|
@@ -1328,6 +1361,7 @@ function render(state) {
|
|
|
1328
1361
|
if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
|
|
1329
1362
|
else delete document.documentElement.dataset.theme;
|
|
1330
1363
|
liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
|
|
1364
|
+
jobBySession = new Map((state.jobs || []).map((j) => [j.sessionId, j]));
|
|
1331
1365
|
// A transcript being followed live keeps its header cost in step with the board.
|
|
1332
1366
|
if (follow.id && exportableSession?.id === follow.id && exportableSession.usage) {
|
|
1333
1367
|
const live = (state.liveSessions || []).find((s) => s.sessionId === follow.id);
|