claude-mission-control 1.13.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 +19 -0
- package/lib/config.js +8 -1
- package/lib/demo.js +25 -1
- package/lib/gitlog.js +42 -1
- package/lib/remote.js +180 -0
- package/package.json +1 -1
- package/public/index.html +72 -1
- package/public/mobile.html +180 -0
- package/server.js +21 -11
package/README.md
CHANGED
|
@@ -35,6 +35,14 @@ 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
|
+
|
|
42
|
+
**New in 1.14.0**
|
|
43
|
+
|
|
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.
|
|
45
|
+
|
|
38
46
|
**New in 1.13.0**
|
|
39
47
|
|
|
40
48
|
- Background sessions from `claude --bg` and Agent View carry a `BG` chip, the name Agent View gives them, and its one-line summary of what they did.
|
|
@@ -194,6 +202,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
|
|
|
194
202
|

|
|
195
203
|
|
|
196
204
|
- **Project details** — click any project's name for a slide-over with its full session list, recent git commits, rendered CLAUDE.md, per-project memory files, skills/agents/commands from `.claude/`, and settings (permissions, MCP servers, allowed tools), plus the resolved **Toolset** below. Read-only; also a quick audit of which projects are missing instructions or memory. Esc closes.
|
|
205
|
+
- **Cost per commit** — in each project's slide-over, every commit made during a Claude session carries a `≈$` figure: that session's cost split evenly over the commits it made (±5 minutes of its activity). Hover for which session it came from. The line above the list sums it up: cost per commit, how many of the recent commits came from a Claude session, and what was spent in sessions that committed nothing. It's an even split, so a one-line version bump counts the same as a feature.
|
|
197
206
|
- **Toolset** — inside the project slide-over: what Claude Code would actually use in that project. Every installed plugin, every agent and skill from the project, your user dir, and enabled plugins, and every MCP server that applies, each marked on or off with where it came from and which settings scope decided it, resolved with Claude Code's own precedence (managed › local › project › user, deny beats allow, project agents shadow user agents shadow plugin agents). An amber list at the top flags things worth a look: a plugin enabled but not installed or gone stale, a deny rule naming an agent that no longer exists, a server that needs auth, a settings file that doesn't parse. The `⧉` on a row copies the exact snippet to flip it, aimed at `.claude/settings.local.json`; MCP toggles live in Claude's own registry, so that recipe tells you to run `/mcp` in a session instead (servers from a project's `.mcp.json` also honour the settings keys `enabledMcpjsonServers`, `disabledMcpjsonServers` and `enableAllProjectMcpServers`, and their recipes use those). Buttons at the foot reveal the project's settings files in Finder. Read-only — the dashboard never edits Claude configuration.
|
|
198
207
|
|
|
199
208
|

