claude-mission-control 1.7.0 → 1.8.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
 
@@ -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/config.js CHANGED
@@ -35,6 +35,17 @@ const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
35
35
 
36
36
  const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], theme: 'board' };
37
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
+ ];
48
+
38
49
  function readJson(file, fallback) {
39
50
  try {
40
51
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -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
  }
@@ -188,6 +199,7 @@ function resolvedTerminal() {
188
199
  }
189
200
 
190
201
  module.exports = {
202
+ THEMES,
191
203
  readConfig,
192
204
  configDir: () => CONFIG_DIR,
193
205
  findOnPath,
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.8.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 {
@@ -1652,6 +1725,54 @@ async function runSearch(q, deep) {
1652
1725
  }
1653
1726
 
1654
1727
  // ---------- settings ----------
1728
+ // One-click self-update. The server decides what to run from its own install
1729
+ // location; we just kick it off and wait for the new version to come back up.
1730
+ async function applyUpdate(out, currentVersion) {
1731
+ out.textContent = 'updating…';
1732
+ let startPid = null;
1733
+ try {
1734
+ startPid = (await (await fetch('/api/health', { cache: 'no-store' })).json()).pid || null;
1735
+ } catch {}
1736
+ let r;
1737
+ try {
1738
+ r = await (await fetch('/api/update', { method: 'POST' })).json();
1739
+ } catch {
1740
+ out.textContent = "couldn't reach the server";
1741
+ return;
1742
+ }
1743
+ if (r.manual) { out.textContent = r.manual; return; }
1744
+ if (!r.ok) { out.textContent = `update failed: ${r.error || 'unknown error'}`; return; }
1745
+ if (r.unchanged) {
1746
+ out.textContent = r.kind === 'npm'
1747
+ ? 'nothing newer on npm yet — the release may still be publishing, try again in a few minutes'
1748
+ : 'nothing new arrived — try again later';
1749
+ return;
1750
+ }
1751
+ if (!r.willRestart) {
1752
+ out.textContent = `updated to ${r.version || 'the new version'} — restart the dashboard to finish`;
1753
+ return;
1754
+ }
1755
+ out.textContent = 'updated — restarting…';
1756
+ // The old process exits moments after replying; poll health until a
1757
+ // different server answers — new version, new pid, or back up after a
1758
+ // visible down-gap (pid catches a fast relaunch the version can't).
1759
+ const t0 = Date.now();
1760
+ let sawDown = false;
1761
+ (async function poll() {
1762
+ try {
1763
+ const h = await (await fetch('/api/health', { cache: 'no-store' })).json();
1764
+ if (h.version !== currentVersion || (startPid && h.pid && h.pid !== startPid) || sawDown) {
1765
+ location.reload();
1766
+ return;
1767
+ }
1768
+ } catch {
1769
+ sawDown = true;
1770
+ }
1771
+ if (Date.now() - t0 < 60000) setTimeout(poll, 1500);
1772
+ else out.textContent = "server didn't come back — check the logs";
1773
+ })();
1774
+ }
1775
+
1655
1776
  async function saveSetting(endpoint, payload, note) {
1656
1777
  try {
1657
1778
  const r = await fetch(endpoint, {
@@ -1680,6 +1801,7 @@ async function openSettings() {
1680
1801
  }
1681
1802
  return '';
1682
1803
  };
1804
+ const themeId = (c.themes || []).some((t) => t.id === c.theme) ? c.theme : 'board';
1683
1805
  const general = `
1684
1806
  <div class="set-row">
1685
1807
  <span class="set-label">Notifications<span class="set-sub">waiting-for-input and stuck-session alerts</span></span>
@@ -1702,10 +1824,9 @@ async function openSettings() {
1702
1824
  <input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
1703
1825
  </div>
1704
1826
  <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>
1827
+ <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
1828
  <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>
1829
+ ${(c.themes || []).map((t) => `<option value="${t.id}" ${t.id === themeId ? 'selected' : ''}>${esc(t.label)}</option>`).join('')}
1709
1830
  </select>
1710
1831
  </div>
1711
1832
  <div class="set-row">
@@ -1752,7 +1873,13 @@ async function openSettings() {
1752
1873
  const r = await (await fetch('/api/update-check')).json();
1753
1874
  if (r.error) out.textContent = `couldn't check: ${r.error}`;
1754
1875
  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`;
1876
+ else {
1877
+ 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>`;
1878
+ $('#set-update-now').addEventListener('click', (ev) => {
1879
+ ev.preventDefault();
1880
+ applyUpdate(out, r.current);
1881
+ });
1882
+ }
1756
1883
  } catch {
1757
1884
  out.textContent = "couldn't reach GitHub";
1758
1885
  }
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(),