claude-mission-control 1.22.0 → 1.24.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
@@ -137,6 +137,27 @@ Model, context use, your 5-hour limit, and this session's cost come from Claude
137
137
  { "statusLine": { "type": "command", "command": "claude-dashboard statusline", "padding": 0 } }
138
138
  ```
139
139
 
140
+ ## MCP server: let Claude ask Mission Control
141
+
142
+ `claude-dashboard mcp` is a read-only MCP server, so a Claude Code session can see what your other sessions are doing and search your past ones. Connect it once for every project:
143
+
144
+ ```bash
145
+ claude mcp add --scope user mission-control -- claude-dashboard mcp
146
+ ```
147
+
148
+ Then ask things like *"what did we decide about the contact form last week?"* or *"which sessions changed functions.php?"*. Four tools, none of which change anything:
149
+
150
+ - `live_sessions`: what is running now, which sessions wait on you and why, cost, context.
151
+ - `search_sessions`: the dashboard's search (prompts, titles, notes, transcripts) with the same filters.
152
+ - `read_session`: the latest turns of one session.
153
+ - `usage_summary`: 5-hour and weekly limits, when your pace runs out, this week's cost by project.
154
+
155
+ It answers from the running dashboard on `127.0.0.1`, so start the dashboard first. It needs a global install (npm or Homebrew), not npx.
156
+
157
+ ## Search filters
158
+
159
+ Search takes filters anywhere in the query: `project:<name>`, `since:7d` or `since:2026-11-01`, `m:<model>` (any model the session used), `b:<branch>` (the branch it started on), and `file:<name>` (a file it changed, from the same file history as the changes panel). A filter on its own, such as `file:functions.php`, lists every session that matches.
160
+
140
161
  ## Install (always-on)
141
162
 
142
163
  ```bash
@@ -311,7 +332,8 @@ Edit `ignore.json` — an array of absolute path prefixes. A project is hidden i
311
332
  | `CLAUDE_DASH_HOST` | `127.0.0.1` | Bind address — see below before changing |
312
333
  | `CLAUDE_DASH_DEMO` | unset | `1` = serve believable fake data (screenshots, trying it without Claude history) |
