claude-mission-control 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,10 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
35
35
 
36
36
  *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
37
37
 
38
+ **New in 1.15.0**
39
+
40
+ - Phone view: see who needs you and your limits on your phone, over home Wi-Fi or Tailscale. Opt-in, read-only, locked to a pairing key in the link.
41
+
38
42
  **New in 1.14.0**
39
43
 
40
44
  - Cost per commit in each project's slide-over: every commit made during a Claude session shows its share of that session's cost, with a summary of cost per commit and spend in sessions that committed nothing.
@@ -228,6 +232,16 @@ A SwiftBar plugin lives in `menubar/claude-dash.15s.sh`. The menu bar shows `❯
228
232
 
229
233
  Setup: `brew install --cask swiftbar`, then point SwiftBar's plugin folder at this repo's `menubar/` directory. The plugin refreshes every 15 seconds (rename the file to change the interval).
230
234
 
235
+ ## Phone view
236
+
237
+ See who needs you from your phone. Settings → **Phone view** turns on a second, read-only listener on your home Wi-Fi or on Tailscale. It shows each running session, what it's waiting on, and your limit meters with the forecast.
238
+
239
+ 1. Turn on **Phone view** in settings. If macOS asks whether node may accept incoming connections, click Allow.
240
+ 2. Pick **Home Wi-Fi**, or **Tailscale** to reach it away from home (Tailscale on both devices, your devices only).
241
+ 3. Click **copy link**, then paste it into Safari on your phone (Universal Clipboard) or AirDrop it. Add it to your home screen if you like.
242
+
243
+ The link carries a pairing key after `#`, which browsers never send to a server; the page sends it as a header instead. Without the key the listener answers nothing, and 20 wrong keys from one address lock that address out for ten minutes. **new link** replaces the key, so every old link stops working. The phone listener serves only that page and its feed: no transcripts, no file paths, nothing that runs a command. It stays off until you turn it on, never runs in demo mode, and follows your Mac's address when the network changes. The port is 4518 (`CLAUDE_DASH_REMOTE_PORT` changes it).
244
+
231
245
  ## Settings
232
246
 
233
247
  The ⚙ gear in the header opens settings — no JSON editing required:
package/lib/config.js CHANGED
@@ -34,7 +34,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
34
34
  const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
35
35
  const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
36
36
 
37
- const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, sessionNotes: {}, idleNagMinutes: 60, theme: 'board' };
37
+ const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, sessionNotes: {}, idleNagMinutes: 60, theme: 'board', remoteEnabled: false, remoteMode: 'lan' };
38
38
 
39
39
  // The one theme list: updateConfig validates against it and GET /api/config
40
40
  // serves it, so the settings dropdown can never offer a value the server
@@ -83,6 +83,13 @@ function updateConfig(patch) {
83
83
  next.idleNagMinutes = Math.round(patch.idleNagMinutes);
84
84
  }
85
85
  if (THEMES.some((t) => t.id === patch.theme)) next.theme = patch.theme;
86
+ // Phone view: turning it on mints a pairing key; rotating replaces it, so
87
+ // every link handed out before stops working.
88
+ if (typeof patch.remoteEnabled === 'boolean') next.remoteEnabled = patch.remoteEnabled;
89
+ if (patch.remoteMode === 'lan' || patch.remoteMode === 'tailscale') next.remoteMode = patch.remoteMode;
90
+ if (patch.rotateRemoteKey === true || (next.remoteEnabled && !next.remoteKey)) {
91
+ next.remoteKey = require('crypto').randomBytes(24).toString('base64url');
92
+ }
86
93
  writeJson(CONFIG_FILE, next);
87
94
  return next;
88
95
  }
