claude-mission-control 1.23.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 +21 -0
- package/bin/claude-dashboard.js +4 -0
- package/lib/changes.js +4 -1
- package/lib/config.js +3 -2
- package/lib/mcp.js +282 -0
- package/lib/names.js +1 -1
- package/lib/paths.js +9 -4
- package/lib/search.js +52 -7
- package/package.json +1 -1
- package/public/index.html +1 -1
- package/server.js +5 -3
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
|
package/bin/claude-dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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/config.js
CHANGED
|
@@ -242,10 +242,11 @@ function removeIgnore(prefix) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
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.
|
|
245
246
|
function findOnPath(name, pathEnv = process.env.PATH || '', exists = fs.existsSync) {
|
|
246
|
-
for (const dir of String(pathEnv).split(
|
|
247
|
+
for (const dir of String(pathEnv).split(':')) {
|
|
247
248
|
if (!dir) continue;
|
|
248
|
-
const p = path.join(dir, name);
|
|
249
|
+
const p = path.posix.join(dir, name);
|
|
249
250
|
if (exists(p)) return p;
|
|
250
251
|
}
|
|
251
252
|
return null;
|
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
|
@@ -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(
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
241
|
+
module.exports = {
|
|
242
|
+
searchHistory, searchTitles, searchNotes, parseSearchQuery, transcriptLineMatch, searchTranscripts,
|
|
243
|
+
sessionMatchesFilters, sessionFilterFor,
|
|
244
|
+
};
|
package/package.json
CHANGED
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
|
|
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');
|
|
@@ -476,8 +476,10 @@ const server = http.createServer((req, res) => {
|
|
|
476
476
|
return;
|
|
477
477
|
}
|
|
478
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);
|
|
479
481
|
Promise.all([
|
|
480
|
-
searchHistory(q),
|
|
482
|
+
searchHistory(q, { allowSession }),
|
|
481
483
|
deep ? searchTranscripts(q, collector.raw.transcriptGroups) : Promise.resolve(null),
|
|
482
484
|
])
|
|
483
485
|
.then(([prompts, transcripts]) => {
|
|
@@ -488,7 +490,7 @@ const server = http.createServer((req, res) => {
|
|
|
488
490
|
for (const r of notes) r.projectName = friendlyName(r.project);
|
|
489
491
|
if (transcripts) for (const r of transcripts.matches) r.projectName = friendlyName(r.project);
|
|
490
492
|
let chats = null;
|
|
491
|
-
if (deep) {
|
|
493
|
+
if (deep && !allowSession) {
|
|
492
494
|
const { text, since } = parseSearchQuery(q);
|
|
493
495
|
const pool = since ? readChats().filter((c) => c.updatedAt >= since) : readChats();
|
|
494
496
|
chats = searchChats(text, pool);
|