313
334
  | `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 (auto-falls back to `~/.config/claude-dashboard`) |
335
+ | `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 |
336
+ | `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
337
 
316
338
  The server binds to `127.0.0.1` only by default.
317
339
 
@@ -6,8 +6,12 @@
6
6
  // and never auto-open).
7
7
  // `claude-dashboard statusline` prints one line for Claude Code's statusLine
8
8
  // setting instead of starting anything.
9
+ // `claude-dashboard mcp` is a read-only MCP server over stdio for Claude Code
10
+ // (`claude mcp add --scope user mission-control -- claude-dashboard mcp`).
9
11
  if (process.argv[2] === 'statusline') {
10
12
  require('../lib/statusline').runStatusline();
13
+ } else if (process.argv[2] === 'mcp') {
14
+ require('../lib/mcp').runMcp();
11
15
  } else {
12
16
  if (!process.argv.includes('--no-open')) process.env.CLAUDE_DASH_OPEN = '1';
13
17
  require('../server.js');
package/lib/changes.js CHANGED
@@ -42,9 +42,12 @@ function mergeChanges(target, src) {
42
42
 
43
43
  // Real path for a file-history entry: the key/trackingPath can be an odd
44
44
  // encoded form, but realParentDir is always the true directory.
45
+ // Keys use forward slashes on every platform, like canonical project paths,
46
+ // so relPath can shorten them against the project root.
45
47
  function realPathOf(key, backup) {
46
48
  if (!backup || !backup.realParentDir || !key) return null;
47
- return path.join(backup.realParentDir, path.basename(String(key)));
49
+ const dir = String(backup.realParentDir).replace(/\\/g, '/').replace(/\/+$/, '');
50
+ return `${dir}/${String(key).replace(/\\/g, '/').split('/').pop()}`;
48
51
  }
49
52
 
50
53
  const SESSION_ID = /^[0-9a-f-]{8,64}$/i;
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
- fs.writeFileSync(CHATS_FILE, JSON.stringify(chats));
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).
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
- // Config lives beside the code when that's writable (git checkout — the
13
- // original layout). Under npx / a global npm install the package dir may be
14
- // read-only or ephemeral, so fall back to ~/.config/claude-dashboard.
15
- // CLAUDE_DASH_CONFIG_DIR overrides both.
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
- const os = require('os');
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
- const dir = path.join(os.homedir(), '.config', 'claude-dashboard');
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
- fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
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 = { ...readConfig() };
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 = { ...readConfig() };
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 = { ...readConfig() };
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 = { ...readConfig() };
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 = { ...readConfig() };
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 = readNames();
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 list = readIgnores();
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,14 +237,16 @@ function addIgnore(prefix) {
181
237
 
182
238
  function removeIgnore(prefix) {
183
239
  const key = canonicalize(prefix).toLowerCase();
184
- writeJson(IGNORE_FILE, readIgnores().filter((p) => canonicalize(p).toLowerCase() !== key));
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.
245
+ // Linux-only (detectTerminals), so PATH is ':'-separated with posix joins.
188
246
  function findOnPath(name, pathEnv = process.env.PATH || '', exists = fs.existsSync) {
189
- for (const dir of String(pathEnv).split(path.delimiter)) {
247
+ for (const dir of String(pathEnv).split(':')) {
190
248
  if (!dir) continue;
191
- const p = path.join(dir, name);
249
+ const p = path.posix.join(dir, name);
192
250
  if (exists(p)) return p;
193
251
  }
194
252
  return null;
@@ -257,6 +315,9 @@ module.exports = {
257
315
  readConfig,
258
316
  readPricing,
259
317
  configDir: () => CONFIG_DIR,
318
+ chooseConfigDir,
319
+ writeJson,
320
+ readJsonForWrite,
260
321
  findOnPath,
261
322
  detectClaudeApp,
262
323
  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(__dirname, '..', 'ignore.json');
9
+ const IGNORE_FILE = path.join(require('./config').configDir(), 'ignore.json');
10
10
 
11
11
  let cache = { mtimeMs: 0, prefixes: [] };
12
12
 
package/lib/mcp.js ADDED
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+ // `claude-dashboard mcp`: a Model Context Protocol server over stdio, so a
3
+ // Claude Code session can ask Mission Control what the other sessions are
4
+ // doing, search past ones, and check limits. Read-only by construction: the
5
+ // four tools below are the whole surface, and each one is a fixed GET to the
6
+ // running dashboard on 127.0.0.1 — no listener, nothing that runs a command.
7
+ // The pure handler takes `get` so tests drive it without a server.
8
+ const http = require('http');
9
+ const readline = require('readline');
10
+ const { version: VERSION } = require('../package.json');
11
+ const { parseSearchQuery } = require('./search');
12
+
13
+ const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
14
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
16
+
17
+ const MCP_TOOLS = [
18
+ {
19
+ name: 'live_sessions',
20
+ description:
21
+ 'Claude Code sessions running on this machine right now, sessions waiting on the user first: project, title, status, ' +
22
+ 'what a waiting session needs, model, estimated cost so far and burn rate, context use, and its latest action.',
23
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
24
+ annotations: { title: 'Live sessions', ...READ_ONLY },
25
+ },
26
+ {
27
+ name: 'search_sessions',
28
+ description:
29
+ 'Search past and present Claude Code sessions on this machine: every prompt the user sent, session titles, the ' +
30
+ "user's session notes and (deep, the default) full transcripts. Filters go in the query: project:<name>, " +
31
+ 'since:7d or since:YYYY-MM-DD, m:<model>, b:<branch>, file:<changed file>. A filter-only query such as ' +
32
+ '"file:functions.php" lists the sessions that changed that file. Use read_session on a sessionId to read one.',
33
+ inputSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ query: { type: 'string', minLength: 2, maxLength: 200, description: 'Words to find, plus any filters.' },
37
+ deep: { type: 'boolean', description: 'Also search full transcripts (slower). Default true.' },
38
+ },
39
+ required: ['query'],
40
+ additionalProperties: false,
41
+ },
42
+ annotations: { title: 'Search sessions', ...READ_ONLY },
43
+ },
44
+ {
45
+ name: 'read_session',
46
+ description:
47
+ "One session's recent turns: what the user said, Claude's replies, and the tools it called with a short summary " +
48
+ 'of each input. Long messages are clipped.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ sessionId: { type: 'string', description: 'A session id from live_sessions or search_sessions.' },
53
+ last: { type: 'integer', minimum: 1, maximum: 200, description: 'How many of the latest turns to return. Default 40.' },
54
+ },
55
+ required: ['sessionId'],
56
+ additionalProperties: false,
57
+ },
58
+ annotations: { title: 'Read session', ...READ_ONLY },
59
+ },
60
+ {
61
+ name: 'usage_summary',
62
+ description:
63
+ "The user's Claude usage: 5-hour and weekly limit use with reset times, when the current pace runs out, this " +
64
+ "week's sessions, prompts and estimated cost by project, all-time totals, and the most expensive sessions. Costs are " +
65
+ 'estimates at list price.',
66
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
67
+ annotations: { title: 'Usage summary', ...READ_ONLY },
68
+ },
69
+ ];
70
+
71
+ const iso = (t) => (typeof t === 'number' && Number.isFinite(t) ? new Date(t).toISOString() : null);
72
+ const isoAny = (t) => (typeof t === 'string' ? iso(Date.parse(t)) : iso(t));
73
+
74
+ function liveSummary(state) {
75
+ const sessions = (state.liveSessions || []).map((s) => ({
76
+ sessionId: s.sessionId,
77
+ project: s.projectName,
78
+ title: s.title || null,
79
+ status: s.status,
80
+ needsYou: s.status === 'waiting',
81
+ waitingFor: s.waitingFor || null,
82
+ waitingReason: s.waitingReason || null,
83
+ model: s.model || null,
84
+ estCost: s.estCost ?? null,
85
+ burnRatePerHour: s.burnRate ?? null,
86
+ contextPct: s.context ? s.context.pct : null,
87
+ lastAction: s.lastAction ? [s.lastAction.verb, s.lastAction.target].filter(Boolean).join(' ') : null,
88
+ background: Boolean(s.background),
89
+ startedAt: iso(s.startedAt),
90
+ }));
91
+ sessions.sort((a, b) => Number(b.needsYou) - Number(a.needsYou));
92
+ return { sessions };
93
+ }
94
+
95
+ function searchSummary(r) {
96
+ const out = {
97
+ prompts: (r.prompts || []).map((p) => ({ project: p.projectName, sessionId: p.sessionId, at: iso(p.timestamp), text: p.snippet })),
98
+ titles: (r.titles || []).map((t) => ({ project: t.projectName, sessionId: t.sessionId, at: iso(t.lastActivityAt), title: t.title })),
99
+ notes: (r.notes || []).map((n) => ({ project: n.projectName, sessionId: n.sessionId, at: iso(n.lastActivityAt), note: n.snippet })),
100
+ };
101
+ if (r.transcripts) {
102
+ out.transcripts = {
103
+ matches: r.transcripts.matches.map((m) => ({ project: m.projectName, sessionId: m.sessionId, role: m.role, at: iso(m.ts), text: m.text })),
104
+ scanned: r.transcripts.scanned,
105
+ total: r.transcripts.total,
106
+ complete: r.transcripts.complete,
107
+ };
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function sessionSummary(r, last) {
113
+ const all = r.events || [];
114
+ const events = all.slice(-last).map((e) =>
115
+ e.kind === 'tool'
116
+ ? { kind: 'tool', at: iso(e.ts), tool: e.name, input: e.input }
117
+ : { kind: e.kind, at: iso(e.ts), text: e.text, ...(e.truncated ? { clipped: true } : {}) }
118
+ );
119
+ return {
120
+ sessionId: r.sessionId,
121
+ title: r.title || null,
122
+ project: r.projectName || null,
123
+ note: r.note || null,
124
+ filesChanged: r.changeCount || 0,
125
+ earlierTurnsOmitted: Math.max(0, all.length - events.length) + (r.truncatedTurns || 0),
126
+ events,
127
+ };
128
+ }
129
+
130
+ function usageSummary(state, stats) {
131
+ const q = state.quota || {};
132
+ const f = state.forecast || {};
133
+ return {
134
+ limits: {
135
+ fiveHourPct: q.utilization ?? null,
136
+ fiveHourResetsAt: isoAny(q.resetsAt),
137
+ fiveHourRunsOutAt: iso(f.session && f.session.hitAt),
138
+ weeklyPct: q.weeklyUtilization ?? null,
139
+ weeklyResetsAt: isoAny(q.weeklyResetsAt),
140
+ weekRunsOutAt: iso(f.week && f.week.hitAt),
141
+ stale: Boolean(q.stale),
142
+ },
143
+ week: stats.week || null,
144
+ allTime: stats.totals || null,
145
+ topSessions: (stats.topSessions || []).slice(0, 5).map((s) => ({
146
+ sessionId: s.sessionId, title: s.title, project: s.projectName, model: s.model, cost: s.cost, at: iso(s.lastActivityAt),
147
+ })),
148
+ };
149
+ }
150
+
151
+ const text = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj) }] });
152
+ const toolError = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true });
153
+
154
+ async function runTool(name, args, get) {
155
+ if (name === 'live_sessions') return text(liveSummary(await get('/api/state')));
156
+ if (name === 'search_sessions') {
157
+ const q = typeof args.query === 'string' ? args.query.trim() : '';
158
+ if (q.length < 2 || q.length > 200) return toolError('query must be 2 to 200 characters.');
159
+ const deep = args.deep === false ? '0' : '1';
160
+ // Echo what the query meant: every result passed these filters, which a
161
+ // filter-only query's matches can't show on their own.
162
+ const p = parseSearchQuery(q);
163
+ const filters = {};
164
+ for (const k of ['project', 'model', 'branch', 'file']) if (p[k]) filters[k] = p[k];
165
+ if (p.since) filters.since = iso(p.since);
166
+ const found = searchSummary(await get(`/api/search?q=${encodeURIComponent(q)}&deep=${deep}`));
167
+ return text({ matchedText: p.text || null, filters, ...found });
168
+ }
169
+ if (name === 'read_session') {
170
+ const id = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
171
+ if (!SESSION_ID.test(id)) return toolError('sessionId must be a session id (a UUID) from live_sessions or search_sessions.');
172
+ const last = Number.isInteger(args.last) ? Math.min(200, Math.max(1, args.last)) : 40;
173
+ return text(sessionSummary(await get(`/api/session?id=${id}`), last));
174
+ }
175
+ if (name === 'usage_summary') {
176
+ const [state, stats] = await Promise.all([get('/api/state'), get('/api/stats')]);
177
+ return text(usageSummary(state, stats));
178
+ }
179
+ return null;
180
+ }
181
+
182
+ // One JSON-RPC message in, one reply out (null for notifications).
183
+ function createMcpHandler({ get, port = 4517 }) {
184
+ return async function handle(msg) {
185
+ const isRequest = msg && msg.id !== undefined && msg.id !== null;
186
+ if (!isRequest) return null;
187
+ const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result });
188
+ const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } });
189
+ const params = msg.params || {};
190
+ switch (msg.method) {
191
+ case 'initialize':
192
+ return reply({
193
+ protocolVersion: PROTOCOLS.includes(params.protocolVersion) ? params.protocolVersion : PROTOCOLS[0],
194
+ capabilities: { tools: {} },
195
+ serverInfo: { name: 'mission-control', title: 'Claude Mission Control', version: VERSION },
196
+ instructions:
197
+ 'Read-only view of the Claude Code sessions on this machine, from the Mission Control dashboard. ' +
198
+ 'Use search_sessions to find past work and decisions, read_session to read one, live_sessions for what is ' +
199
+ 'running now, usage_summary for limits and cost.',
200
+ });
201
+ case 'ping':
202
+ return reply({});
203
+ case 'tools/list':
204
+ return reply({ tools: MCP_TOOLS });
205
+ case 'tools/call': {
206
+ if (!MCP_TOOLS.some((t) => t.name === params.name)) return fail(-32602, `Unknown tool: ${String(params.name).slice(0, 60)}`);
207
+ try {
208
+ return reply(await runTool(params.name, params.arguments || {}, get));
209
+ } catch (e) {
210
+ if (e && (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET')) {
211
+ return reply(toolError(
212
+ `Mission Control isn't running at http://127.0.0.1:${port}. Start it with \`claude-dashboard\` ` +
213
+ '(or `brew services start claude-dashboard`), then try again.'
214
+ ));
215
+ }
216
+ return reply(toolError(`Mission Control error: ${String(e && e.message).slice(0, 200)}`));
217
+ }
218
+ }
219
+ default:
220
+ return fail(-32601, `Method not found: ${String(msg.method).slice(0, 60)}`);
221
+ }
222
+ };
223
+ }
224
+
225
+ // GET a dashboard endpoint on loopback. The path is always one built above.
226
+ function dashGet(port, timeoutMs = 15000) {
227
+ return (p) =>
228
+ new Promise((resolve, reject) => {
229
+ const req = http.get({ host: '127.0.0.1', port, path: p, timeout: timeoutMs }, (res) => {
230
+ let body = '';
231
+ res.setEncoding('utf8');
232
+ res.on('data', (c) => (body += c));
233
+ res.on('end', () => {
234
+ if (res.statusCode === 404) return reject(new Error('not found (unknown session?)'));
235
+ if (res.statusCode !== 200) return reject(new Error(`dashboard answered ${res.statusCode}`));
236
+ try {
237
+ resolve(JSON.parse(body));
238
+ } catch {
239
+ reject(new Error('dashboard sent something that is not JSON'));
240
+ }
241
+ });
242
+ });
243
+ req.on('timeout', () => req.destroy(new Error('dashboard took too long to answer')));
244
+ req.on('error', reject);
245
+ });
246
+ }
247
+
248
+ // stdio transport: newline-delimited JSON-RPC. stdout carries only protocol
249
+ // messages; anything else goes to stderr.
250
+ function runMcp() {
251
+ const port = Number(process.env.CLAUDE_DASH_PORT) || 4517;
252
+ const handle = createMcpHandler({ get: dashGet(port), port });
253
+ const send = (m) => process.stdout.write(JSON.stringify(m) + '\n');
254
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
255
+ // Replies are async; on stdin close, finish the ones in flight, then exit.
256
+ let pending = 0;
257
+ let closed = false;
258
+ const maybeExit = () => closed && pending === 0 && process.exit(0);
259
+ rl.on('line', (line) => {
260
+ if (!line.trim()) return;
261
+ let msg;
262
+ try {
263
+ msg = JSON.parse(line);
264
+ } catch {
265
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
266
+ return;
267
+ }
268
+ pending++;
269
+ handle(msg)
270
+ .then((r) => r && send(r), (e) => process.stderr.write(`mcp: ${e && e.stack}\n`))
271
+ .finally(() => {
272
+ pending--;
273
+ maybeExit();
274
+ });
275
+ });
276
+ rl.on('close', () => {
277
+ closed = true;
278
+ maybeExit();
279
+ });
280
+ }
281
+
282
+ module.exports = { createMcpHandler, MCP_TOOLS, runMcp, liveSummary, searchSummary, sessionSummary, usageSummary };
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(__dirname, '..', 'names.json');
7
+ const NAMES_FILE = path.join(require('./config').configDir(), 'names.json');
8
8
 