package/lib/remote.js ADDED
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+ // Phone view: an opt-in second listener on the home network or Tailscale.
3
+ //
4
+ // It serves exactly three things: the phone page, the bundled fonts, and a
5
+ // small read-only JSON feed (who needs you, limits). No transcripts, no
6
+ // paths, no /api/* routes, nothing that runs a command. The feed needs the
7
+ // pairing key as a Bearer header; the key travels to the phone in the URL
8
+ // fragment (#k=...), which browsers never send to a server.
9
+ const http = require('http');
10
+ const crypto = require('crypto');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const PORT = Number(process.env.CLAUDE_DASH_REMOTE_PORT) || 4518;
16
+ const FAIL_LIMIT = 20; // bad keys per address per window before we stop answering
17
+ const FAIL_WINDOW_MS = 10 * 60 * 1000;
18
+
19
+ function newKey() {
20
+ return crypto.randomBytes(24).toString('base64url');
21
+ }
22
+
23
+ const isTailscale = (ip) => {
24
+ const [a, b] = ip.split('.').map(Number);
25
+ return a === 100 && b >= 64 && b <= 127; // 100.64.0.0/10, Tailscale's CGNAT range
26
+ };
27
+ const isPrivate = (ip) => {
28
+ const [a, b] = ip.split('.').map(Number);
29
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
30
+ };
31
+
32
+ // The address to listen on for a mode, from os.networkInterfaces() output.
33
+ function pickAddress(ifaces, mode) {
34
+ const v4 = [];
35
+ for (const [name, list] of Object.entries(ifaces || {})) {
36
+ for (const a of list || []) {
37
+ if ((a.family === 'IPv4' || a.family === 4) && !a.internal) v4.push({ name, ip: a.address });
38
+ }
39
+ }
40
+ if (mode === 'tailscale') return (v4.find((a) => isTailscale(a.ip)) || {}).ip || null;
41
+ const lan = v4.filter((a) => isPrivate(a.ip) && !isTailscale(a.ip));
42
+ // Prefer the usual Wi-Fi/Ethernet interfaces over VPN and bridge ones.
43
+ return (lan.find((a) => /^(en|eth|wl)/.test(a.name)) || lan[0] || {}).ip || null;
44
+ }
45
+
46
+ function checkKey(authHeader, key) {
47
+ if (!key || typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) return false;
48
+ const a = Buffer.from(authHeader.slice(7));
49
+ const b = Buffer.from(key);
50
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
51
+ }
52
+
53
+ // The phone feed: only what the phone page shows.
54
+ function mobileState(state) {
55
+ const s = state || {};
56
+ const live = (s.liveSessions || []).map((l) => ({
57
+ projectName: l.projectName,
58
+ title: l.title || '',
59
+ status: l.status,
60
+ reason: l.waitingReason ? { kind: l.waitingReason.kind, text: String(l.waitingReason.text || '').slice(0, 200) } : null,
61
+ waitingMin: l.waitingMin ?? null,
62
+ quietMin: l.quietMin ?? null,
63
+ startedAt: l.startedAt || null,
64
+ estCost: l.estCost ?? null,
65
+ contextPct: l.context ? l.context.pct : null,
66
+ background: !!l.background,
67
+ }));
68
+ const q = s.quota;
69
+ const f = s.forecast || {};
70
+ return {
71
+ generatedAt: s.generatedAt || Date.now(),
72
+ waiting: live.filter((l) => l.status === 'waiting').length,
73
+ live,
74
+ limits: q && !q.stale ? {
75
+ session: q.utilization ?? null,
76
+ week: q.weeklyUtilization ?? null,
77
+ sessionHitAt: f.session ? f.session.hitAt : null,
78
+ weekHitAt: f.week ? f.week.hitAt : null,
79
+ } : null,
80
+ };
81
+ }
82
+
83
+ function createRemoteServer({ getState, readConfig, publicDir }) {
84
+ let server = null;
85
+ let current = null; // { host, mode }
86
+ let lastError = null;
87
+ const fails = new Map(); // ip -> { n, since }
88
+
89
+ function handle(req, res) {
90
+ const url = req.url.split('?')[0];
91
+ const ip = req.socket.remoteAddress || '';
92
+ const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
93
+ if (req.method !== 'GET') {
94
+ res.writeHead(405, base);
95
+ return res.end();
96
+ }
97
+ if (url === '/' || url === '/m') {
98
+ fs.readFile(path.join(publicDir, 'mobile.html'), (err, buf) => {
99
+ if (err) { res.writeHead(500, base); return res.end(); }
100
+ res.writeHead(200, {
101
+ ...base,
102
+ 'Content-Type': 'text/html; charset=utf-8',
103
+ 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'",
104
+ });
105
+ res.end(buf);
106
+ });
107
+ return;
108
+ }
109
+ if (url.startsWith('/fonts/')) {
110
+ const name = path.basename(url);
111
+ if (!/^[\w-]+\.woff2$/.test(name)) { res.writeHead(404, base); return res.end(); }
112
+ fs.readFile(path.join(publicDir, 'fonts', name), (err, buf) => {
113
+ if (err) { res.writeHead(404, base); return res.end(); }
114
+ res.writeHead(200, { ...base, 'Content-Type': 'font/woff2', 'Cache-Control': 'max-age=86400' });
115
+ res.end(buf);
116
+ });
117
+ return;
118
+ }
119
+ if (url === '/m/state') {
120
+ const f = fails.get(ip);
121
+ if (f && Date.now() - f.since < FAIL_WINDOW_MS && f.n >= FAIL_LIMIT) {
122
+ res.writeHead(429, base);
123
+ return res.end();
124
+ }
125
+ if (!checkKey(req.headers.authorization, readConfig().remoteKey)) {
126
+ const fresh = !f || Date.now() - f.since >= FAIL_WINDOW_MS;
127
+ fails.set(ip, fresh ? { n: 1, since: Date.now() } : { n: f.n + 1, since: f.since });
128
+ res.writeHead(401, base);
129
+ return res.end();
130
+ }
131
+ fails.delete(ip);
132
+ res.writeHead(200, { ...base, 'Content-Type': 'application/json; charset=utf-8' });
133
+ return res.end(JSON.stringify(mobileState(getState())));
134
+ }
135
+ res.writeHead(404, base);
136
+ res.end();
137
+ }
138
+
139
+ function stop() {
140
+ if (server) server.close();
141
+ server = null;
142
+ current = null;
143
+ }
144
+
145
+ // Start, move, or stop the listener to match config. Safe to call often.
146
+ function apply(cfg) {
147
+ if (!cfg.remoteEnabled || !cfg.remoteKey) {
148
+ stop();
149
+ lastError = null;
150
+ return;
151
+ }
152
+ const mode = cfg.remoteMode === 'tailscale' ? 'tailscale' : 'lan';
153
+ const host = pickAddress(os.networkInterfaces(), mode);
154
+ if (!host) {
155
+ stop();
156
+ lastError = mode === 'tailscale' ? 'No Tailscale address on this Mac. Is Tailscale running?' : 'No home network address found. Is Wi-Fi connected?';
157
+ return;
158
+ }
159
+ if (current && current.host === host) return;
160
+ stop();
161
+ lastError = null;
162
+ const s = http.createServer(handle);
163
+ s.on('error', (err) => {
164
+ lastError = err.code === 'EADDRINUSE' ? `Port ${PORT} is in use. Set CLAUDE_DASH_REMOTE_PORT to pick another.` : err.message;
165
+ if (server === s) { server = null; current = null; }
166
+ });
167
+ s.listen(PORT, host);
168
+ server = s;
169
+ current = { host, mode };
170
+ }
171
+
172
+ function status(cfg) {
173
+ const url = current && cfg.remoteKey ? `http://${current.host}:${PORT}/#k=${cfg.remoteKey}` : null;
174
+ return { enabled: !!cfg.remoteEnabled, mode: cfg.remoteMode === 'tailscale' ? 'tailscale' : 'lan', running: !!current, url, error: lastError };
175
+ }
176
+
177
+ return { apply, status, stop };
178
+ }
179
+
180
+ module.exports = { createRemoteServer, pickAddress, mobileState, checkKey, newKey, REMOTE_PORT: PORT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.14.0",
3
+ "version": "1.15.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
@@ -2423,6 +2423,61 @@ async function saveSetting(endpoint, payload, note) {
2423
2423
  }
2424
2424
  }
