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 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.
@@ -4,7 +4,8 @@ After=default.target
4
4
 
5
5
  [Service]
6
6
  ExecStart=__NODE_PATH__ __APP_DIR__/server.js
7
- Restart=on-failure
7
+ Environment=CLAUDE_DASH_SERVICE=1
8
+ Restart=always
8
9
  RestartSec=3
9
10
 
10
11
  [Install]
@@ -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 } = sessionTitle(m);
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: sessionTitle(m).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: sessionTitle(m).title, projectName: friendlyName(g.path) };
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 (patch.theme === 'board' || patch.theme === 'phosphor') next.theme = patch.theme;
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,
@@ -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
- function sessionTitle(meta) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.7.0",
3
+ "version": "1.9.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
@@ -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; Phosphor is always-dark green terminal</span></span>
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
- <option value="board" ${c.theme !== 'phosphor' ? 'selected' : ''}>Departures board</option>
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 out.innerHTML = `${esc(r.latest)} available — <a href="${esc(r.url)}" target="_blank" rel="noopener" style="color:var(--accent)">release notes</a>, then git pull`;
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()