claude-mission-control 1.7.0 → 1.9.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 +4 -0
- package/claude-dashboard.service +2 -1
- package/com.claude-dashboard.plist +1 -0
- package/lib/collector.js +12 -6
- package/lib/config.js +33 -2
- package/lib/transcripts.js +5 -1
- package/lib/update.js +110 -0
- package/package.json +1 -1
- package/public/index.html +208 -4
- package/server.js +31 -1
package/README.md
CHANGED
|
@@ -113,6 +113,8 @@ The ⚙ gear in the header opens settings — no JSON editing required:
|
|
|
113
113
|
- **Notifications** on/off (writes `config.json`); per-project mute lives on each project's slide-over
|
|
114
114
|
- **Terminal** for open/new-session buttons: Ghostty, iTerm2, or Terminal.app, auto-detected (`config.json`)
|
|
115
115
|
- **Rename any project** (writes `names.json`) or **hide it** and its whole subtree (writes `ignore.json`), with an unhide list below
|
|
116
|
+
- **Theme**: Departures board (follows system light/dark), Phosphor, Amber CRT, Midnight, or Newsprint (`config.json`)
|
|
117
|
+
- **Updates**: "check for updates" asks GitHub only when you click; when a new release is out, **update now** pulls it in place (git or npm installs) and service installs restart themselves on the new version
|
|
116
118
|
|
|
117
119
|
Everything saves instantly; the underlying files stay hand-editable. Keyboard: `⌘K` for the palette, `/` for search, `Esc` closes anything.
|
|
118
120
|
|
|
@@ -130,6 +132,8 @@ Edit `names.json` to control how projects are titled:
|
|
|
130
132
|
|
|
131
133
|
Unlisted projects fall back to a cleaned-up folder name. Changes are picked up automatically — no restart needed.
|
|
132
134
|
|
|
135
|
+
Sessions can be renamed too: click the ✎ next to any session title (live board, project cards, pinned strip, or a project's slide-over), type a name, and press Enter. Escape cancels; an empty name goes back to the automatic title. Custom names live in `config.json` under `sessionNames`, keyed by session id, and win over the AI-generated or first-prompt title everywhere.
|
|
136
|
+
|
|
133
137
|
## Hiding projects
|
|
134
138
|
|
|
135
139
|
Edit `ignore.json` — an array of absolute path prefixes. A project is hidden if its path is, or sits under, any listed prefix, so one line hides a whole tree (e.g. all the plugins/themes inside one site). This only hides them from the dashboard, strip, and menu bar; nothing on disk or in `~/.claude` is touched. Picked up automatically.
|
package/claude-dashboard.service
CHANGED
|
@@ -14,5 +14,6 @@
|
|
|
14
14
|
<key>StandardErrorPath</key><string>__HOME__/Library/Logs/claude-dashboard.log</string>
|
|
15
15
|
<key>EnvironmentVariables</key><dict>
|
|
16
16
|
<key>CLAUDE_DASH_PORT</key><string>4517</string>
|
|
17
|
+
<key>CLAUDE_DASH_SERVICE</key><string>1</string>
|
|
17
18
|
</dict>
|
|
18
19
|
</dict></plist>
|
package/lib/collector.js
CHANGED
|
@@ -165,8 +165,14 @@ class Collector {
|
|
|
165
165
|
return [...set.values()].filter((p) => !isIgnored(p));
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// sessionTitle with the user's custom names from config.json applied.
|
|
169
|
+
titleOf(m) {
|
|
170
|
+
return sessionTitle(m, readConfig().sessionNames);
|
|
171
|
+
}
|
|
172
|
+
|
|
168
173
|
assemble() {
|
|
169
174
|
const registry = readRegistry();
|
|
175
|
+
const sessionNames = readConfig().sessionNames || {}; // read once, not per session
|
|
170
176
|
const liveByProject = new Set(
|
|
171
177
|
this.raw.live.map((s) => worktreeRoot(s.cwd).root.toLowerCase())
|
|
172
178
|
);
|
|
@@ -203,7 +209,7 @@ class Collector {
|
|
|
203
209
|
0
|
|
204
210
|
),
|
|
205
211
|
sessions: sessionMetas.slice(0, SESSIONS_PER_PROJECT).map((m) => {
|
|
206
|
-
const { title, source } = sessionTitle(m);
|
|
212
|
+
const { title, source } = sessionTitle(m, sessionNames);
|
|
207
213
|
return {
|
|
208
214
|
sessionId: m.sessionId,
|
|
209
215
|
estCost: estimateCost(combinedUsage(m)),
|
|
@@ -235,7 +241,7 @@ class Collector {
|
|
|
235
241
|
const subsBySession = new Map();
|
|
236
242
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
237
243
|
for (const m of g.sessions) {
|
|
238
|
-
titleBySession.set(m.sessionId, sessionTitle(m).title);
|
|
244
|
+
titleBySession.set(m.sessionId, sessionTitle(m, sessionNames).title);
|
|
239
245
|
if (m.model) modelBySession.set(m.sessionId, m.model);
|
|
240
246
|
const subs = subagentSummary(m);
|
|
241
247
|
if (subs) subsBySession.set(m.sessionId, subs);
|
|
@@ -306,7 +312,7 @@ class Collector {
|
|
|
306
312
|
if (pinnedIds.has(m.sessionId)) {
|
|
307
313
|
pinned.push({
|
|
308
314
|
sessionId: m.sessionId,
|
|
309
|
-
title: sessionTitle(m).title,
|
|
315
|
+
title: sessionTitle(m, sessionNames).title,
|
|
310
316
|
projectName: friendlyName(g.path),
|
|
311
317
|
model: m.model || null,
|
|
312
318
|
lastActivityAt: m.lastActivityAt,
|
|
@@ -365,7 +371,7 @@ class Collector {
|
|
|
365
371
|
const g = this.raw.transcriptGroups.get(projectPath.toLowerCase());
|
|
366
372
|
if (!g) return [];
|
|
367
373
|
return g.sessions.map((m) => {
|
|
368
|
-
const { title, source } =
|
|
374
|
+
const { title, source } = this.titleOf(m);
|
|
369
375
|
return {
|
|
370
376
|
sessionId: m.sessionId,
|
|
371
377
|
title,
|
|
@@ -441,7 +447,7 @@ class Collector {
|
|
|
441
447
|
timeline.push({
|
|
442
448
|
project: friendlyName(g.path),
|
|
443
449
|
sessionId: m.sessionId,
|
|
444
|
-
title:
|
|
450
|
+
title: this.titleOf(m).title,
|
|
445
451
|
start: Math.max(m.startedAt || m.lastActivityAt, dayCut),
|
|
446
452
|
end: m.lastActivityAt,
|
|
447
453
|
});
|
|
@@ -507,7 +513,7 @@ class Collector {
|
|
|
507
513
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
508
514
|
for (const m of g.sessions) {
|
|
509
515
|
if (m.sessionId === sessionId) {
|
|
510
|
-
return { file: m.file, title:
|
|
516
|
+
return { file: m.file, title: this.titleOf(m).title, projectName: friendlyName(g.path) };
|
|
511
517
|
}
|
|
512
518
|
}
|
|
513
519
|
}
|
package/lib/config.js
CHANGED
|
@@ -33,7 +33,18 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
|
33
33
|
const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
|
|
34
34
|
const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
|
|
35
35
|
|
|
36
|
-
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], theme: 'board' };
|
|
36
|
+
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, theme: 'board' };
|
|
37
|
+
|
|
38
|
+
// The one theme list: updateConfig validates against it and GET /api/config
|
|
39
|
+
// serves it, so the settings dropdown can never offer a value the server
|
|
40
|
+
// would drop. Adding a theme = one entry here + its CSS block in index.html.
|
|
41
|
+
const THEMES = [
|
|
42
|
+
{ id: 'board', label: 'Departures board' },
|
|
43
|
+
{ id: 'phosphor', label: 'Phosphor' },
|
|
44
|
+
{ id: 'amber', label: 'Amber CRT' },
|
|
45
|
+
{ id: 'midnight', label: 'Midnight' },
|
|
46
|
+
{ id: 'newsprint', label: 'Newsprint' },
|
|
47
|
+
];
|
|
37
48
|
|
|
38
49
|
function readJson(file, fallback) {
|
|
39
50
|
try {
|
|
@@ -59,7 +70,7 @@ function updateConfig(patch) {
|
|
|
59
70
|
if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
|
|
60
71
|
next.weeklyBudget = Math.round(patch.weeklyBudget);
|
|
61
72
|
}
|
|
62
|
-
if (
|
|
73
|
+
if (THEMES.some((t) => t.id === patch.theme)) next.theme = patch.theme;
|
|
63
74
|
writeJson(CONFIG_FILE, next);
|
|
64
75
|
return next;
|
|
65
76
|
}
|
|
@@ -85,6 +96,23 @@ function togglePin(sessionId) {
|
|
|
85
96
|
return !has;
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
// Pure: returns a new sessionNames map with `name` set for `sessionId`
|
|
100
|
+
// (trimmed, capped at 80 chars); an empty name removes the entry.
|
|
101
|
+
function applySessionName(sessionNames, sessionId, name) {
|
|
102
|
+
const next = { ...(sessionNames || {}) };
|
|
103
|
+
const clean = String(name || '').trim().slice(0, 80);
|
|
104
|
+
if (clean) next[sessionId] = clean;
|
|
105
|
+
else delete next[sessionId];
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setSessionName(sessionId, name) {
|
|
110
|
+
const next = { ...readConfig() };
|
|
111
|
+
next.sessionNames = applySessionName(next.sessionNames, sessionId, name);
|
|
112
|
+
writeJson(CONFIG_FILE, next);
|
|
113
|
+
return next.sessionNames[sessionId] || '';
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
function readNames() {
|
|
89
117
|
return readJson(NAMES_FILE, {});
|
|
90
118
|
}
|
|
@@ -188,6 +216,7 @@ function resolvedTerminal() {
|
|
|
188
216
|
}
|
|
189
217
|
|
|
190
218
|
module.exports = {
|
|
219
|
+
THEMES,
|
|
191
220
|
readConfig,
|
|
192
221
|
configDir: () => CONFIG_DIR,
|
|
193
222
|
findOnPath,
|
|
@@ -196,6 +225,8 @@ module.exports = {
|
|
|
196
225
|
updateConfig,
|
|
197
226
|
setProjectMuted,
|
|
198
227
|
togglePin,
|
|
228
|
+
applySessionName,
|
|
229
|
+
setSessionName,
|
|
199
230
|
readNames,
|
|
200
231
|
setName,
|
|
201
232
|
readIgnores,
|
package/lib/transcripts.js
CHANGED
|
@@ -357,7 +357,11 @@ function dailyCostSeries(daysList, numDays, today = Date.now()) {
|
|
|
357
357
|
}
|
|
358
358
|
|
|
359
359
|
// Best display title for a session, with its provenance.
|
|
360
|
-
|
|
360
|
+
// `custom` is a user-set name from config.json (sessionNames); it wins over
|
|
361
|
+
// anything derived from the transcript.
|
|
362
|
+
function sessionTitle(meta, customNames) {
|
|
363
|
+
const custom = customNames && customNames[meta.sessionId];
|
|
364
|
+
if (custom) return { title: custom, source: 'custom' };
|
|
361
365
|
if (meta.aiTitle) return { title: meta.aiTitle, source: 'ai-title' };
|
|
362
366
|
if (meta.firstUserPrompt) return { title: meta.firstUserPrompt, source: 'first-prompt' };
|
|
363
367
|
if (meta.lastPrompt) return { title: meta.lastPrompt, source: 'last-prompt' };
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Self-update: figure out how this copy of the dashboard was installed, and
|
|
3
|
+
// run the matching update command. The server never takes a path or command
|
|
4
|
+
// from the client — everything derives from the app's own location.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { canonicalize } = require('./paths');
|
|
10
|
+
|
|
11
|
+
const PKG_NAME = require('../package.json').name;
|
|
12
|
+
|
|
13
|
+
// Pure: appDir + "does .git exist there" -> 'git' | 'npm' | 'npx' | 'brew' | 'unknown'.
|
|
14
|
+
// Location signals (brew Cellar, npx cache, non-npm package managers) win over
|
|
15
|
+
// a .git dir: those are package-manager-owned trees we must never git-pull in.
|
|
16
|
+
// pnpm/yarn/volta/bun globals also live under node_modules, but running
|
|
17
|
+
// `npm install -g` there installs a second copy under npm's own prefix while
|
|
18
|
+
// the running copy stays old — so they get manual instructions instead.
|
|
19
|
+
function detectInstallKind(appDir, hasGitDir) {
|
|
20
|
+
const p = canonicalize(appDir).toLowerCase();
|
|
21
|
+
if (p.includes('/cellar/') || p.includes('/homebrew/')) return 'brew';
|
|
22
|
+
if (p.includes('/_npx/')) return 'npx';
|
|
23
|
+
if (['/pnpm/', '/yarn/', '/.yarn/', '/volta/', '/.volta/', '/.bun/'].some((sig) => p.includes(sig))) return 'unknown';
|
|
24
|
+
if (hasGitDir) return 'git';
|
|
25
|
+
if (p.includes('/node_modules/')) return 'npm';
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure: install kind -> either a fixed command to run, or instructions to
|
|
30
|
+
// show. The command list is closed — nothing here ever comes from a request.
|
|
31
|
+
function updatePlan(kind, pkgName) {
|
|
32
|
+
if (kind === 'git') return { type: 'run', cmd: 'git', args: ['pull', '--ff-only'] };
|
|
33
|
+
if (kind === 'npm') return { type: 'run', cmd: 'npm', args: ['install', '-g', `${pkgName}@latest`] };
|
|
34
|
+
if (kind === 'brew') return { type: 'manual', message: `Run: brew upgrade ${pkgName}` };
|
|
35
|
+
if (kind === 'npx') return { type: 'manual', message: `Quit the dashboard and re-run npx ${pkgName} — npx fetches the newest release each time.` };
|
|
36
|
+
return { type: 'manual', message: 'Update with the tool you installed with, or download from https://github.com/JonImmsWordpressDev/claude-dashboard/releases' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure: where npm's CLI JS lives relative to the node binary's directory —
|
|
40
|
+
// the unix prefix layout and the Windows layout. Running it via our own
|
|
41
|
+
// process.execPath sidesteps both the service manager's minimal PATH
|
|
42
|
+
// (launchd/systemd ship no PATH, so bare 'npm' is ENOENT) and Windows,
|
|
43
|
+
// where 'npm' is npm.cmd and can't be spawned without a shell.
|
|
44
|
+
function npmCliCandidates(nodeDir) {
|
|
45
|
+
return [
|
|
46
|
+
path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
47
|
+
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// launchd (KeepAlive) and systemd (Restart) relaunch us after an update exit;
|
|
52
|
+
// a terminal-run process must not be killed. Signals, most reliable first:
|
|
53
|
+
// our own service definitions set CLAUDE_DASH_SERVICE=1; systemd sets
|
|
54
|
+
// INVOCATION_ID for every unit, which covers units deployed before that var
|
|
55
|
+
// existed; ppid 1 on macOS covers launchd agents installed before it (kept
|
|
56
|
+
// mac-only so an orphaned `dashboard &` run on Linux isn't misread).
|
|
57
|
+
function serviceManaged() {
|
|
58
|
+
if (process.env.CLAUDE_DASH_SERVICE === '1') return true;
|
|
59
|
+
if (process.env.INVOCATION_ID) return true;
|
|
60
|
+
return process.platform === 'darwin' && process.ppid === 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readPkgVersion(appDir) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).version || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runSelfUpdate(appDir) {
|
|
72
|
+
const kind = detectInstallKind(appDir, fs.existsSync(path.join(appDir, '.git')));
|
|
73
|
+
const plan = updatePlan(kind, PKG_NAME);
|
|
74
|
+
if (plan.type === 'manual') {
|
|
75
|
+
return Promise.resolve({ ok: false, kind, manual: plan.message });
|
|
76
|
+
}
|
|
77
|
+
let cmd = plan.cmd;
|
|
78
|
+
let args = plan.args;
|
|
79
|
+
if (kind === 'npm') {
|
|
80
|
+
const cli = npmCliCandidates(path.dirname(process.execPath)).find((p) => fs.existsSync(p));
|
|
81
|
+
if (cli) {
|
|
82
|
+
cmd = process.execPath;
|
|
83
|
+
args = [cli, ...plan.args];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const before = readPkgVersion(appDir);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
execFile(cmd, args, { cwd: appDir, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
resolve({ ok: false, kind, error: String(stderr || err.message).slice(0, 300) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// The command exiting 0 isn't proof anything changed: npm can re-fetch
|
|
94
|
+
// the same version while a release is still publishing, and git can
|
|
95
|
+
// pull nothing. Only a version change on disk earns a restart.
|
|
96
|
+
const after = readPkgVersion(appDir);
|
|
97
|
+
const changed = Boolean(after && before && after !== before);
|
|
98
|
+
resolve({
|
|
99
|
+
ok: true,
|
|
100
|
+
kind,
|
|
101
|
+
version: after || undefined,
|
|
102
|
+
unchanged: !changed,
|
|
103
|
+
willRestart: changed && serviceManaged(),
|
|
104
|
+
output: String(stdout).slice(0, 300),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { detectInstallKind, updatePlan, npmCliCandidates, runSelfUpdate };
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -101,6 +101,79 @@
|
|
|
101
101
|
--flap-split: rgba(0, 0, 0, 0.5);
|
|
102
102
|
--amber-ink: #050a06;
|
|
103
103
|
}
|
|
104
|
+
/* Amber CRT: the VT220's other phosphor. Real amber terminals signaled with
|
|
105
|
+
two intensities, so body text is dim amber, "working" is standard amber,
|
|
106
|
+
and "needs you" is the white-hot bright intensity. Red still means broken. */
|
|
107
|
+
:root[data-theme="amber"] {
|
|
108
|
+
--disp-font: 'JetBrains Mono', ui-monospace, monospace;
|
|
109
|
+
--bg: #0a0602;
|
|
110
|
+
--surface: #140d04;
|
|
111
|
+
--surface2: #1f1508;
|
|
112
|
+
--ink: #d99a3f;
|
|
113
|
+
--muted: #a1712a;
|
|
114
|
+
--faint: #6e4c1a;
|
|
115
|
+
--line: #241708;
|
|
116
|
+
--accent: #ffb020;
|
|
117
|
+
--warn: #ffe6c0;
|
|
118
|
+
--bad: #ff4b33;
|
|
119
|
+
--chip-bg: #1f1508;
|
|
120
|
+
--warn-bg: rgba(255, 230, 192, 0.10);
|
|
121
|
+
--accent-bg: rgba(255, 176, 32, 0.10);
|
|
122
|
+
--glow: 0 0 9px;
|
|
123
|
+
--flap: #140d04;
|
|
124
|
+
--flap-hi: #1b1206;
|
|
125
|
+
--flap-split: rgba(0, 0, 0, 0.5);
|
|
126
|
+
--amber-ink: #0a0602;
|
|
127
|
+
}
|
|
128
|
+
/* Midnight: the board on the night shift. Deep blue-black enamel, frost
|
|
129
|
+
ink, ice-cyan boarding lamp; a warm lantern amber is the one warm thing
|
|
130
|
+
on screen, so "needs you" pops hardest. */
|
|
131
|
+
:root[data-theme="midnight"] {
|
|
132
|
+
--disp-font: 'Oswald', 'Arial Narrow', sans-serif;
|
|
133
|
+
--bg: #0a1220;
|
|
134
|
+
--surface: #111a2c;
|
|
135
|
+
--surface2: #1a2740;
|
|
136
|
+
--ink: #d6e2f0;
|
|
137
|
+
--muted: #8299b5;
|
|
138
|
+
--faint: #4d6076;
|
|
139
|
+
--line: #1c2a42;
|
|
140
|
+
--accent: #5bc8d8;
|
|
141
|
+
--warn: #ffc466;
|
|
142
|
+
--bad: #ff6b5e;
|
|
143
|
+
--chip-bg: #1a2740;
|
|
144
|
+
--warn-bg: rgba(255, 196, 102, 0.12);
|
|
145
|
+
--accent-bg: rgba(91, 200, 216, 0.10);
|
|
146
|
+
--glow: 0 0 6px;
|
|
147
|
+
--flap: #111a2c;
|
|
148
|
+
--flap-hi: #16223a;
|
|
149
|
+
--flap-split: rgba(0, 0, 0, 0.45);
|
|
150
|
+
--amber-ink: #0a1220;
|
|
151
|
+
}
|
|
152
|
+
/* Newsprint: always-light broadsheet. Grey newsprint stock, serif section
|
|
153
|
+
headers, printed-green ink for "boarding", and one red: stop-press red
|
|
154
|
+
for "needs you". Errors are oxblood — a different red, darker. No glow;
|
|
155
|
+
paper doesn't. */
|
|
156
|
+
:root[data-theme="newsprint"] {
|
|
157
|
+
--disp-font: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
|
|
158
|
+
--bg: #eeebe1;
|
|
159
|
+
--surface: #e6e2d6;
|
|
160
|
+
--surface2: #dbd6c7;
|
|
161
|
+
--ink: #1c1914;
|
|
162
|
+
--muted: #5f594c;
|
|
163
|
+
--faint: #928a79;
|
|
164
|
+
--line: #c9c3b2;
|
|
165
|
+
--accent: #23704b;
|
|
166
|
+
--warn: #c22c1c;
|
|
167
|
+
--bad: #6b1005;
|
|
168
|
+
--chip-bg: #dbd6c7;
|
|
169
|
+
--warn-bg: rgba(194, 44, 28, 0.08);
|
|
170
|
+
--accent-bg: rgba(35, 112, 75, 0.08);
|
|
171
|
+
--glow: none;
|
|
172
|
+
--flap: #e6e2d6;
|
|
173
|
+
--flap-hi: #ece8dd;
|
|
174
|
+
--flap-split: rgba(0, 0, 0, 0.08);
|
|
175
|
+
--amber-ink: #eeebe1;
|
|
176
|
+
}
|
|
104
177
|
* { box-sizing: border-box; margin: 0; }
|
|
105
178
|
html { -webkit-text-size-adjust: 100%; }
|
|
106
179
|
body {
|
|
@@ -329,6 +402,15 @@
|
|
|
329
402
|
.s-title[data-session], .di-title[data-session] { cursor: pointer; }
|
|
330
403
|
.s-title[data-session]:hover, .di-title[data-session]:hover { color: var(--accent); }
|
|
331
404
|
|
|
405
|
+
/* inline session rename (pencil button swaps the title for this input) */
|
|
406
|
+
.rename-input {
|
|
407
|
+
font: inherit; font-size: 12.5px; color: var(--ink);
|
|
408
|
+
background: var(--surface2); border: 1px solid var(--accent);
|
|
409
|
+
border-radius: 2px; padding: 2px 6px; flex: 1; min-width: 0; width: 100%;
|
|
410
|
+
box-sizing: border-box;
|
|
411
|
+
}
|
|
412
|
+
.rename-input:focus { outline: none; }
|
|
413
|
+
|
|
332
414
|
/* transcript rendering */
|
|
333
415
|
.t-turn { margin: 10px 0; }
|
|
334
416
|
.t-user {
|
|
@@ -861,6 +943,10 @@ function copyBtn(cmd) {
|
|
|
861
943
|
|
|
862
944
|
// Live sessions are already open somewhere; only offer open on idle ones.
|
|
863
945
|
let liveIds = new Set();
|
|
946
|
+
function renameBtn(sessionId, title) {
|
|
947
|
+
return `<button class="copy rename-btn" data-rename-session="${esc(sessionId)}" data-title="${esc(title || '')}" title="Rename this session">✎</button>`;
|
|
948
|
+
}
|
|
949
|
+
|
|
864
950
|
function openBtn(sessionId) {
|
|
865
951
|
if (liveIds.has(sessionId)) return '';
|
|
866
952
|
return `<button class="copy open-btn" data-open="${esc(sessionId)}" title="Open this session">open ⬈</button>`;
|
|
@@ -899,6 +985,7 @@ function liveCard(s) {
|
|
|
899
985
|
${modelChip(s.model)}
|
|
900
986
|
<span class="dep-elapsed">${elapsed(s.startedAt)}</span>
|
|
901
987
|
${statusFlap(s)}
|
|
988
|
+
${renameBtn(s.sessionId, s.title)}
|
|
902
989
|
${copyBtn(s.resumeCommand)}
|
|
903
990
|
</div>
|
|
904
991
|
</div>`;
|
|
@@ -912,6 +999,7 @@ function projectCard(p) {
|
|
|
912
999
|
sess.worktree ? `<span class="wt mono">⎇ ${esc(sess.worktree)}</span>` : ''}${esc(sess.title)}</span>
|
|
913
1000
|
${modelChip(sess.model)}
|
|
914
1001
|
<span class="s-when">${relTime(sess.lastActivityAt)}</span>
|
|
1002
|
+
${renameBtn(sess.sessionId, sess.title)}
|
|
915
1003
|
${openBtn(sess.sessionId)}
|
|
916
1004
|
${copyBtn(sess.resumeCommand)}
|
|
917
1005
|
</li>`).join('')}</ul>`
|
|
@@ -944,6 +1032,7 @@ function renderPinned(state) {
|
|
|
944
1032
|
${modelChip(p.model)}
|
|
945
1033
|
<span class="s-when">${esc(p.projectName)}</span>
|
|
946
1034
|
<span class="s-when">${relTime(p.lastActivityAt)}</span>
|
|
1035
|
+
${renameBtn(p.sessionId, p.title)}
|
|
947
1036
|
<button class="copy" data-pin="${esc(p.sessionId)}" title="Unpin">unpin</button>
|
|
948
1037
|
</div>`).join(''));
|
|
949
1038
|
}
|
|
@@ -1044,7 +1133,11 @@ function renderDigest(state) {
|
|
|
1044
1133
|
patch($('#digest-body'), html);
|
|
1045
1134
|
}
|
|
1046
1135
|
|
|
1136
|
+
let renderDeferred = false; // an SSE tick arrived while a rename input was open
|
|
1047
1137
|
function render(state) {
|
|
1138
|
+
// Rebuilding the DOM mid-edit would wipe the rename input; catch up afterwards.
|
|
1139
|
+
if (document.querySelector('.rename-input')) { renderDeferred = true; return; }
|
|
1140
|
+
renderDeferred = false;
|
|
1048
1141
|
if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
|
|
1049
1142
|
else delete document.documentElement.dataset.theme;
|
|
1050
1143
|
liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
|
|
@@ -1283,6 +1376,7 @@ function renderDetail(d, name) {
|
|
|
1283
1376
|
s.worktree ? `<span class="wt mono">⎇ ${esc(s.worktree)}</span> ` : ''}${esc(s.title)}</span>
|
|
1284
1377
|
${fmtCost(s.estCost) ? `<span class="s-when">≈${fmtCost(s.estCost)}</span>` : ''}
|
|
1285
1378
|
<span class="s-when">${relTime(s.lastActivityAt)}</span>
|
|
1379
|
+
${renameBtn(s.sessionId, s.title)}
|
|
1286
1380
|
${openBtn(s.sessionId)}
|
|
1287
1381
|
${copyBtn(s.resumeCommand)}
|
|
1288
1382
|
</li>`).join('')}</ul>`
|
|
@@ -1652,6 +1746,108 @@ async function runSearch(q, deep) {
|
|
|
1652
1746
|
}
|
|
1653
1747
|
|
|
1654
1748
|
// ---------- settings ----------
|
|
1749
|
+
// One-click self-update. The server decides what to run from its own install
|
|
1750
|
+
// location; we just kick it off and wait for the new version to come back up.
|
|
1751
|
+
async function applyUpdate(out, currentVersion) {
|
|
1752
|
+
out.textContent = 'updating…';
|
|
1753
|
+
let startPid = null;
|
|
1754
|
+
try {
|
|
1755
|
+
startPid = (await (await fetch('/api/health', { cache: 'no-store' })).json()).pid || null;
|
|
1756
|
+
} catch {}
|
|
1757
|
+
let r;
|
|
1758
|
+
try {
|
|
1759
|
+
r = await (await fetch('/api/update', { method: 'POST' })).json();
|
|
1760
|
+
} catch {
|
|
1761
|
+
out.textContent = "couldn't reach the server";
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (r.manual) { out.textContent = r.manual; return; }
|
|
1765
|
+
if (!r.ok) { out.textContent = `update failed: ${r.error || 'unknown error'}`; return; }
|
|
1766
|
+
if (r.unchanged) {
|
|
1767
|
+
out.textContent = r.kind === 'npm'
|
|
1768
|
+
? 'nothing newer on npm yet — the release may still be publishing, try again in a few minutes'
|
|
1769
|
+
: 'nothing new arrived — try again later';
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
if (!r.willRestart) {
|
|
1773
|
+
out.textContent = `updated to ${r.version || 'the new version'} — restart the dashboard to finish`;
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
out.textContent = 'updated — restarting…';
|
|
1777
|
+
// The old process exits moments after replying; poll health until a
|
|
1778
|
+
// different server answers — new version, new pid, or back up after a
|
|
1779
|
+
// visible down-gap (pid catches a fast relaunch the version can't).
|
|
1780
|
+
const t0 = Date.now();
|
|
1781
|
+
let sawDown = false;
|
|
1782
|
+
(async function poll() {
|
|
1783
|
+
try {
|
|
1784
|
+
const h = await (await fetch('/api/health', { cache: 'no-store' })).json();
|
|
1785
|
+
if (h.version !== currentVersion || (startPid && h.pid && h.pid !== startPid) || sawDown) {
|
|
1786
|
+
location.reload();
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
} catch {
|
|
1790
|
+
sawDown = true;
|
|
1791
|
+
}
|
|
1792
|
+
if (Date.now() - t0 < 60000) setTimeout(poll, 1500);
|
|
1793
|
+
else out.textContent = "server didn't come back — check the logs";
|
|
1794
|
+
})();
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Swap a session title for a text input. Enter/blur saves, Escape cancels,
|
|
1798
|
+
// an empty name goes back to the automatic title.
|
|
1799
|
+
function startRename(btn) {
|
|
1800
|
+
const id = btn.dataset.renameSession;
|
|
1801
|
+
const row = btn.closest('li, .pin-row, .dep-row');
|
|
1802
|
+
const titleEl = row && row.querySelector(`[data-session="${CSS.escape(id)}"]`);
|
|
1803
|
+
if (!titleEl || row.querySelector('.rename-input')) return;
|
|
1804
|
+
const before = btn.dataset.title || '';
|
|
1805
|
+
const input = document.createElement('input');
|
|
1806
|
+
input.className = 'rename-input';
|
|
1807
|
+
input.type = 'text';
|
|
1808
|
+
input.maxLength = 80;
|
|
1809
|
+
input.value = before;
|
|
1810
|
+
input.placeholder = 'Session name — leave empty to reset';
|
|
1811
|
+
titleEl.hidden = true;
|
|
1812
|
+
titleEl.after(input);
|
|
1813
|
+
input.focus();
|
|
1814
|
+
input.select();
|
|
1815
|
+
let done = false;
|
|
1816
|
+
const finish = async (save) => {
|
|
1817
|
+
if (done) return;
|
|
1818
|
+
done = true;
|
|
1819
|
+
const val = input.value.trim();
|
|
1820
|
+
input.remove();
|
|
1821
|
+
titleEl.hidden = false;
|
|
1822
|
+
if (save && val !== before) {
|
|
1823
|
+
try {
|
|
1824
|
+
const r = await fetch('/api/config', {
|
|
1825
|
+
method: 'POST',
|
|
1826
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1827
|
+
body: JSON.stringify({ renameSession: id, name: val }),
|
|
1828
|
+
});
|
|
1829
|
+
const out = await r.json();
|
|
1830
|
+
if (out.ok) {
|
|
1831
|
+
// Drawers aren't re-rendered by SSE, so patch the title in place.
|
|
1832
|
+
const text = [...titleEl.childNodes].find((n) => n.nodeType === 3);
|
|
1833
|
+
if (text) text.textContent = out.title; else titleEl.append(out.title);
|
|
1834
|
+
btn.dataset.title = out.title;
|
|
1835
|
+
toast(val ? `Renamed to "${val}"` : 'Name reset to the automatic title');
|
|
1836
|
+
} else toast(`Couldn't rename: ${out.error}`);
|
|
1837
|
+
} catch {
|
|
1838
|
+
toast("Couldn't reach the server");
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (renderDeferred && lastState) render(lastState);
|
|
1842
|
+
};
|
|
1843
|
+
input.addEventListener('keydown', (e) => {
|
|
1844
|
+
e.stopPropagation(); // keep the page's shortcuts (Esc, /, etc.) out of the edit
|
|
1845
|
+
if (e.key === 'Enter') finish(true);
|
|
1846
|
+
else if (e.key === 'Escape') finish(false);
|
|
1847
|
+
});
|
|
1848
|
+
input.addEventListener('blur', () => finish(true));
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1655
1851
|
async function saveSetting(endpoint, payload, note) {
|
|
1656
1852
|
try {
|
|
1657
1853
|
const r = await fetch(endpoint, {
|
|
@@ -1680,6 +1876,7 @@ async function openSettings() {
|
|
|
1680
1876
|
}
|
|
1681
1877
|
return '';
|
|
1682
1878
|
};
|
|
1879
|
+
const themeId = (c.themes || []).some((t) => t.id === c.theme) ? c.theme : 'board';
|
|
1683
1880
|
const general = `
|
|
1684
1881
|
<div class="set-row">
|
|
1685
1882
|
<span class="set-label">Notifications<span class="set-sub">waiting-for-input and stuck-session alerts</span></span>
|
|
@@ -1702,10 +1899,9 @@ async function openSettings() {
|
|
|
1702
1899
|
<input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
|
|
1703
1900
|
</div>
|
|
1704
1901
|
<div class="set-row">
|
|
1705
|
-
<span class="set-label">Theme<span class="set-sub">Departures board follows light/dark;
|
|
1902
|
+
<span class="set-label">Theme<span class="set-sub">Departures board follows light/dark; Newsprint is always-light; the rest are always-dark</span></span>
|
|
1706
1903
|
<select id="set-theme">
|
|
1707
|
-
|
|
1708
|
-
<option value="phosphor" ${c.theme === 'phosphor' ? 'selected' : ''}>Phosphor</option>
|
|
1904
|
+
${(c.themes || []).map((t) => `<option value="${t.id}" ${t.id === themeId ? 'selected' : ''}>${esc(t.label)}</option>`).join('')}
|
|
1709
1905
|
</select>
|
|
1710
1906
|
</div>
|
|
1711
1907
|
<div class="set-row">
|
|
@@ -1752,7 +1948,13 @@ async function openSettings() {
|
|
|
1752
1948
|
const r = await (await fetch('/api/update-check')).json();
|
|
1753
1949
|
if (r.error) out.textContent = `couldn't check: ${r.error}`;
|
|
1754
1950
|
else if (r.upToDate) out.textContent = `up to date (${r.current})`;
|
|
1755
|
-
else
|
|
1951
|
+
else {
|
|
1952
|
+
out.innerHTML = `${esc(r.latest)} available — <a href="${esc(r.url)}" target="_blank" rel="noopener" style="color:var(--accent)">release notes</a> · <a href="#" id="set-update-now" style="color:var(--warn)">update now</a>`;
|
|
1953
|
+
$('#set-update-now').addEventListener('click', (ev) => {
|
|
1954
|
+
ev.preventDefault();
|
|
1955
|
+
applyUpdate(out, r.current);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1756
1958
|
} catch {
|
|
1757
1959
|
out.textContent = "couldn't reach GitHub";
|
|
1758
1960
|
}
|
|
@@ -1846,6 +2048,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1846
2048
|
}
|
|
1847
2049
|
return;
|
|
1848
2050
|
}
|
|
2051
|
+
const rb = e.target.closest('[data-rename-session]');
|
|
2052
|
+
if (rb) { startRename(rb); return; }
|
|
1849
2053
|
const t = e.target.closest('[data-session]');
|
|
1850
2054
|
if (t) { openTranscript(t.dataset.session, t.dataset.hl); return; }
|
|
1851
2055
|
const nb = e.target.closest('[data-new]');
|
package/server.js
CHANGED
|
@@ -17,6 +17,7 @@ const cfg = require('./lib/config');
|
|
|
17
17
|
const { isProjectMuted } = require('./lib/notify');
|
|
18
18
|
const { recentCommits, linkCommitsToSessions } = require('./lib/gitlog');
|
|
19
19
|
const { demoState, demoStats, demoSession } = require('./lib/demo');
|
|
20
|
+
const { runSelfUpdate } = require('./lib/update');
|
|
20
21
|
const DEMO = process.env.CLAUDE_DASH_DEMO === '1';
|
|
21
22
|
|
|
22
23
|
const PORT = Number(process.env.CLAUDE_DASH_PORT) || 4517;
|
|
@@ -114,7 +115,7 @@ const server = http.createServer((req, res) => {
|
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
if (url === '/api/config' && req.method === 'GET') {
|
|
117
|
-
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], version: VERSION, errors: [] });
|
|
118
|
+
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], themes: cfg.THEMES, version: VERSION, errors: [] });
|
|
118
119
|
}
|
|
119
120
|
if (url === '/api/events') {
|
|
120
121
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
@@ -179,6 +180,27 @@ const server = http.createServer((req, res) => {
|
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// Manual only, no client input: updates this install in place (git pull or
|
|
184
|
+
// npm -g reinstall, decided server-side from our own location). When the
|
|
185
|
+
// process is service-managed, it exits after replying and launchd/systemd
|
|
186
|
+
// relaunch it on the new code.
|
|
187
|
+
if (url === '/api/update' && req.method === 'POST') {
|
|
188
|
+
if (!sameOrigin(req)) return json(res, 403, { ok: false, error: 'forbidden' });
|
|
189
|
+
runSelfUpdate(__dirname).then((result) => {
|
|
190
|
+
json(res, result.ok || result.manual ? 200 : 500, result);
|
|
191
|
+
if (result.ok && result.willRestart) {
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
console.log(`self-update to ${result.version} applied — exiting so the service manager relaunches`);
|
|
194
|
+
// launchd's KeepAlive=true relaunches on any exit, so macOS exits
|
|
195
|
+
// clean. Linux exits 1 because units deployed before this feature
|
|
196
|
+
// have Restart=on-failure and never get the updated unit file.
|
|
197
|
+
process.exit(process.platform === 'darwin' ? 0 : 1);
|
|
198
|
+
}, 500);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
if (url === '/api/events') {
|
|
183
205
|
res.writeHead(200, {
|
|
184
206
|
'Content-Type': 'text/event-stream',
|
|
@@ -230,6 +252,7 @@ const server = http.createServer((req, res) => {
|
|
|
230
252
|
if (url === '/api/config' && req.method === 'GET') {
|
|
231
253
|
json(res, 200, {
|
|
232
254
|
...cfg.readConfig(),
|
|
255
|
+
themes: cfg.THEMES,
|
|
233
256
|
version: VERSION,
|
|
234
257
|
errors: collector.state.errors || [],
|
|
235
258
|
terminals: cfg.detectTerminals(),
|
|
@@ -256,6 +279,13 @@ const server = http.createServer((req, res) => {
|
|
|
256
279
|
collector.assemble();
|
|
257
280
|
return json(res, 200, { ok: true, pinned });
|
|
258
281
|
}
|
|
282
|
+
if (payload.renameSession !== undefined) {
|
|
283
|
+
const id = String(payload.renameSession || '');
|
|
284
|
+
if (!collector.findSessionFile(id)) return json(res, 404, { ok: false, error: 'unknown session' });
|
|
285
|
+
const name = cfg.setSessionName(id, String(payload.name || ''));
|
|
286
|
+
collector.assemble();
|
|
287
|
+
return json(res, 200, { ok: true, name, title: collector.findSessionFile(id).title });
|
|
288
|
+
}
|
|
259
289
|
if (payload.mutePath !== undefined) {
|
|
260
290
|
const known = collector
|
|
261
291
|
.projectPaths()
|