|
|
@@ -223,6 +232,16 @@ A SwiftBar plugin lives in `menubar/claude-dash.15s.sh`. The menu bar shows `❯
|
|
|
223
232
|
|
|
224
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).
|
|
225
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
|
+
|
|
226
245
|
## Settings
|
|
227
246
|
|
|
228
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/demo.js
CHANGED
|
@@ -463,5 +463,29 @@ function demoToolset() {
|
|
|
463
463
|
};
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
|
|
466
|
+
// Project slide-over for the demo: commits with their cost split from the
|
|
467
|
+
// sessions that made them (see commitCosts in lib/gitlog.js).
|
|
468
|
+
function demoProject() {
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
const c = (sha, ago, subject, stat, cost, title, n) => ({
|
|
471
|
+
sha, ts: now - ago, subject, author: 'Demo', claude: cost != null, stat,
|
|
472
|
+
...(cost != null ? { cost, costFrom: [{ sessionId: S1, title, commits: n }], sessionId: S1, sessionTitle: title } : {}),
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
path: '/demo/acme-storefront', sessions: [], muted: false,
|
|
476
|
+
claudeMd: [], memory: [], skills: [], agents: [], commands: [], settings: {},
|
|
477
|
+
toolset: demoToolset(),
|
|
478
|
+
commits: [
|
|
479
|
+
c('9f3c2a1', 6 * MIN, 'Checkout: create the payment intent server-side', '3 files changed, 64 insertions(+), 9 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
|
|
480
|
+
c('41be07d', 22 * MIN, 'Cart: call the checkout endpoint and redirect', '2 files changed, 31 insertions(+), 4 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
|
|
481
|
+
c('c0d9e55', 41 * MIN, 'Remove the legacy checkout page', '1 file changed, 0 insertions(+), 212 deletions(-)', 8.2, 'Wire Stripe checkout into the cart', 3),
|
|
482
|
+
c('77a1f30', 3 * HOUR, 'Onboarding: account and store screens', '6 files changed, 240 insertions(+), 12 deletions(-)', 9.1, 'Design the onboarding flow', 2),
|
|
483
|
+
c('e52b8c4', 3 * HOUR + 40 * MIN, 'Onboarding: first-product screen', '3 files changed, 118 insertions(+), 3 deletions(-)', 9.1, 'Design the onboarding flow', 2),
|
|
484
|
+
c('1a6d9f2', 26 * HOUR, 'Bump dependencies', '2 files changed, 14 insertions(+), 14 deletions(-)', null),
|
|
485
|
+
],
|
|
486
|
+
commitSummary: { total: 6, withSession: 5, perCommit: 8.56, noCommitSpend: 3.4 },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject };
|
|
467
491
|
|
package/lib/gitlog.js
CHANGED
|
@@ -44,6 +44,47 @@ function linkCommitsToSessions(commits, sessions) {
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Cost per commit: each session's cost is split evenly across the commits
|
|
48
|
+
// made inside its activity window (same padded window as above). A commit
|
|
49
|
+
// inside two overlapping sessions gets a share of both. Sessions that cost
|
|
50
|
+
// something but committed nothing, since the oldest listed commit, are
|
|
51
|
+
// reported separately rather than spread over unrelated commits.
|
|
52
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
53
|
+
function commitCosts(commits, sessions) {
|
|
54
|
+
const cost = new Map(); // sha -> usd
|
|
55
|
+
const from = new Map(); // sha -> [{ sessionId, title, commits }]
|
|
56
|
+
const oldest = commits.length ? Math.min(...commits.map((c) => c.ts)) : Infinity;
|
|
57
|
+
let attributed = 0;
|
|
58
|
+
let noCommit = 0;
|
|
59
|
+
for (const s of sessions || []) {
|
|
60
|
+
const usd = s.estCost || 0;
|
|
61
|
+
if (usd < 0.005 || !s.lastActivityAt) continue;
|
|
62
|
+
const start = (s.startedAt || s.lastActivityAt) - PAD;
|
|
63
|
+
const end = s.lastActivityAt + PAD;
|
|
64
|
+
const inside = commits.filter((c) => c.ts >= start && c.ts <= end);
|
|
65
|
+
if (!inside.length) {
|
|
66
|
+
if (s.lastActivityAt >= oldest) noCommit += usd;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
attributed += usd;
|
|
70
|
+
for (const c of inside) {
|
|
71
|
+
cost.set(c.sha, (cost.get(c.sha) || 0) + usd / inside.length);
|
|
72
|
+
from.set(c.sha, [...(from.get(c.sha) || []), { sessionId: s.sessionId, title: s.title || null, commits: inside.length }]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const out = commits.map((c) => (cost.has(c.sha) ? { ...c, cost: round2(cost.get(c.sha)), costFrom: from.get(c.sha) } : c));
|
|
76
|
+
const withSession = cost.size;
|
|
77
|
+
return {
|
|
78
|
+
commits: out,
|
|
79
|
+
summary: {
|
|
80
|
+
total: commits.length,
|
|
81
|
+
withSession,
|
|
82
|
+
perCommit: withSession ? round2(attributed / withSession) : null,
|
|
83
|
+
noCommitSpend: round2(noCommit),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
47
88
|
function recentCommits(projectPath, limit = 30) {
|
|
48
89
|
return new Promise((resolve) => {
|
|
49
90
|
execFile(
|
|
@@ -55,4 +96,4 @@ function recentCommits(projectPath, limit = 30) {
|
|
|
55
96
|
});
|
|
56
97
|
}
|
|
57
98
|
|
|
58
|
-
module.exports = { parseGitLog, linkCommitsToSessions, recentCommits };
|
|
99
|
+
module.exports = { parseGitLog, linkCommitsToSessions, commitCosts, recentCommits };
|
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
package/public/index.html
CHANGED
|
@@ -1607,6 +1607,18 @@ function renderMd(raw) {
|
|
|
1607
1607
|
return `<div class="md">${out.join('')}</div>`;
|
|
1608
1608
|
}
|
|
1609
1609
|
|
|
1610
|
+
// "≈$3.40 per commit · 18 of 30 made during a Claude session · ≈$12 in sessions with no commit"
|
|
1611
|
+
function commitSummaryLine(s) {
|
|
1612
|
+
if (!s || !s.withSession) return '';
|
|
1613
|
+
const w = costWording();
|
|
1614
|
+
const parts = [
|
|
1615
|
+
`${costChip(s.perCommit, { cls: '' })} per commit`,
|
|
1616
|
+
`${s.withSession} of ${s.total} made during a Claude session`,
|
|
1617
|
+
fmtCost(s.noCommitSpend) ? `${costChip(s.noCommitSpend, { cls: '' })} in sessions with no commit` : '',
|
|
1618
|
+
].filter(Boolean);
|
|
1619
|
+
return `<div class="set-sub" style="margin:0 0 8px" title="${esc(`Each session's ${w.noun} is split evenly over the commits made while it was active (±5 minutes). Commits outside any session are left out.`)}">${parts.join(' · ')}</div>`;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1610
1622
|
function dSection(title, inner, empty) {
|
|
1611
1623
|
return `<details class="d-section" open><summary>${esc(title)}</summary>${
|
|
1612
1624
|
inner || `<div class="d-empty">${esc(empty)}</div>`}</details>`;
|
|
@@ -1697,6 +1709,8 @@ function renderDetail(d, name) {
|
|
|
1697
1709
|
${esc(c.subject)}
|
|
1698
1710
|
</span>
|
|
1699
1711
|
${c.sessionId ? `<button class="copy" data-session="${esc(c.sessionId)}" title="${esc(c.sessionTitle || '')}">session →</button>` : ''}
|
|
1712
|
+
${c.cost != null ? `<span title="${esc((c.costFrom || []).map((f) =>
|
|
1713
|
+
`${f.title || 'session'}: split over ${f.commits} commit${f.commits === 1 ? '' : 's'}`).join('\n'))}">${costChip(c.cost)}</span>` : ''}
|
|
1700
1714
|
<span class="s-when">${esc(statCompact(c.stat))}</span>
|
|
1701
1715
|
<span class="s-when">${relTime(c.ts)}</span>
|
|
1702
1716
|
</li>`).join('');
|
|
@@ -1708,7 +1722,7 @@ function renderDetail(d, name) {
|
|
|
1708
1722
|
$('#d-body').innerHTML =
|
|
1709
1723
|
muteBtn +
|
|
1710
1724
|
dSection(`Sessions — ${d.sessions.length}`, sessions, 'No sessions recorded.') +
|
|
1711
|
-
dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
|
|
1725
|
+
dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? commitSummaryLine(d.commitSummary) + `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
|
|
1712
1726
|
dSection('CLAUDE.md', d.claudeMd.length ? dFiles(d.claudeMd, 'name') : null, 'No CLAUDE.md — sessions here start without project instructions.') +
|
|
1713
1727
|
dSection(`Memory — ${d.memory.length} file${d.memory.length === 1 ? '' : 's'}`, d.memory.length ? dFiles(d.memory, 'name') : null, 'No memory yet.') +
|
|
1714
1728
|
dSection(`Skills, agents & commands — ${caps.length}`, caps.length ? dFiles(caps, 'name') : null, 'None in .claude/skills, .claude/agents, or .claude/commands.') +
|
|
@@ -2409,6 +2423,61 @@ async function saveSetting(endpoint, payload, note) {
|
|
|
2409
2423
|
}
|
|
2410
2424
|
}
|
|
2411
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
|
+
|
|
2412
2481
|
async function openSettings() {
|
|
2413
2482
|
openDrawerShell('Settings', 'changes save instantly');
|
|
2414
2483
|
try {
|
|
@@ -2443,6 +2512,7 @@ async function openSettings() {
|
|
|
2443
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>
|
|
2444
2513
|
<input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
|
|
2445
2514
|
</div>
|
|
2515
|
+
${phoneViewRows(c.remote)}
|
|
2446
2516
|
<div class="set-row">
|
|
2447
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>
|
|
2448
2518
|
<select id="set-theme">
|
|
@@ -2541,6 +2611,7 @@ async function openSettings() {
|
|
|
2541
2611
|
saveSetting('/api/config', { notifications: e.target.checked }, e.target.checked ? 'Notifications on' : 'Notifications off'));
|
|
2542
2612
|
$('#set-usage').addEventListener('change', (e) =>
|
|
2543
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);
|
|
2544
2615
|
$('#set-chats').addEventListener('change', async (e) => {
|
|
2545
2616
|
const file = e.target.files[0];
|
|
2546
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
@@ -15,9 +15,10 @@ const { sessionTitle } = require('./lib/transcripts');
|
|
|
15
15
|
const { friendlyName } = require('./lib/names');
|
|
16
16
|
const cfg = require('./lib/config');
|
|
17
17
|
const { isProjectMuted } = require('./lib/notify');
|
|
18
|
-
const { recentCommits, linkCommitsToSessions } = require('./lib/gitlog');
|
|
18
|
+
const { recentCommits, linkCommitsToSessions, commitCosts } = require('./lib/gitlog');
|
|
19
|
+
const { createRemoteServer } = require('./lib/remote');
|
|
19
20
|
const { sessionChangeList, sessionFileDiff } = require('./lib/changes');
|
|
20
|
-
const { demoState, demoStats, demoSession, demoChanges, demoInventory,
|
|
21
|
+
const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject } = require('./lib/demo');
|
|
21
22
|
const { runSelfUpdate } = require('./lib/update');
|
|
22
23
|
const { readInventory } = require('./lib/inventory');
|
|
23
24
|
const { readToolset } = require('./lib/toolset');
|
|
@@ -133,13 +134,7 @@ const server = http.createServer((req, res) => {
|
|
|
133
134
|
}
|
|
134
135
|
if (url === '/api/search') return json(res, 200, { q: '', prompts: [], titles: [], notes: [], transcripts: null });
|
|
135
136
|
if (url === '/api/reveal-file') return json(res, 200, { ok: true });
|
|
136
|
-
if (url === '/api/project')
|
|
137
|
-
return json(res, 200, {
|
|
138
|
-
path: '/demo/acme-storefront', sessions: [], commits: [], muted: false,
|
|
139
|
-
claudeMd: [], memory: [], skills: [], agents: [], commands: [], settings: {},
|
|
140
|
-
toolset: demoToolset(),
|
|
141
|
-
});
|
|
142
|
-
}
|
|
137
|
+
if (url === '/api/project') return json(res, 200, demoProject());
|
|
143
138
|
if (url === '/api/config' && req.method === 'GET') {
|
|
144
139
|
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, idleNagMinutes: 60, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], themes: cfg.THEMES, version: VERSION, errors: [], pricingSource: null, unpricedModels: [] });
|
|
145
140
|
}
|
|
@@ -258,7 +253,9 @@ const server = http.createServer((req, res) => {
|
|
|
258
253
|
Promise.all([projectDetail(known), recentCommits(known), readInventory(collector.projectPaths())])
|
|
259
254
|
.then(async ([detail, commits, inventory]) => {
|
|
260
255
|
detail.sessions = collector.allSessions(known);
|
|
261
|
-
|
|
256
|
+
const costed = commitCosts(linkCommitsToSessions(commits, detail.sessions), detail.sessions);
|
|
257
|
+
detail.commits = costed.commits;
|
|
258
|
+
detail.commitSummary = costed.summary;
|
|
262
259
|
detail.muted = isProjectMuted(known, cfg.readConfig().mutedProjects);
|
|
263
260
|
detail.toolset = await readToolset(known, inventory);
|
|
264
261
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -296,6 +293,7 @@ const server = http.createServer((req, res) => {
|
|
|
296
293
|
ignores: cfg.readIgnores(),
|
|
297
294
|
pricingSource: cfg.readPricing().source,
|
|
298
295
|
unpricedModels: collector.unpricedModels(),
|
|
296
|
+
remote: remote.status(cfg.readConfig()),
|
|
299
297
|
});
|
|
300
298
|
return;
|
|
301
299
|
}
|
|
@@ -329,7 +327,7 @@ const server = http.createServer((req, res) => {
|
|
|
329
327
|
if (!known) return json(res, 404, { ok: false, error: 'unknown project' });
|
|
330
328
|
cfg.setProjectMuted(known, payload.muted !== false);
|
|
331
329
|
} else {
|
|
332
|
-
cfg.updateConfig(payload);
|
|
330
|
+
remote.apply(cfg.updateConfig(payload));
|
|
333
331
|
}
|
|
334
332
|
} else if (url === '/api/names') {
|
|
335
333
|
const known = collector
|
|
@@ -593,7 +591,19 @@ function openBrowser(url) {
|
|
|
593
591
|
else execFile('xdg-open', [url], () => {});
|
|
594
592
|
}
|
|
595
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
|
+
|
|
596
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
|
+
}
|
|
597
607
|
server.listen(PORT, HOST, () => {
|
|
598
608
|
console.log(`claude-dashboard listening on ${DASH_URL}${DEMO ? ' (demo mode)' : ''}`);
|
|
599
609
|
if (OPEN) {
|