9
9
  let cache = { mtimeMs: 0, map: {} };
10
10
 
@@ -28,7 +28,7 @@ function loadNames() {
28
28
  const GENERIC = new Set(['app', 'public', 'wp-content', 'themes', 'plugins', 'src', 'site']);
29
29
 
30
30
  function displayBase(p) {
31
- const parts = canonicalize(p).split(path.sep).filter(Boolean);
31
+ const parts = canonicalize(p).split('/').filter(Boolean); // canonical form is '/' everywhere
32
32
  for (let i = parts.length - 1; i >= 0; i--) {
33
33
  if (!GENERIC.has(parts[i].toLowerCase())) return parts[i];
34
34
  }
package/lib/paths.js CHANGED
@@ -10,11 +10,16 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
10
10
  // no trailing slash. Display strings keep whatever the source gave us.
11
11
  function canonicalize(p) {
12
12
  if (!p) return p;
13
- // Don't run path.resolve on a Windows-style path when we're not on Windows
14
- // (it would prefix the posix cwd) — those appear in tests and in data
15
- // copied between machines.
13
+ // Don't resolve a path written for the other platform: on posix,
14
+ // path.resolve would prefix a Windows path with the cwd; on Windows, it
15
+ // would put the current drive on a posix path. Both appear in tests and in
16
+ // data copied between machines. (`//server` is a UNC path, so it resolves.)
16
17
  const winStyle = /^[A-Za-z]:[\\/]/.test(p);
17
- let r = winStyle && process.platform !== 'win32' ? p : path.resolve(p);
18
+ const posixStyle = /^\/(?!\/)/.test(p);
19
+ let r;
20
+ if (winStyle && process.platform !== 'win32') r = p;
21
+ else if (posixStyle && process.platform === 'win32') r = path.posix.normalize(p);
22
+ else r = path.resolve(p);
18
23
  r = r.replace(/\\/g, '/');
19
24
  if (r.length > 1 && r.endsWith('/')) r = r.slice(0, -1);
20
25
  return r;
package/lib/search.js CHANGED
@@ -13,11 +13,20 @@ const MAX_RESULTS = 60;
13
13
  // 'project:dashboard since:7d fix the build' -> filters plus remaining text.
14
14
  // project: substring-matches the canonical project path; since: takes Nd or
15
15
  // YYYY-MM-DD (local). Malformed values are dropped rather than erroring.
16
+ // m:/model:, b:/branch: and file: are session filters: substring matches on
17
+ // any model the session used, the branch it started on, and the paths of the
18
+ // files it changed.
16
19
  function parseSearchQuery(q, now = Date.now()) {
17
20
  let project = null;
18
21
  let since = null;
22
+ let model = null;
23
+ let branch = null;
24
+ let file = null;
19
25
  const text = String(q)
20
26
  .replace(/(?:^|\s)project:(\S+)/i, (_, v) => { project = v.toLowerCase(); return ' '; })
27
+ .replace(/(?:^|\s)(?:m|model):(\S+)/i, (_, v) => { model = v.toLowerCase(); return ' '; })
28
+ .replace(/(?:^|\s)(?:b|branch):(\S+)/i, (_, v) => { branch = v.toLowerCase(); return ' '; })
29
+ .replace(/(?:^|\s)file:(\S+)/i, (_, v) => { file = v.toLowerCase(); return ' '; })
21
30
  .replace(/(?:^|\s)since:(\S+)/i, (_, v) => {
22
31
  const rel = /^(\d+)d$/i.exec(v);
23
32
  const abs = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
@@ -27,16 +36,42 @@ function parseSearchQuery(q, now = Date.now()) {
27
36
  })
28
37
  .replace(/\s+/g, ' ')
29
38
  .trim();
30
- return { text, project, since };
39
+ return { text, project, since, model, branch, file };
31
40
  }
32
41
 
33
- function searchHistory(q) {
42
+ const hasSessionFilters = (f) => Boolean(f.model || f.branch || f.file);
43
+
44
+ // A session's transcript meta against the session filters. A filter the
45
+ // session has no data for (no branch, no changes) fails rather than passes.
46
+ function sessionMatchesFilters(meta, f) {
47
+ if (f.model) {
48
+ const models = [meta.model, ...Object.keys(meta.usage || {})].filter(Boolean);
49
+ if (!models.some((m) => m.toLowerCase().includes(f.model))) return false;
50
+ }
51
+ if (f.branch && !(meta.gitBranch || '').toLowerCase().includes(f.branch)) return false;
52
+ if (f.file && !Object.keys(meta.changes || {}).some((p) => p.toLowerCase().includes(f.file))) return false;
53
+ return true;
54
+ }
55
+
56
+ // For sources keyed only by session id (history.jsonl): a predicate over the
57
+ // ids of sessions passing the filters, or null when there are none to apply.
58
+ function sessionFilterFor(q, transcriptGroups) {
59
+ const f = parseSearchQuery(q);
60
+ if (!hasSessionFilters(f)) return null;
61
+ const ok = new Set();
62
+ for (const g of transcriptGroups.values()) {
63
+ for (const m of g.sessions) if (sessionMatchesFilters(m, f)) ok.add(m.sessionId);
64
+ }
65
+ return (id) => Boolean(id) && ok.has(id);
66
+ }
67
+
68
+ function searchHistory(q, { allowSession = null, file = HISTORY_FILE } = {}) {
34
69
  const { text, project, since } = parseSearchQuery(q);
35
70
  const needle = text.toLowerCase();
36
71
  return new Promise((resolve) => {
37
72
  const matches = [];
38
73
  const rl = readline.createInterface({
39
- input: fs.createReadStream(HISTORY_FILE, { encoding: 'utf8' }),
74
+ input: fs.createReadStream(file, { encoding: 'utf8' }),
40
75
  crlfDelay: Infinity,
41
76
  });
42
77
  rl.on('line', (line) => {
@@ -48,6 +83,7 @@ function searchHistory(q) {
48
83
  if (isIgnored(root)) return;
49
84
  if (project && !root.toLowerCase().includes(project)) return;
50
85
  if (since && !(rec.timestamp >= since)) return;
86
+ if (allowSession && !allowSession(rec.sessionId)) return;
51
87
  const i = rec.display.toLowerCase().indexOf(needle);
52
88
  const start = Math.max(0, i - 60);
53
89
  matches.push({
@@ -71,7 +107,8 @@ function searchHistory(q) {
71
107
 
72
108
  // Title matches come from the collector's in-memory transcript groups.
73
109
  function searchTitles(q, transcriptGroups, sessionTitle) {
74
- const { text, project, since } = parseSearchQuery(q);
110
+ const f = parseSearchQuery(q);
111
+ const { text, project, since } = f;
75
112
  const needle = text.toLowerCase();
76
113
  const out = [];
77
114
  for (const g of transcriptGroups.values()) {
@@ -79,6 +116,7 @@ function searchTitles(q, transcriptGroups, sessionTitle) {
79
116
  if (project && !g.path.toLowerCase().includes(project)) continue;
80
117
  for (const m of g.sessions) {
81
118
  if (since && !(m.lastActivityAt >= since)) continue;
119
+ if (!sessionMatchesFilters(m, f)) continue;
82
120
  const { title } = sessionTitle(m);
83
121
  if (title && title.toLowerCase().includes(needle)) {
84
122
  out.push({ title, project: g.path, sessionId: m.sessionId, lastActivityAt: m.lastActivityAt });
@@ -91,7 +129,8 @@ function searchTitles(q, transcriptGroups, sessionTitle) {
91
129
 
92
130
  // Notes the user attached to sessions (config.json sessionNotes).
93
131
  function searchNotes(q, transcriptGroups, sessionNotes, now = Date.now()) {
94
- const { text, project, since } = parseSearchQuery(q, now);
132
+ const f = parseSearchQuery(q, now);
133
+ const { text, project, since } = f;
95
134
  const needle = text.toLowerCase();
96
135
  if (!needle) return [];
97
136
  const out = [];
@@ -103,6 +142,7 @@ function searchNotes(q, transcriptGroups, sessionNotes, now = Date.now()) {
103
142
  const note = typeof raw === 'string' ? raw : raw == null ? '' : String(raw);
104
143
  if (!note || !note.toLowerCase().includes(needle)) continue;
105
144
  if (since && !(m.lastActivityAt >= since)) continue;
145
+ if (!sessionMatchesFilters(m, f)) continue;
106
146
  const i = note.toLowerCase().indexOf(needle);
107
147
  const start = Math.max(0, i - 60);
108
148
  out.push({
@@ -157,7 +197,8 @@ const MAX_DEEP_RESULTS = 40;
157
197
  const MAX_PER_SESSION = 3;
158
198
 
159
199
  async function searchTranscripts(q, transcriptGroups) {
160
- const { text, project, since } = parseSearchQuery(q);
200
+ const f = parseSearchQuery(q);
201
+ const { text, project, since } = f;
161
202
  const needle = text.toLowerCase();
162
203
  if (!needle) return { matches: [], scanned: 0, total: 0, complete: true };
163
204
  const sessions = [];
@@ -166,6 +207,7 @@ async function searchTranscripts(q, transcriptGroups) {
166
207
  if (project && !g.path.toLowerCase().includes(project)) continue;
167
208
  for (const m of g.sessions) {
168
209
  if (since && !(m.lastActivityAt >= since)) continue;
210
+ if (!sessionMatchesFilters(m, f)) continue;
169
211
  sessions.push({ file: m.file, sessionId: m.sessionId, project: g.path, lastActivityAt: m.lastActivityAt });
170
212
  }
171
213
  }
@@ -196,4 +238,7 @@ async function searchTranscripts(q, transcriptGroups) {
196
238
  return { matches, scanned, total: sessions.length, complete: scanned >= sessions.length };
197
239
  }
198
240
 
199
- module.exports = { searchHistory, searchTitles, searchNotes, parseSearchQuery, transcriptLineMatch, searchTranscripts };
241
+ module.exports = {
242
+ searchHistory, searchTitles, searchNotes, parseSearchQuery, transcriptLineMatch, searchTranscripts,
243
+ sessionMatchesFilters, sessionFilterFor,
244
+ };
@@ -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
- /* ignore */
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
- /* ignore */
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
- /* ignore */
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
- /* ignore */
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
- /* ignore */
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
- /* ignore */
308
+ badLine(scan);
300
309
  }
301
310
  }
302
311
  }
@@ -700,6 +709,29 @@ function sessionTitle(meta, customNames) {
700
709
  return { title: meta.sessionId.slice(0, 8), source: 'session-id' };
701
710
  }
702
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
+
703
735
  // Group scanned sessions by canonical project root (worktrees fold in).
704
736
  function groupByProject(sessions) {
705
737
  const byProject = new Map(); // lowercase root -> { path, sessions: [] }
@@ -723,5 +755,5 @@ function groupByProject(sessions) {
723
755
  module.exports = {
724
756
  scanAllTranscripts, scanFile, groupByProject, sessionTitle, tidyPromptTitle, sessionKind, combinedUsage, mergeUsage,
725
757
  scanLine, mergeDays, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, contextSummary, toolSummary,
726
- waitingReason, reasonText, shouldReadMeta, TOOL_RING,
758
+ waitingReason, reasonText, shouldReadMeta, TOOL_RING, scannerHealth,
727
759
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.22.0",
3
+ "version": "1.24.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
@@ -2730,7 +2730,7 @@ async function runSearch(q, deep) {
2730
2730
  deepRows ? `<ul class="d-sessions">${deepRows}</ul>` : null, 'No matches inside transcripts.')
2731
2731
  : `<div style="margin:14px 0"><button class="copy" data-deep-search="${esc(q)}">search inside full transcripts →</button>
2732
2732
  <span class="s-when" style="margin-left:8px">reads every conversation, not just your prompts</span></div>`;
2733
- $('#d-path').textContent = `${d.prompts.length + d.titles.length + (t ? t.matches.length : 0) + (d.notes || []).length} results — narrow with project:name or since:7d`;
2733
+ $('#d-path').textContent = `${d.prompts.length + d.titles.length + (t ? t.matches.length : 0) + (d.notes || []).length} results — narrow with project:name, since:7d, m:opus, b:branch or file:name`;
2734
2734
  const titleRows = d.titles.map((t) => `
2735
2735
  <li>
2736
2736
  <span class="s-title" data-session="${esc(t.sessionId)}" title="Click to read the transcript">${esc(t.title)}</span>
package/server.js CHANGED
@@ -9,7 +9,7 @@ const { Collector } = require('./lib/collector');
9
9
  const { openSession, openNewSession, revealTarget, revealFile } = require('./lib/opener');
10
10
  const { projectDetail } = require('./lib/detail');
11
11
  const { sessionTranscript } = require('./lib/transcript-view');
12
- const { searchHistory, searchTitles, searchNotes, searchTranscripts, parseSearchQuery } = require('./lib/search');
12
+ const { searchHistory, searchTitles, searchNotes, searchTranscripts, parseSearchQuery, sessionFilterFor } = require('./lib/search');
13
13
  const { readChats, saveChats, searchChats } = require('./lib/chats');
14
14
  const { sessionTitle } = require('./lib/transcripts');
15
15
  const { friendlyName } = require('./lib/names');
@@ -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
- if (HOST !== '127.0.0.1') return true; // explicitly opted into remote access
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
- const origin = req.headers.origin;
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) {
@@ -474,8 +476,10 @@ const server = http.createServer((req, res) => {
474
476
  return;
475
477
  }
476
478
  const deep = new URL(req.url, 'http://localhost').searchParams.get('deep') === '1';
479
+ // m:/b:/file: describe Claude Code sessions; claude.ai chats have none of that.
480
+ const allowSession = sessionFilterFor(q, collector.raw.transcriptGroups);
477
481
  Promise.all([
478
- searchHistory(q),
482
+ searchHistory(q, { allowSession }),
479
483
  deep ? searchTranscripts(q, collector.raw.transcriptGroups) : Promise.resolve(null),
480
484
  ])
481
485
  .then(([prompts, transcripts]) => {
@@ -486,7 +490,7 @@ const server = http.createServer((req, res) => {
486
490
  for (const r of notes) r.projectName = friendlyName(r.project);
487
491
  if (transcripts) for (const r of transcripts.matches) r.projectName = friendlyName(r.project);
488
492
  let chats = null;
489
- if (deep) {
493
+ if (deep && !allowSession) {
490
494
  const { text, since } = parseSearchQuery(q);
491
495
  const pool = since ? readChats().filter((c) => c.updatedAt >= since) : readChats();
492
496
  chats = searchChats(text, pool);
@@ -503,8 +507,7 @@ const server = http.createServer((req, res) => {
503
507
 
504
508
  if (url === '/api/open' && req.method === 'POST') {
505
509
  // Same-origin only: browsers always send Origin on cross-origin POSTs.
506
- const origin = req.headers.origin;
507
- if (origin && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) {
510
+ if (!sameOrigin(req)) {
508
511
  res.writeHead(403, { 'Content-Type': 'application/json' });
509
512
  res.end('{"ok":false,"error":"forbidden"}');
510
513
  return;