2425
2425
 
2426
+ // Settings → Phone view: the opt-in listener for your phone (lib/remote.js).
2427
+ function phoneViewRows(r) {
2428
+ if (!r) return '';
2429
+ const status = !r.enabled ? 'off'
2430
+ : r.error ? `<span style="color:var(--warn)">${esc(r.error)}</span>`
2431
+ : r.running ? `<span style="color:var(--accent)">on</span>` : 'starting…';
2432
+ return `
2433
+ <div class="set-row">
2434
+ <span class="set-label">Phone view<span class="set-sub">who needs you and your limits, on your phone; read-only, needs the pairing key in the link</span></span>
2435
+ <input type="checkbox" id="set-remote" ${r.enabled ? 'checked' : ''}>
2436
+ </div>
2437
+ ${r.enabled ? `
2438
+ <div class="set-row">
2439
+ <span class="set-label">Reachable from<span class="set-sub">Tailscale works away from home, on your devices only; status: ${status}</span></span>
2440
+ <select id="set-remote-mode">
2441
+ <option value="lan" ${r.mode === 'lan' ? 'selected' : ''}>Home Wi-Fi</option>
2442
+ <option value="tailscale" ${r.mode === 'tailscale' ? 'selected' : ''}>Tailscale</option>
2443
+ </select>
2444
+ </div>
2445
+ ${r.url ? `
2446
+ <div class="set-row">
2447
+ <span class="set-label">Phone link<span class="set-sub">copy it, then paste on your phone (Universal Clipboard) or AirDrop it; new link turns the old one off</span></span>
2448
+ <button class="copy" id="set-remote-copy">copy link</button>
2449
+ <button class="copy" id="set-remote-rotate">new link</button>
2450
+ </div>` : ''}` : ''}`;
2451
+ }
2452
+
2453
+ function wirePhoneView(r) {
2454
+ const toggle = $('#set-remote');
2455
+ if (!toggle) return;
2456
+ toggle.addEventListener('change', async (e) => {
2457
+ await saveSetting('/api/config', { remoteEnabled: e.target.checked }, e.target.checked ? 'Phone view on' : 'Phone view off');
2458
+ openSettings();
2459
+ });
2460
+ const mode = $('#set-remote-mode');
2461
+ if (mode) mode.addEventListener('change', async (e) => {
2462
+ await saveSetting('/api/config', { remoteMode: e.target.value }, e.target.value === 'tailscale' ? 'Phone view: Tailscale' : 'Phone view: home Wi-Fi');
2463
+ openSettings();
2464
+ });
2465
+ const copy = $('#set-remote-copy');
2466
+ if (copy) copy.addEventListener('click', async () => {
2467
+ try {
2468
+ await navigator.clipboard.writeText(r.url);
2469
+ toast('Phone link copied. Paste it in Safari on your phone.');
2470
+ } catch {
2471
+ toast("Clipboard blocked. The link is in config.json's remoteKey.");
2472
+ }
2473
+ });
2474
+ const rotate = $('#set-remote-rotate');
2475
+ if (rotate) rotate.addEventListener('click', async () => {
2476
+ await saveSetting('/api/config', { rotateRemoteKey: true }, 'New phone link made. The old one no longer works.');
2477
+ openSettings();
2478
+ });
2479
+ }
2480
+
2426
2481
  async function openSettings() {
2427
2482
  openDrawerShell('Settings', 'changes save instantly');
2428
2483
  try {
@@ -2457,6 +2512,7 @@ async function openSettings() {
2457
2512
  <span class="set-label">Usage meters<span class="set-sub">fetches your own usage from Anthropic's API using your Claude Code sign-in (kept in memory) — the dashboard's only automatic network call</span></span>
2458
2513
  <input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
2459
2514
  </div>
2515
+ ${phoneViewRows(c.remote)}
2460
2516
  <div class="set-row">
2461
2517
  <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>
2462
2518
  <select id="set-theme">
@@ -2555,6 +2611,7 @@ async function openSettings() {
2555
2611
  saveSetting('/api/config', { notifications: e.target.checked }, e.target.checked ? 'Notifications on' : 'Notifications off'));
2556
2612
  $('#set-usage').addEventListener('change', (e) =>
2557
2613
  saveSetting('/api/config', { usageApi: e.target.checked }, e.target.checked ? 'Usage meters on' : 'Usage meters off — header falls back to the statusline cache'));
2614
+ wirePhoneView(c.remote);
2558
2615
  $('#set-chats').addEventListener('change', async (e) => {
2559
2616
  const file = e.target.files[0];
2560
2617
  if (!file) return;
@@ -0,0 +1,180 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-title" content="Mission Control">
8
+ <meta name="robots" content="noindex">
9
+ <title>Mission Control</title>
10
+ <style>
11
+ @font-face { font-family: 'JetBrains Mono'; src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2'); font-weight: 400; font-display: swap; }
12
+ @font-face { font-family: 'JetBrains Mono'; src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2'); font-weight: 700; font-display: swap; }
13
+ @font-face { font-family: 'Oswald'; src: url('/fonts/Oswald-Variable.woff2') format('woff2'); font-weight: 200 700; font-display: swap; }
14
+ :root {
15
+ --bg: #f5f4f0; --surface: #ffffff; --ink: #1d2326; --muted: #596264; --faint: #8a9394;
16
+ --line: #dedbd2; --accent: #1d7a44; --warn: #a66a00; --warn-bg: rgba(166, 106, 0, 0.10); --bad: #c0392b;
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --bg: #0d1215; --surface: #151d22; --ink: #e9e4d6; --muted: #93a09e; --faint: #5d6b6b;
21
+ --line: #202b31; --accent: #46c073; --warn: #ffb020; --warn-bg: rgba(255, 176, 32, 0.12); --bad: #ff5f45;
22
+ }
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; background: var(--bg); color: var(--ink); }
26
+ body {
27
+ font: 14px/1.45 'JetBrains Mono', ui-monospace, monospace;
28
+ padding: max(14px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom));
29
+ }
30
+ .disp { font-family: Oswald, 'Arial Narrow', sans-serif; text-transform: uppercase; letter-spacing: 0.06em; }
31
+ header { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
32
+ header h1 { margin: 0; font-size: 17px; font-weight: 600; flex: 1; }
33
+ header h1 .ch { color: var(--warn); }
34
+ #dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
35
+ #dot.on { background: var(--accent); }
36
+ #dot.err { background: var(--warn); }
37
+ #clock { font-weight: 700; font-variant-numeric: tabular-nums; }
38
+ #summary { padding: 16px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); margin-bottom: 14px; }
39
+ #summary .big { font-size: 34px; font-weight: 600; line-height: 1.1; }
40
+ #summary.alert { background: var(--warn-bg); border-color: var(--warn); }
41
+ #summary.alert .big { color: var(--warn); }
42
+ #summary.clear .big { color: var(--accent); }
43
+ #summary .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
44
+ .card { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; }
45
+ .card.waiting { border-color: var(--warn); border-left-width: 4px; }
46
+ .card .top { display: flex; align-items: center; gap: 8px; }
47
+ .card .proj { font-size: 18px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
+ .st { font-size: 12px; font-weight: 700; padding: 2px 7px; border-radius: 3px; border: 1px solid var(--line); color: var(--muted); }
49
+ .st.waiting { background: var(--warn); color: #0d1215; border-color: var(--warn); }
50
+ .st.busy { color: var(--accent); }
51
+ .badge { font-size: 11px; color: var(--accent); border: 1px solid var(--line); border-radius: 3px; padding: 0 5px; }
52
+ .title { color: var(--muted); margin-top: 4px; overflow-wrap: anywhere; }
53
+ .reason { margin-top: 8px; color: var(--ink); overflow-wrap: anywhere; }
54
+ .reason .g { color: var(--warn); margin-right: 6px; }
55
+ .meta { margin-top: 8px; font-size: 12px; color: var(--faint); display: flex; gap: 12px; flex-wrap: wrap; }
56
+ .meta .warn { color: var(--warn); }
57
+ h2 { font-size: 12px; color: var(--muted); margin: 20px 0 8px; font-weight: 400; letter-spacing: 0.12em; }
58
+ .meter { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
59
+ .meter .lbl { width: 60px; color: var(--muted); }
60
+ .meter .bar { flex: 1; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
61
+ .meter .fill { display: block; height: 100%; background: var(--accent); }
62
+ .meter .fill.hot { background: var(--warn); }
63
+ .meter .fill.crit { background: var(--bad); }
64
+ .meter .pct { width: 44px; text-align: right; }
65
+ .eta { color: var(--warn); font-size: 12px; margin: -4px 0 10px 70px; }
66
+ #msg { color: var(--muted); padding: 24px 4px; }
67
+ #foot { color: var(--faint); font-size: 11px; margin-top: 18px; text-align: center; }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <header>
72
+ <span id="dot"></span>
73
+ <h1 class="disp"><span class="ch">❯</span> Mission Control</h1>
74
+ <span id="clock"></span>
75
+ </header>
76
+ <main id="app"><div id="msg">Connecting…</div></main>
77
+ <div id="foot"></div>
78
+ <script>
79
+ 'use strict';
80
+ // Pairing key: from the link's #k=... (never sent to the server as part of
81
+ // the URL), remembered so a home-screen bookmark keeps working.
82
+ const KEY = (() => {
83
+ const m = location.hash.match(/(?:^#|&)k=([\w-]+)/);
84
+ if (m) { try { localStorage.setItem('mcKey', m[1]); } catch {} return m[1]; }
85
+ try { return localStorage.getItem('mcKey'); } catch { return null; }
86
+ })();
87
+
88
+ const $ = (s) => document.querySelector(s);
89
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
90
+ const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
91
+ const sameDay = (ms) => new Date(ms).toDateString() === new Date().toDateString();
92
+ const when = (ms) => (sameDay(ms) ? '' : new Date(ms).toLocaleDateString([], { weekday: 'short' }) + ' ') + clock(ms);
93
+ const mins = (m) => (m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`);
94
+ const since = (ms) => (ms ? mins(Math.max(0, Math.floor((Date.now() - ms) / 60000))) : '');
95
+ let lastOk = 0;
96
+
97
+ function message(text) {
98
+ $('#app').innerHTML = `<div id="msg">${esc(text)}</div>`;
99
+ }
100
+
101
+ function card(s) {
102
+ const glyph = s.reason ? (s.reason.kind === 'question' ? '?' : s.reason.kind === 'permission' ? '🔒' : '›') : '';
103
+ return `<div class="card ${s.status === 'waiting' ? 'waiting' : ''}">
104
+ <div class="top">
105
+ <span class="proj disp">${esc(s.projectName)}</span>
106
+ ${s.background ? '<span class="badge">BG</span>' : ''}
107
+ <span class="st ${esc(s.status)}">${s.status === 'waiting' ? 'NEEDS YOU' : esc(String(s.status || '').toUpperCase())}</span>
108
+ </div>
109
+ <div class="title">${esc(s.title)}</div>
110
+ ${s.reason ? `<div class="reason"><span class="g">${glyph}</span>${esc(s.reason.text)}</div>` : ''}
111
+ <div class="meta">
112
+ ${s.waitingMin != null ? `<span class="warn">waiting ${mins(s.waitingMin)}</span>` : s.startedAt ? `<span>${since(s.startedAt)}</span>` : ''}
113
+ ${s.quietMin != null ? `<span class="warn">quiet ${s.quietMin}m</span>` : ''}
114
+ ${s.estCost != null ? `<span>≈$${Number(s.estCost).toFixed(2)}</span>` : ''}
115
+ ${s.contextPct != null ? `<span class="${s.contextPct >= 70 ? 'warn' : ''}">ctx ${s.contextPct}%</span>` : ''}
116
+ </div>
117
+ </div>`;
118
+ }
119
+
120
+ function meter(label, pct, hitAt) {
121
+ if (pct == null) return '';
122
+ const cls = pct >= 90 ? 'crit' : pct >= 75 ? 'hot' : '';
123
+ return `<div class="meter"><span class="lbl">${label}</span><span class="bar"><span class="fill ${cls}" style="width:${Math.min(100, pct)}%"></span></span><span class="pct">${Math.round(pct)}%</span></div>` +
124
+ (hitAt && pct < 100 ? `<div class="eta">at this pace: 100% around ${esc(when(hitAt))}</div>` : '');
125
+ }
126
+
127
+ function render(d) {
128
+ const waiting = d.waiting || 0;
129
+ const live = d.live || [];
130
+ document.title = waiting ? `(${waiting}) Mission Control` : 'Mission Control';
131
+ const summary = waiting
132
+ ? `<div id="summary" class="alert"><div class="big disp">${waiting} need${waiting === 1 ? 's' : ''} you</div><div class="sub">${live.length} running</div></div>`
133
+ : live.length
134
+ ? `<div id="summary" class="clear"><div class="big disp">All clear</div><div class="sub">${live.length} running, none waiting on you</div></div>`
135
+ : `<div id="summary"><div class="big disp">Quiet</div><div class="sub">No Claude Code sessions running</div></div>`;
136
+ const l = d.limits;
137
+ const limits = l && (l.session != null || l.week != null)
138
+ ? `<h2 class="disp">Limits</h2>${meter('5-hour', l.session, l.sessionHitAt)}${meter('week', l.week, l.weekHitAt)}`
139
+ : '';
140
+ $('#app').innerHTML = summary + live.map(card).join('') + limits;
141
+ }
142
+
143
+ async function poll() {
144
+ if (!KEY) {
145
+ message('Open this page from the link in Settings → Phone view on your Mac.');
146
+ $('#dot').className = 'err';
147
+ return;
148
+ }
149
+ try {
150
+ const r = await fetch('/m/state', { headers: { Authorization: `Bearer ${KEY}` }, cache: 'no-store' });
151
+ if (r.status === 401) {
152
+ message('This link no longer works. Copy a fresh one from Settings → Phone view on your Mac.');
153
+ $('#dot').className = 'err';
154
+ return;
155
+ }
156
+ if (r.status === 429) {
157
+ message('Too many tries with a wrong key. Wait ten minutes, then use a fresh link.');
158
+ $('#dot').className = 'err';
159
+ return;
160
+ }
161
+ if (!r.ok) throw new Error(String(r.status));
162
+ render(await r.json());
163
+ lastOk = Date.now();
164
+ $('#dot').className = 'on';
165
+ } catch {
166
+ $('#dot').className = 'err';
167
+ if (!lastOk) message("Can't reach your Mac. Check that your phone is on the same network, and that the dashboard is running.");
168
+ }
169
+ $('#foot').textContent = lastOk ? `updated ${clock(lastOk)} · refreshes every 5 seconds` : '';
170
+ }
171
+
172
+ function tick() { $('#clock').textContent = clock(Date.now()); }
173
+ tick();
174
+ setInterval(tick, 15000);
175
+ poll();
176
+ setInterval(() => { if (!document.hidden) poll(); }, 5000);
177
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
178
+ </script>
179
+ </body>
180
+ </html>
package/server.js CHANGED
@@ -16,6 +16,7 @@ const { friendlyName } = require('./lib/names');
16
16
  const cfg = require('./lib/config');
17
17
  const { isProjectMuted } = require('./lib/notify');
18
18
  const { recentCommits, linkCommitsToSessions, commitCosts } = require('./lib/gitlog');
19
+ const { createRemoteServer } = require('./lib/remote');
19
20
  const { sessionChangeList, sessionFileDiff } = require('./lib/changes');
20
21
  const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject } = require('./lib/demo');
21
22
  const { runSelfUpdate } = require('./lib/update');
@@ -292,6 +293,7 @@ const server = http.createServer((req, res) => {
292
293
  ignores: cfg.readIgnores(),
293
294
  pricingSource: cfg.readPricing().source,
294
295
  unpricedModels: collector.unpricedModels(),
296
+ remote: remote.status(cfg.readConfig()),
295
297
  });
296
298
  return;
297
299
  }
@@ -325,7 +327,7 @@ const server = http.createServer((req, res) => {
325
327
  if (!known) return json(res, 404, { ok: false, error: 'unknown project' });
326
328
  cfg.setProjectMuted(known, payload.muted !== false);
327
329
  } else {
328
- cfg.updateConfig(payload);
330
+ remote.apply(cfg.updateConfig(payload));
329
331
  }
330
332
  } else if (url === '/api/names') {
331
333
  const known = collector
@@ -589,7 +591,19 @@ function openBrowser(url) {
589
591
  else execFile('xdg-open', [url], () => {});
590
592
  }
591
593
 
594
+ // Phone view: opt-in second listener; never in demo mode.
595
+ const remote = createRemoteServer({
596
+ getState: () => collector.state,
597
+ readConfig: cfg.readConfig,
598
+ publicDir: path.join(__dirname, 'public'),
599
+ });
600
+
592
601
  (DEMO ? Promise.resolve() : collector.start()).then(() => {
602
+ if (!DEMO) {
603
+ remote.apply(cfg.readConfig());
604
+ // Wi-Fi and Tailscale addresses change; follow them.
605
+ setInterval(() => remote.apply(cfg.readConfig()), 60_000).unref();
606
+ }
593
607
  server.listen(PORT, HOST, () => {
594
608
  console.log(`claude-dashboard listening on ${DASH_URL}${DEMO ? ' (demo mode)' : ''}`);
595
609
  if (OPEN) {