claude-mission-control 1.21.0 → 1.23.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 +2 -1
- package/lib/chats.js +9 -1
- package/lib/collector.js +18 -5
- package/lib/config.js +83 -23
- package/lib/guard.js +58 -0
- package/lib/ignore.js +1 -1
- package/lib/names.js +1 -1
- package/lib/transcripts.js +79 -11
- package/package.json +1 -1
- package/server.js +8 -7
package/README.md
CHANGED
|
@@ -311,7 +311,8 @@ Edit `ignore.json` — an array of absolute path prefixes. A project is hidden i
|
|
|
311
311
|
| `CLAUDE_DASH_HOST` | `127.0.0.1` | Bind address — see below before changing |
|
|
312
312
|
| `CLAUDE_DASH_DEMO` | unset | `1` = serve believable fake data (screenshots, trying it without Claude history) |
|
|
313
313
|
| `CLAUDE_DASH_OPEN` | unset | `1` = open the browser on startup (what `npx` sets for you) |
|
|
314
|
-
| `CLAUDE_DASH_CONFIG_DIR` | repo dir | Where config.json/names.json/ignore.json live
|
|
314
|
+
| `CLAUDE_DASH_CONFIG_DIR` | repo dir (git checkout) | Where config.json/names.json/ignore.json/chats.json live. Non-git installs (npm, npx) use `~/.config/claude-dashboard`; files from older versions are copied there on first run |
|
|
315
|
+
| `CLAUDE_DASH_ALLOWED_HOSTS` | — | Comma-separated extra hostnames accepted in the Host header, e.g. your MagicDNS name behind `tailscale serve`. Anything else gets 403 (DNS-rebinding protection) |
|
|
315
316
|
|
|
316
317
|
The server binds to `127.0.0.1` only by default.
|
|
317
318
|
|
package/lib/chats.js
CHANGED
|
@@ -95,7 +95,15 @@ function readChats() {
|
|
|
95
95
|
// Normalize the raw export and persist. Returns the imported count.
|
|
96
96
|
function saveChats(raw) {
|
|
97
97
|
const chats = normalizeChats(raw);
|
|
98
|
-
|
|
98
|
+
// Atomic, like config: a failed write keeps the previous import intact.
|
|
99
|
+
const tmp = `${CHATS_FILE}.${process.pid}.tmp`;
|
|
100
|
+
try {
|
|
101
|
+
fs.writeFileSync(tmp, JSON.stringify(chats));
|
|
102
|
+
fs.renameSync(tmp, CHATS_FILE);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
try { fs.unlinkSync(tmp); } catch { /* already gone */ }
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
99
107
|
cache = { mtimeMs: 0, chats: [] }; // next read picks up the new file
|
|
100
108
|
return chats.length;
|
|
101
109
|
}
|
package/lib/collector.js
CHANGED
|
@@ -7,7 +7,7 @@ const { readQuota } = require('./quota');
|
|
|
7
7
|
const { fetchOauthUsage } = require('./usage');
|
|
8
8
|
const { readRegistry } = require('./registry');
|
|
9
9
|
const { refreshHistory, activityBuckets, weekHourHeat } = require('./history');
|
|
10
|
-
const { scanAllTranscripts, scanFile, groupByProject, sessionTitle, sessionKind, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, reasonText, contextSummary, toolSummary } = require('./transcripts');
|
|
10
|
+
const { scanAllTranscripts, scanFile, groupByProject, sessionTitle, sessionKind, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, reasonText, contextSummary, toolSummary, scannerHealth } = require('./transcripts');
|
|
11
11
|
const { collectGitStatus } = require('./gitstatus');
|
|
12
12
|
const { friendlyName } = require('./names');
|
|
13
13
|
const { isIgnored } = require('./ignore');
|
|
@@ -165,6 +165,13 @@ class Collector {
|
|
|
165
165
|
const sessions = await scanAllTranscripts();
|
|
166
166
|
this.raw.transcriptGroups = groupByProject(sessions);
|
|
167
167
|
this.raw.metaBySession = new Map(sessions.map((m) => [m.sessionId, m]));
|
|
168
|
+
// Say so once per distinct problem, not every 15s scan.
|
|
169
|
+
const warnings = scannerHealth(sessions);
|
|
170
|
+
const key = warnings.join('|');
|
|
171
|
+
if (key !== this.scanWarningKey) {
|
|
172
|
+
this.scanWarningKey = key;
|
|
173
|
+
for (const w of warnings) this.noteError('scan', w);
|
|
174
|
+
}
|
|
168
175
|
this.raw.history = await refreshHistory();
|
|
169
176
|
this.raw.teams = await readTeams();
|
|
170
177
|
// Task summaries for recent sessions (readTasks caches by dir mtime).
|
|
@@ -621,6 +628,7 @@ class Collector {
|
|
|
621
628
|
const sessionDays = [];
|
|
622
629
|
let sessionTotal = 0;
|
|
623
630
|
let costTotal = 0;
|
|
631
|
+
const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
|
|
624
632
|
const dayCut = Date.now() - 24 * 3600000;
|
|
625
633
|
const timeline = [];
|
|
626
634
|
const weekDayKey = dayKey(weekCut);
|
|
@@ -634,12 +642,14 @@ class Collector {
|
|
|
634
642
|
let weekCacheAll = 0;
|
|
635
643
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
636
644
|
if (isIgnored(g.path)) continue;
|
|
637
|
-
sessionTotal += g.sessions.length;
|
|
638
645
|
const projModels = {};
|
|
639
646
|
for (const m of g.sessions) {
|
|
640
647
|
const mDays = combinedDays(m);
|
|
641
648
|
sessionDays.push(mDays);
|
|
642
|
-
|
|
649
|
+
// Automated and empty sessions count toward spend, not toward sessions.
|
|
650
|
+
const real = !sessionKind(m, liveIds.has(m.sessionId));
|
|
651
|
+
if (real) sessionTotal++;
|
|
652
|
+
if (real && m.lastActivityAt >= dayCut) {
|
|
643
653
|
timeline.push({
|
|
644
654
|
project: friendlyName(g.path),
|
|
645
655
|
sessionId: m.sessionId,
|
|
@@ -649,7 +659,7 @@ class Collector {
|
|
|
649
659
|
});
|
|
650
660
|
}
|
|
651
661
|
if (m.lastActivityAt >= weekCut) {
|
|
652
|
-
weekSessions++;
|
|
662
|
+
if (real) weekSessions++;
|
|
653
663
|
if (m.waits) for (let i = 0; i < 4; i++) weekWaits[i] += m.waits[i];
|
|
654
664
|
let mWeekCost = 0;
|
|
655
665
|
for (const [day, byM] of Object.entries(mDays)) {
|
|
@@ -675,7 +685,7 @@ class Collector {
|
|
|
675
685
|
mCost += cost;
|
|
676
686
|
projModels[model] = (projModels[model] || 0) + cost;
|
|
677
687
|
}
|
|
678
|
-
topCandidates.push({
|
|
688
|
+
if (real) topCandidates.push({
|
|
679
689
|
sessionId: m.sessionId,
|
|
680
690
|
title: this.titleOf(m).title,
|
|
681
691
|
projectName: friendlyName(g.path),
|
|
@@ -726,9 +736,12 @@ class Collector {
|
|
|
726
736
|
fameSessions() {
|
|
727
737
|
const pricing = readPricing();
|
|
728
738
|
const out = [];
|
|
739
|
+
const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
|
|
729
740
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
730
741
|
if (isIgnored(g.path)) continue;
|
|
731
742
|
for (const m of g.sessions) {
|
|
743
|
+
// A one-shot claude -p run that starts as a session ends would claim its commits.
|
|
744
|
+
if (sessionKind(m, liveIds.has(m.sessionId))) continue;
|
|
732
745
|
const ts = toolSummary(m.tools);
|
|
733
746
|
out.push({
|
|
734
747
|
sessionId: m.sessionId,
|
package/lib/config.js
CHANGED
|
@@ -9,25 +9,48 @@ const { loadPricing } = require('./pricing');
|
|
|
9
9
|
|
|
10
10
|
const ROOT = path.join(__dirname, '..');
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// CLAUDE_DASH_CONFIG_DIR
|
|
12
|
+
// Every user file the dashboard keeps. All live in one directory.
|
|
13
|
+
const USER_FILES = ['config.json', 'names.json', 'ignore.json', 'chats.json'];
|
|
14
|
+
|
|
15
|
+
// Pure: where user files live. CLAUDE_DASH_CONFIG_DIR wins. A writable git
|
|
16
|
+
// checkout keeps them beside the code (the original layout; they're
|
|
17
|
+
// gitignored). Anything else — a global npm install, the npx cache, brew, an
|
|
18
|
+
// unpacked zip — is a tree a package manager or the self-updater may
|
|
19
|
+
// replace wholesale, so user data goes to ~/.config/claude-dashboard.
|
|
20
|
+
function chooseConfigDir({ env, rootIsGit, rootWritable, root, home }) {
|
|
21
|
+
if (env) return { dir: env, migrateFrom: null };
|
|
22
|
+
if (rootIsGit && rootWritable) return { dir: root, migrateFrom: null };
|
|
23
|
+
return { dir: path.join(home, '.config', 'claude-dashboard'), migrateFrom: root };
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
function resolveConfigDir() {
|
|
17
|
-
|
|
18
|
-
const env = process.env.CLAUDE_DASH_CONFIG_DIR;
|
|
19
|
-
if (env) {
|
|
20
|
-
fs.mkdirSync(env, { recursive: true });
|
|
21
|
-
return env;
|
|
22
|
-
}
|
|
27
|
+
let rootWritable = true;
|
|
23
28
|
try {
|
|
24
29
|
fs.accessSync(ROOT, fs.constants.W_OK);
|
|
25
|
-
return ROOT;
|
|
26
30
|
} catch {
|
|
27
|
-
|
|
28
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
-
return dir;
|
|
31
|
+
rootWritable = false;
|
|
30
32
|
}
|
|
33
|
+
const { dir, migrateFrom } = chooseConfigDir({
|
|
34
|
+
env: process.env.CLAUDE_DASH_CONFIG_DIR,
|
|
35
|
+
rootIsGit: fs.existsSync(path.join(ROOT, '.git')),
|
|
36
|
+
rootWritable,
|
|
37
|
+
root: ROOT,
|
|
38
|
+
home: require('os').homedir(),
|
|
39
|
+
});
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
// Older versions wrote user files into the package dir even for npm
|
|
42
|
+
// installs. Copy (never move) any that aren't in the new home yet, so the
|
|
43
|
+
// first run on this version keeps names, notes, pins and the phone key.
|
|
44
|
+
if (migrateFrom) {
|
|
45
|
+
for (const f of USER_FILES) {
|
|
46
|
+
const from = path.join(migrateFrom, f);
|
|
47
|
+
const to = path.join(dir, f);
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(from) && !fs.existsSync(to)) fs.copyFileSync(from, to);
|
|
50
|
+
} catch { /* unreadable old file: start fresh rather than fail to boot */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return dir;
|
|
31
54
|
}
|
|
32
55
|
const CONFIG_DIR = resolveConfigDir();
|
|
33
56
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -55,14 +78,46 @@ function readJson(file, fallback) {
|
|
|
55
78
|
}
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
// For read-modify-write paths. A missing file is a fresh start; a file that
|
|
82
|
+
// exists but won't parse (hand-edit typo, partial write from an older
|
|
83
|
+
// version) must NOT be treated as empty — writing back would wipe every
|
|
84
|
+
// name, note, pin and the phone key. Refuse, and say which file.
|
|
85
|
+
function readJsonForWrite(file, fallback) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = fs.readFileSync(file, 'utf8');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (e.code === 'ENOENT') return fallback;
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error(`${path.basename(file)} is not valid JSON; fix or remove ${file} and try again`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Atomic: write a sibling temp file, then rename over the target. A crash
|
|
101
|
+
// or full disk mid-write leaves the old file intact instead of truncated.
|
|
58
102
|
function writeJson(file, value) {
|
|
59
|
-
|
|
103
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
106
|
+
fs.renameSync(tmp, file);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
try { fs.unlinkSync(tmp); } catch { /* already gone */ }
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
60
111
|
}
|
|
61
112
|
|
|
62
113
|
function readConfig() {
|
|
63
114
|
return { ...DEFAULTS, ...readJson(CONFIG_FILE, {}) };
|
|
64
115
|
}
|
|
65
116
|
|
|
117
|
+
function readConfigForWrite() {
|
|
118
|
+
return { ...DEFAULTS, ...readJsonForWrite(CONFIG_FILE, {}) };
|
|
119
|
+
}
|
|
120
|
+
|
|
66
121
|
// Rate table: Claude Code's managed `modelPricing` first (contracted rates
|
|
67
122
|
// an admin deployed), then the same key in our config.json, then bundled
|
|
68
123
|
// list prices. Both files are small; read fresh every call like readConfig.
|
|
@@ -72,7 +127,7 @@ function readPricing() {
|
|
|
72
127
|
}
|
|
73
128
|
|
|
74
129
|
function updateConfig(patch) {
|
|
75
|
-
const next =
|
|
130
|
+
const next = readConfigForWrite();
|
|
76
131
|
if (typeof patch.terminal === 'string') next.terminal = patch.terminal;
|
|
77
132
|
if (typeof patch.notifications === 'boolean') next.notifications = patch.notifications;
|
|
78
133
|
if (typeof patch.usageApi === 'boolean') next.usageApi = patch.usageApi;
|
|
@@ -96,7 +151,7 @@ function updateConfig(patch) {
|
|
|
96
151
|
|
|
97
152
|
// Add or remove a project from the notification mute list.
|
|
98
153
|
function setProjectMuted(projectPath, muted) {
|
|
99
|
-
const next =
|
|
154
|
+
const next = readConfigForWrite();
|
|
100
155
|
const key = canonicalize(projectPath);
|
|
101
156
|
const list = (next.mutedProjects || []).filter((p) => canonicalize(p).toLowerCase() !== key.toLowerCase());
|
|
102
157
|
if (muted) list.push(key);
|
|
@@ -107,7 +162,7 @@ function setProjectMuted(projectPath, muted) {
|
|
|
107
162
|
|
|
108
163
|
// Pin or unpin a session for the pinned strip. Returns the new pinned state.
|
|
109
164
|
function togglePin(sessionId) {
|
|
110
|
-
const next =
|
|
165
|
+
const next = readConfigForWrite();
|
|
111
166
|
const list = next.pinnedSessions || [];
|
|
112
167
|
const has = list.includes(sessionId);
|
|
113
168
|
next.pinnedSessions = has ? list.filter((id) => id !== sessionId) : [...list, sessionId];
|
|
@@ -126,7 +181,7 @@ function applySessionName(sessionNames, sessionId, name) {
|
|
|
126
181
|
}
|
|
127
182
|
|
|
128
183
|
function setSessionName(sessionId, name) {
|
|
129
|
-
const next =
|
|
184
|
+
const next = readConfigForWrite();
|
|
130
185
|
next.sessionNames = applySessionName(next.sessionNames, sessionId, name);
|
|
131
186
|
writeJson(CONFIG_FILE, next);
|
|
132
187
|
return next.sessionNames[sessionId] || '';
|
|
@@ -144,7 +199,7 @@ function applySessionNote(sessionNotes, sessionId, note) {
|
|
|
144
199
|
}
|
|
145
200
|
|
|
146
201
|
function setSessionNote(sessionId, note) {
|
|
147
|
-
const next =
|
|
202
|
+
const next = readConfigForWrite();
|
|
148
203
|
next.sessionNotes = applySessionNote(next.sessionNotes, sessionId, note);
|
|
149
204
|
writeJson(CONFIG_FILE, next);
|
|
150
205
|
return next.sessionNotes[sessionId] || '';
|
|
@@ -155,7 +210,7 @@ function readNames() {
|
|
|
155
210
|
}
|
|
156
211
|
|
|
157
212
|
function setName(projectPath, name) {
|
|
158
|
-
const names =
|
|
213
|
+
const names = readJsonForWrite(NAMES_FILE, {});
|
|
159
214
|
const key = canonicalize(projectPath);
|
|
160
215
|
// Stored keys may differ in case from the canonical path — replace any match.
|
|
161
216
|
for (const k of Object.keys(names)) {
|
|
@@ -171,7 +226,8 @@ function readIgnores() {
|
|
|
171
226
|
}
|
|
172
227
|
|
|
173
228
|
function addIgnore(prefix) {
|
|
174
|
-
const
|
|
229
|
+
const raw = readJsonForWrite(IGNORE_FILE, []);
|
|
230
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
175
231
|
const key = canonicalize(prefix);
|
|
176
232
|
if (!list.some((p) => canonicalize(p).toLowerCase() === key.toLowerCase())) {
|
|
177
233
|
list.push(key);
|
|
@@ -181,7 +237,8 @@ function addIgnore(prefix) {
|
|
|
181
237
|
|
|
182
238
|
function removeIgnore(prefix) {
|
|
183
239
|
const key = canonicalize(prefix).toLowerCase();
|
|
184
|
-
|
|
240
|
+
const raw = readJsonForWrite(IGNORE_FILE, []);
|
|
241
|
+
writeJson(IGNORE_FILE, (Array.isArray(raw) ? raw : []).filter((p) => canonicalize(p).toLowerCase() !== key));
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
// Walk PATH for a binary; `exists` injected so the walk is unit-testable.
|
|
@@ -257,6 +314,9 @@ module.exports = {
|
|
|
257
314
|
readConfig,
|
|
258
315
|
readPricing,
|
|
259
316
|
configDir: () => CONFIG_DIR,
|
|
317
|
+
chooseConfigDir,
|
|
318
|
+
writeJson,
|
|
319
|
+
readJsonForWrite,
|
|
260
320
|
findOnPath,
|
|
261
321
|
detectClaudeApp,
|
|
262
322
|
resolvedTerminal,
|
package/lib/guard.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Request guards for the main listener. Pure, so they're unit-testable.
|
|
3
|
+
//
|
|
4
|
+
// DNS rebinding: a page on evil.example can re-point its own name at
|
|
5
|
+
// 127.0.0.1 and then read our GET routes as same-origin. The browser still
|
|
6
|
+
// sends `Host: evil.example`, so an EXACT hostname match on the Host header
|
|
7
|
+
// is the defence. A prefix match is not: `localhost.evil.example` is a name
|
|
8
|
+
// an attacker controls.
|
|
9
|
+
|
|
10
|
+
const LOOPBACK_NAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
11
|
+
|
|
12
|
+
// Hostname part of a Host header value, lowercased: 'LocalHost:4517' ->
|
|
13
|
+
// 'localhost', '[::1]:4517' -> '[::1]'. Empty string for garbage.
|
|
14
|
+
function hostnameOf(hostHeader) {
|
|
15
|
+
const h = String(hostHeader || '').trim().toLowerCase();
|
|
16
|
+
if (!h) return '';
|
|
17
|
+
if (h.startsWith('[')) {
|
|
18
|
+
const end = h.indexOf(']');
|
|
19
|
+
return end === -1 ? '' : h.slice(0, end + 1);
|
|
20
|
+
}
|
|
21
|
+
const colon = h.indexOf(':');
|
|
22
|
+
return colon === -1 ? h : h.slice(0, colon);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `bindHost` is what the server listens on. Accepted: loopback names, any
|
|
26
|
+
// name the user listed in `extraHosts` (CLAUDE_DASH_ALLOWED_HOSTS — e.g. the
|
|
27
|
+
// MagicDNS name `tailscale serve` forwards), and, when bound off-loopback,
|
|
28
|
+
// the bind address itself. A wildcard bind (0.0.0.0) adds nothing: list names.
|
|
29
|
+
function allowedHost(hostHeader, bindHost, extraHosts = []) {
|
|
30
|
+
const name = hostnameOf(hostHeader);
|
|
31
|
+
if (!name) return false;
|
|
32
|
+
if (LOOPBACK_NAMES.has(name)) return true;
|
|
33
|
+
if (extraHosts.some((h) => hostnameOf(h) === name)) return true;
|
|
34
|
+
return bindHost !== '127.0.0.1' && bindHost !== '0.0.0.0' && bindHost !== '::' && !!bindHost && name === bindHost.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// For state-changing routes. Browsers always send Origin on cross-origin
|
|
38
|
+
// POSTs; a missing Origin means a non-browser client (curl), which the Host
|
|
39
|
+
// check has already vetted. A present Origin must name exactly the host the
|
|
40
|
+
// request was sent to, so a page can never POST across origins, whatever
|
|
41
|
+
// the bind address.
|
|
42
|
+
function sameOrigin(originHeader, hostHeader) {
|
|
43
|
+
if (originHeader === undefined) return true;
|
|
44
|
+
let origin;
|
|
45
|
+
try {
|
|
46
|
+
origin = new URL(String(originHeader));
|
|
47
|
+
} catch {
|
|
48
|
+
return false; // includes the literal 'null' origin from sandboxed frames
|
|
49
|
+
}
|
|
50
|
+
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') return false;
|
|
51
|
+
return origin.host.toLowerCase() === String(hostHeader || '').trim().toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseAllowedHosts(env) {
|
|
55
|
+
return String(env || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { hostnameOf, allowedHost, sameOrigin, parseAllowedHosts };
|
package/lib/ignore.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { canonicalize } = require('./paths');
|
|
8
8
|
|
|
9
|
-
const IGNORE_FILE = path.join(
|
|
9
|
+
const IGNORE_FILE = path.join(require('./config').configDir(), 'ignore.json');
|
|
10
10
|
|
|
11
11
|
let cache = { mtimeMs: 0, prefixes: [] };
|
|
12
12
|
|
package/lib/names.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { canonicalize } = require('./paths');
|
|
6
6
|
|
|
7
|
-
const NAMES_FILE = path.join(
|
|
7
|
+
const NAMES_FILE = path.join(require('./config').configDir(), 'names.json');
|
|
8
8
|
|
|
9
9
|
let cache = { mtimeMs: 0, map: {} };
|
|
10
10
|
|
package/lib/transcripts.js
CHANGED
|
@@ -154,7 +154,7 @@ async function scanFile(abs, sessionIdFromName) {
|
|
|
154
154
|
}
|
|
155
155
|
if (meta.firstUserPrompt === null) {
|
|
156
156
|
const text = extractUserText(rec);
|
|
157
|
-
if (text) meta.firstUserPrompt = text.slice(0,
|
|
157
|
+
if (text) meta.firstUserPrompt = text.slice(0, 300); // room for tidyPromptTitle to drop a preamble
|
|
158
158
|
}
|
|
159
159
|
if (meta.cwd && meta.startedAt && meta.firstUserPrompt) break;
|
|
160
160
|
}
|
|
@@ -193,6 +193,8 @@ async function scanFile(abs, sessionIdFromName) {
|
|
|
193
193
|
meta.context = scan.lastContext || null;
|
|
194
194
|
meta.tools = scan.tools || null;
|
|
195
195
|
meta.lastTool = scan.lastTool || null;
|
|
196
|
+
meta.badLines = scan.badLines || 0;
|
|
197
|
+
meta.size = st.size;
|
|
196
198
|
|
|
197
199
|
cache.set(abs, { mtimeMs: st.mtimeMs, size: st.size, meta, scan });
|
|
198
200
|
return meta;
|
|
@@ -220,13 +222,20 @@ async function incrementalScan(abs, size, scan) {
|
|
|
220
222
|
}
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
// A line we picked out by its "type" marker but couldn't parse. Lines are
|
|
226
|
+
// only scanned once complete, so this is real damage or a format change —
|
|
227
|
+
// counted so the collector can say so instead of silently reading $0.
|
|
228
|
+
function badLine(scan) {
|
|
229
|
+
scan.badLines = (scan.badLines || 0) + 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
223
232
|
function scanLine(line, scan) {
|
|
224
233
|
if (line.includes('"type":"ai-title"')) {
|
|
225
234
|
try {
|
|
226
235
|
const rec = JSON.parse(line);
|
|
227
236
|
if (rec.aiTitle) scan.aiTitle = rec.aiTitle;
|
|
228
237
|
} catch {
|
|
229
|
-
|
|
238
|
+
badLine(scan);
|
|
230
239
|
}
|
|
231
240
|
} else if (line.includes('"type":"assistant"') && line.includes('"usage"')) {
|
|
232
241
|
try {
|
|
@@ -253,7 +262,7 @@ function scanLine(line, scan) {
|
|
|
253
262
|
addUsage(day, model, m.usage);
|
|
254
263
|
}
|
|
255
264
|
} catch {
|
|
256
|
-
|
|
265
|
+
badLine(scan);
|
|
257
266
|
}
|
|
258
267
|
} else if (line.includes('"type":"file-history-delta"')) {
|
|
259
268
|
try {
|
|
@@ -261,7 +270,7 @@ function scanLine(line, scan) {
|
|
|
261
270
|
const p = realPathOf(rec.trackingPath, rec.backup);
|
|
262
271
|
if (p) recordChange(scan.changes || (scan.changes = {}), p, rec.backup);
|
|
263
272
|
} catch {
|
|
264
|
-
|
|
273
|
+
badLine(scan);
|
|
265
274
|
}
|
|
266
275
|
} else if (line.includes('"type":"file-history-snapshot"')) {
|
|
267
276
|
try {
|
|
@@ -272,14 +281,14 @@ function scanLine(line, scan) {
|
|
|
272
281
|
if (p) recordChange(scan.changes || (scan.changes = {}), p, backup);
|
|
273
282
|
}
|
|
274
283
|
} catch {
|
|
275
|
-
|
|
284
|
+
badLine(scan);
|
|
276
285
|
}
|
|
277
286
|
} else if (line.includes('"is_error":true') && line.includes('"tool_use_id"')) {
|
|
278
287
|
try {
|
|
279
288
|
const rec = JSON.parse(line);
|
|
280
289
|
if (rec.type === 'user' && rec.message) countToolErrors(scan, rec.message.content);
|
|
281
290
|
} catch {
|
|
282
|
-
|
|
291
|
+
badLine(scan);
|
|
283
292
|
}
|
|
284
293
|
} else if (scan.lastAssistantTs && line.includes('"type":"user"') && !line.includes('"tool_use_id"')) {
|
|
285
294
|
// Your next real prompt after assistant output: bucket the gap.
|
|
@@ -296,7 +305,7 @@ function scanLine(line, scan) {
|
|
|
296
305
|
const waits = scan.waits || (scan.waits = [0, 0, 0, 0]);
|
|
297
306
|
waits[gap < 30000 ? 0 : gap < 120000 ? 1 : gap < 600000 ? 2 : 3]++;
|
|
298
307
|
} catch {
|
|
299
|
-
|
|
308
|
+
badLine(scan);
|
|
300
309
|
}
|
|
301
310
|
}
|
|
302
311
|
}
|
|
@@ -655,15 +664,74 @@ function dailyCostSeries(daysList, numDays, today = Date.now(), pricing = null)
|
|
|
655
664
|
// Best display title for a session, with its provenance.
|
|
656
665
|
// `custom` is a user-set name from config.json (sessionNames); it wins over
|
|
657
666
|
// anything derived from the transcript.
|
|
667
|
+
// A prompt reads badly as a title when it opens with where the work is:
|
|
668
|
+
// "In /Users/x/site/deploy.sh, the rsync flag…" or "In the Foo monorepo at
|
|
669
|
+
// `path` (branch y), the…". The card already names the project, so drop that
|
|
670
|
+
// clause, keep a file name it pointed at, and shorten home paths to ~/.
|
|
671
|
+
const LOCATION_WORDS = /\b(repo|repository|monorepo|project|folder|directory|codebase|theme|plugin|package|workspace)\b/i;
|
|
672
|
+
const homePaths = (t) => t.replace(/\/(?:Users|home)\/[^/\s]+\//g, '~/');
|
|
673
|
+
|
|
674
|
+
function leadingClauseEnd(t) {
|
|
675
|
+
let depth = 0, quote = null;
|
|
676
|
+
for (let i = 3; i < t.length; i++) {
|
|
677
|
+
const c = t[i];
|
|
678
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
679
|
+
if (c === '`' || c === '"') quote = c;
|
|
680
|
+
else if ('([{'.includes(c)) depth++;
|
|
681
|
+
else if (')]}'.includes(c)) depth = Math.max(0, depth - 1);
|
|
682
|
+
else if ((c === ',' || c === ':') && depth === 0 && (i + 1 === t.length || t[i + 1] === ' ')) return i;
|
|
683
|
+
}
|
|
684
|
+
return -1;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function tidyPromptTitle(text) {
|
|
688
|
+
const t = String(text || '');
|
|
689
|
+
if (/^In /.test(t)) {
|
|
690
|
+
const end = leadingClauseEnd(t);
|
|
691
|
+
const clause = end > 0 ? t.slice(3, end) : '';
|
|
692
|
+
if (clause && (clause.includes('/') || LOCATION_WORDS.test(clause))) {
|
|
693
|
+
const rest = t.slice(end + 1).trim();
|
|
694
|
+
if (rest.length >= 8) {
|
|
695
|
+
const file = /\/([\w.-]+\.[a-z][a-z0-9]{0,5})(?=[\s`"'),]|$)/i.exec(clause);
|
|
696
|
+
return homePaths(file ? `${file[1]}: ${rest}` : rest);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return homePaths(t);
|
|
701
|
+
}
|
|
702
|
+
|
|
658
703
|
function sessionTitle(meta, customNames) {
|
|
659
704
|
const custom = customNames && customNames[meta.sessionId];
|
|
660
705
|
if (custom) return { title: custom, source: 'custom' };
|
|
661
706
|
if (meta.aiTitle) return { title: meta.aiTitle, source: 'ai-title' };
|
|
662
|
-
if (meta.firstUserPrompt) return { title: meta.firstUserPrompt, source: 'first-prompt' };
|
|
663
|
-
if (meta.lastPrompt) return { title: meta.lastPrompt, source: 'last-prompt' };
|
|
707
|
+
if (meta.firstUserPrompt) return { title: tidyPromptTitle(meta.firstUserPrompt).slice(0, 120), source: 'first-prompt' };
|
|
708
|
+
if (meta.lastPrompt) return { title: tidyPromptTitle(meta.lastPrompt).slice(0, 120), source: 'last-prompt' };
|
|
664
709
|
return { title: meta.sessionId.slice(0, 8), source: 'session-id' };
|
|
665
710
|
}
|
|
666
711
|
|
|
712
|
+
// The scanner reads an undocumented format by substring match. When Claude
|
|
713
|
+
// Code changes it, nothing throws: costs just read $0. Pure check over a
|
|
714
|
+
// scan's metas; returns warning strings for the settings diagnostics.
|
|
715
|
+
// - drift: several recent, sizeable, interactive transcripts with no usage
|
|
716
|
+
// at all. Real sessions always have assistant turns; a format change is
|
|
717
|
+
// the likely cause.
|
|
718
|
+
// - damage: lines that carried a known "type" marker but didn't parse.
|
|
719
|
+
const DRIFT_MIN_BYTES = 64 * 1024;
|
|
720
|
+
const DRIFT_MIN_SESSIONS = 3;
|
|
721
|
+
const BAD_LINE_WARN = 5;
|
|
722
|
+
function scannerHealth(metas, now = Date.now()) {
|
|
723
|
+
const out = [];
|
|
724
|
+
const weekAgo = now - 7 * 86400000;
|
|
725
|
+
const recent = (metas || []).filter((m) => m.lastActivityAt >= weekAgo && (m.size || 0) >= DRIFT_MIN_BYTES && m.entrypoint !== 'sdk-cli');
|
|
726
|
+
const empty = recent.filter((m) => !Object.keys(m.usage || {}).length);
|
|
727
|
+
if (recent.length >= DRIFT_MIN_SESSIONS && empty.length === recent.length) {
|
|
728
|
+
out.push(`no token usage found in ${empty.length} recent transcripts — Claude Code's transcript format may have changed, so costs and limits will read low`);
|
|
729
|
+
}
|
|
730
|
+
const bad = (metas || []).reduce((n, m) => n + (m.badLines || 0), 0);
|
|
731
|
+
if (bad >= BAD_LINE_WARN) out.push(`${bad} transcript lines could not be parsed — some costs or titles may be missing`);
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
667
735
|
// Group scanned sessions by canonical project root (worktrees fold in).
|
|
668
736
|
function groupByProject(sessions) {
|
|
669
737
|
const byProject = new Map(); // lowercase root -> { path, sessions: [] }
|
|
@@ -685,7 +753,7 @@ function groupByProject(sessions) {
|
|
|
685
753
|
}
|
|
686
754
|
|
|
687
755
|
module.exports = {
|
|
688
|
-
scanAllTranscripts, scanFile, groupByProject, sessionTitle, sessionKind, combinedUsage, mergeUsage,
|
|
756
|
+
scanAllTranscripts, scanFile, groupByProject, sessionTitle, tidyPromptTitle, sessionKind, combinedUsage, mergeUsage,
|
|
689
757
|
scanLine, mergeDays, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, contextSummary, toolSummary,
|
|
690
|
-
waitingReason, reasonText, shouldReadMeta, TOOL_RING,
|
|
758
|
+
waitingReason, reasonText, shouldReadMeta, TOOL_RING, scannerHealth,
|
|
691
759
|
};
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -43,15 +43,17 @@ function indexHtml() {
|
|
|
43
43
|
return indexCache;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
const guard = require('./lib/guard');
|
|
47
|
+
// Extra names to accept in the Host header when bound off-loopback (e.g. a
|
|
48
|
+
// Tailscale MagicDNS name). Comma-separated.
|
|
49
|
+
const ALLOWED_HOSTS = guard.parseAllowedHosts(process.env.CLAUDE_DASH_ALLOWED_HOSTS);
|
|
50
|
+
|
|
46
51
|
function allowedHost(req) {
|
|
47
|
-
|
|
48
|
-
const h = String(req.headers.host || '');
|
|
49
|
-
return h.startsWith('127.0.0.1') || h.startsWith('localhost');
|
|
52
|
+
return guard.allowedHost(req.headers.host, HOST, ALLOWED_HOSTS);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function sameOrigin(req) {
|
|
53
|
-
|
|
54
|
-
return !origin || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin) || HOST !== '127.0.0.1';
|
|
56
|
+
return guard.sameOrigin(req.headers.origin, req.headers.host);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
function readBody(req, res, cb) {
|
|
@@ -503,8 +505,7 @@ const server = http.createServer((req, res) => {
|
|
|
503
505
|
|
|
504
506
|
if (url === '/api/open' && req.method === 'POST') {
|
|
505
507
|
// Same-origin only: browsers always send Origin on cross-origin POSTs.
|
|
506
|
-
|
|
507
|
-
if (origin && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) {
|
|
508
|
+
if (!sameOrigin(req)) {
|
|
508
509
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
509
510
|
res.end('{"ok":false,"error":"forbidden"}');
|
|
510
511
|
return;
|