agentdashpulse 1.0.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/LICENSE +21 -0
- package/README.md +201 -0
- package/package.json +38 -0
- package/public/index.html +1532 -0
- package/server.js +1636 -0
package/server.js
ADDED
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { WebSocketServer } = require('ws');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const { spawn, execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
// ── Constants ───────────────────────────────────────────
|
|
11
|
+
const PORT = process.env.PORT || 3456;
|
|
12
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
13
|
+
const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions');
|
|
14
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
15
|
+
const SESSION_NAMES_FILE = path.join(__dirname, 'session-names.json');
|
|
16
|
+
const AI_SUMMARIES_DIR = path.join(CLAUDE_DIR, 'claude-code-dashboard');
|
|
17
|
+
const AI_SUMMARIES_FILE = path.join(AI_SUMMARIES_DIR, 'ai-summaries.json');
|
|
18
|
+
const GLOBAL_CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
|
|
19
|
+
|
|
20
|
+
// ── Copilot Paths ──────────────────────────────────────
|
|
21
|
+
const COPILOT_DIR = path.join(os.homedir(), '.copilot');
|
|
22
|
+
const COPILOT_SESSION_STATE_DIR = path.join(COPILOT_DIR, 'session-state');
|
|
23
|
+
const COPILOT_MCP_CONFIG = path.join(COPILOT_DIR, 'mcp-config.json');
|
|
24
|
+
const COPILOT_CONFIG = path.join(COPILOT_DIR, 'config.json');
|
|
25
|
+
const COPILOT_AGENTS_DIR = path.join(COPILOT_DIR, 'agents');
|
|
26
|
+
|
|
27
|
+
const WS_PUSH_INTERVAL_MS = 5000;
|
|
28
|
+
const WS_MAX_SESSIONS = 30;
|
|
29
|
+
const AI_SUMMARY_CACHE_TTL_MS = 600000; // 10 min
|
|
30
|
+
|
|
31
|
+
// ── Claude CLI Check ───────────────────────────────────
|
|
32
|
+
let claudeCliStatus = { available: false, version: null, checkedAt: null };
|
|
33
|
+
|
|
34
|
+
function checkClaudeCli() {
|
|
35
|
+
try {
|
|
36
|
+
const version = execSync('claude --version', { encoding: 'utf8', timeout: 5000, shell: true }).trim();
|
|
37
|
+
claudeCliStatus = { available: true, version, checkedAt: Date.now() };
|
|
38
|
+
} catch {
|
|
39
|
+
claudeCliStatus = { available: false, version: null, checkedAt: Date.now() };
|
|
40
|
+
}
|
|
41
|
+
return claudeCliStatus;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Session Names Persistence ───────────────────────────
|
|
45
|
+
|
|
46
|
+
function loadSessionNames() {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(SESSION_NAMES_FILE, 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saveSessionNames(names) {
|
|
55
|
+
fs.writeFileSync(SESSION_NAMES_FILE, JSON.stringify(names, null, 2), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Generate a suggested name for a session based on its content */
|
|
59
|
+
function suggestSessionName(session) {
|
|
60
|
+
const hints = [];
|
|
61
|
+
|
|
62
|
+
// From user messages — extract key topics
|
|
63
|
+
const userEvents = (session.recentEvents || []).filter(e => e.type === 'user' && e.text);
|
|
64
|
+
if (userEvents.length > 0) {
|
|
65
|
+
// Take first few user messages as topic indicators
|
|
66
|
+
const firstMsgs = userEvents.slice(0, 3).map(e => e.text.slice(0, 100));
|
|
67
|
+
hints.push(...firstMsgs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// From todos — task descriptions are very descriptive
|
|
71
|
+
const todos = session.todos || [];
|
|
72
|
+
if (todos.length > 0) {
|
|
73
|
+
hints.push(todos.map(t => t.content).join('; '));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// From agent descriptions
|
|
77
|
+
const agents = session.agents || [];
|
|
78
|
+
if (agents.length > 0) {
|
|
79
|
+
const uniqueDescs = [...new Set(agents.map(a => a.description))].slice(0, 5);
|
|
80
|
+
hints.push(uniqueDescs.join(', '));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// From skills used
|
|
84
|
+
const skills = (session.recentEvents || []).filter(e => e.type === 'skill').map(e => e.skill);
|
|
85
|
+
if (skills.length > 0) {
|
|
86
|
+
hints.push('Skills: ' + [...new Set(skills)].join(', '));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build a simple summary: take the most informative hint
|
|
90
|
+
const combined = hints.join(' | ').slice(0, 500);
|
|
91
|
+
return combined || session.title || 'Unnamed session';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function isProcessRunning(pid) {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get sessions from session metadata files */
|
|
106
|
+
function getSessionMetadata() {
|
|
107
|
+
const map = new Map();
|
|
108
|
+
try {
|
|
109
|
+
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
|
|
110
|
+
for (const f of files) {
|
|
111
|
+
try {
|
|
112
|
+
const data = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
|
113
|
+
if (data.sessionId) map.set(data.sessionId, data);
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
return map;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Discover ALL conversation JSONL files across all projects */
|
|
121
|
+
function discoverAllConversations() {
|
|
122
|
+
const conversations = [];
|
|
123
|
+
try {
|
|
124
|
+
const dirs = fs.readdirSync(PROJECTS_DIR);
|
|
125
|
+
for (const dir of dirs) {
|
|
126
|
+
const dirPath = path.join(PROJECTS_DIR, dir);
|
|
127
|
+
try {
|
|
128
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
129
|
+
for (const f of files) {
|
|
130
|
+
const sessionId = f.replace('.jsonl', '');
|
|
131
|
+
const fullPath = path.join(dirPath, f);
|
|
132
|
+
const stat = fs.statSync(fullPath);
|
|
133
|
+
conversations.push({
|
|
134
|
+
sessionId,
|
|
135
|
+
project: dir,
|
|
136
|
+
path: fullPath,
|
|
137
|
+
size: stat.size,
|
|
138
|
+
lastModified: stat.mtimeMs
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
return conversations.sort((a, b) => b.lastModified - a.lastModified);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Full parse of a JSONL conversation file — extracts agents, todos, events */
|
|
148
|
+
function parseConversation(jsonlPath, recentLineCount = 200) {
|
|
149
|
+
try {
|
|
150
|
+
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
151
|
+
const lines = content.trim().split('\n');
|
|
152
|
+
|
|
153
|
+
let title = null;
|
|
154
|
+
let totalUserMessages = 0;
|
|
155
|
+
let totalAssistantMessages = 0;
|
|
156
|
+
let totalToolCalls = 0;
|
|
157
|
+
let lastActivity = null;
|
|
158
|
+
let firstTimestamp = null;
|
|
159
|
+
|
|
160
|
+
// Agent workflow tracking
|
|
161
|
+
const agents = []; // { id, description, type, status, spawnedAt, parentId }
|
|
162
|
+
const todos = []; // latest todo state
|
|
163
|
+
const cronJobs = []; // { id, cron, prompt, recurring, createdAt }
|
|
164
|
+
const timeline = []; // ordered events for workflow view
|
|
165
|
+
const messagePollIds = new Set(); // track tool calls that poll for external user messages
|
|
166
|
+
let inCronContext = false; // true after a cron enqueue, false after first tool call is tracked
|
|
167
|
+
let cronChannel = 'remote'; // detected channel from cron prompt
|
|
168
|
+
const MESSAGE_POLL_KEYWORDS = /message|poll|check|fetch|command|消息|指令|轮询/i;
|
|
169
|
+
const EMPTY_RESULT_PATTERNS = [
|
|
170
|
+
'(Bash completed with no output)',
|
|
171
|
+
'No new', 'no new', 'null', '""', "''", '{}', '[]'
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// Scan ALL lines for stats, agents, todos
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
try {
|
|
177
|
+
const obj = JSON.parse(line);
|
|
178
|
+
if (obj.type === 'ai-title') title = obj.aiTitle;
|
|
179
|
+
|
|
180
|
+
if (obj.timestamp && !firstTimestamp) firstTimestamp = obj.timestamp;
|
|
181
|
+
if (obj.timestamp) lastActivity = obj.timestamp;
|
|
182
|
+
|
|
183
|
+
if (obj.type === 'user') {
|
|
184
|
+
totalUserMessages++;
|
|
185
|
+
const contentArr = obj.message?.content || [];
|
|
186
|
+
const textParts = contentArr
|
|
187
|
+
.filter(c => c.type === 'text')
|
|
188
|
+
.map(c => c.text)
|
|
189
|
+
.join(' ')
|
|
190
|
+
.replace(/<ide_[^>]*>[\s\S]*?<\/ide_[^>]*>/g, '')
|
|
191
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
192
|
+
.trim();
|
|
193
|
+
if (textParts) {
|
|
194
|
+
timeline.push({
|
|
195
|
+
type: 'user',
|
|
196
|
+
timestamp: obj.timestamp,
|
|
197
|
+
text: textParts.slice(0, 400),
|
|
198
|
+
uuid: obj.uuid
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check tool_result items for remote user input (Teams/Slack/WeChat/Feishu/MCP etc.)
|
|
203
|
+
for (const c of contentArr) {
|
|
204
|
+
if (c.type === 'tool_result' && messagePollIds.has(c.tool_use_id)) {
|
|
205
|
+
const text = typeof c.content === 'string' ? c.content :
|
|
206
|
+
Array.isArray(c.content) ? c.content.map(p => p.text || '').join(' ') : '';
|
|
207
|
+
const trimmed = text.trim();
|
|
208
|
+
// Skip empty/trivial results
|
|
209
|
+
const isEmpty = trimmed.length <= 5 ||
|
|
210
|
+
EMPTY_RESULT_PATTERNS.some(p => trimmed === p || trimmed.startsWith(p));
|
|
211
|
+
// Also skip JSON API responses (outbound message confirmations)
|
|
212
|
+
const isApiResponse = (trimmed.startsWith('{') && trimmed.includes('"ok"')) ||
|
|
213
|
+
(trimmed.startsWith('{') && trimmed.includes('"status"'));
|
|
214
|
+
if (!isEmpty && !isApiResponse) {
|
|
215
|
+
timeline.push({
|
|
216
|
+
type: 'remote-input',
|
|
217
|
+
channel: cronChannel,
|
|
218
|
+
timestamp: obj.timestamp,
|
|
219
|
+
text: text.slice(0, 500)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// Clear cron context after processing result
|
|
223
|
+
messagePollIds.delete(c.tool_use_id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Track queue-operation (cron triggers) — these contain the actual cron prompt
|
|
229
|
+
if (obj.type === 'queue-operation' && obj.operation === 'enqueue' && obj.content) {
|
|
230
|
+
// Detect if this cron is polling for messages
|
|
231
|
+
if (MESSAGE_POLL_KEYWORDS.test(obj.content)) {
|
|
232
|
+
inCronContext = true;
|
|
233
|
+
// Detect channel
|
|
234
|
+
const prompt = obj.content.toLowerCase();
|
|
235
|
+
if (prompt.includes('feishu') || prompt.includes('飞书') || prompt.includes('lark')) cronChannel = 'feishu';
|
|
236
|
+
else if (prompt.includes('teams')) cronChannel = 'teams';
|
|
237
|
+
else if (prompt.includes('wechat') || prompt.includes('微信')) cronChannel = 'wechat';
|
|
238
|
+
else if (prompt.includes('slack')) cronChannel = 'slack';
|
|
239
|
+
else cronChannel = 'remote';
|
|
240
|
+
}
|
|
241
|
+
timeline.push({
|
|
242
|
+
type: 'cron-trigger',
|
|
243
|
+
timestamp: obj.timestamp,
|
|
244
|
+
text: obj.content.slice(0, 400)
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (obj.type === 'assistant') {
|
|
249
|
+
totalAssistantMessages++;
|
|
250
|
+
const msg = obj.message || {};
|
|
251
|
+
const contentArr = msg.content || [];
|
|
252
|
+
|
|
253
|
+
const toolUses = [];
|
|
254
|
+
let textContent = '';
|
|
255
|
+
|
|
256
|
+
for (const c of contentArr) {
|
|
257
|
+
if (c.type === 'tool_use') {
|
|
258
|
+
totalToolCalls++;
|
|
259
|
+
const toolInfo = {
|
|
260
|
+
tool: c.name,
|
|
261
|
+
id: c.id,
|
|
262
|
+
input: summarizeInput(c.name, c.input)
|
|
263
|
+
};
|
|
264
|
+
toolUses.push(toolInfo);
|
|
265
|
+
|
|
266
|
+
// Track the FIRST Bash/MCP tool call within a message-polling cron context
|
|
267
|
+
// Only the first call is the actual poll; subsequent calls are execution of the result
|
|
268
|
+
if (inCronContext) {
|
|
269
|
+
if (c.name === 'Bash' || c.name.startsWith('mcp__')) {
|
|
270
|
+
messagePollIds.add(c.id);
|
|
271
|
+
inCronContext = false; // only track the first tool call
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Track Agent spawns
|
|
276
|
+
if (c.name === 'Agent') {
|
|
277
|
+
agents.push({
|
|
278
|
+
id: c.id,
|
|
279
|
+
description: c.input?.description || 'Sub-agent',
|
|
280
|
+
subagentType: c.input?.subagent_type || 'general-purpose',
|
|
281
|
+
runInBackground: c.input?.run_in_background || false,
|
|
282
|
+
spawnedAt: obj.timestamp,
|
|
283
|
+
status: 'running' // will be updated when result comes
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Track TodoWrite
|
|
288
|
+
if (c.name === 'TodoWrite' && c.input?.todos) {
|
|
289
|
+
todos.length = 0;
|
|
290
|
+
todos.push(...c.input.todos);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Track Skill invocations
|
|
294
|
+
if (c.name === 'Skill') {
|
|
295
|
+
timeline.push({
|
|
296
|
+
type: 'skill',
|
|
297
|
+
timestamp: obj.timestamp,
|
|
298
|
+
skill: c.input?.skill || '?',
|
|
299
|
+
args: c.input?.args || ''
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Track CronCreate
|
|
304
|
+
if (c.name === 'CronCreate') {
|
|
305
|
+
cronJobs.push({
|
|
306
|
+
toolUseId: c.id,
|
|
307
|
+
cron: c.input?.cron || '',
|
|
308
|
+
prompt: c.input?.prompt || '',
|
|
309
|
+
recurring: c.input?.recurring !== false,
|
|
310
|
+
durable: c.input?.durable || false,
|
|
311
|
+
createdAt: obj.timestamp,
|
|
312
|
+
status: 'active'
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Track CronDelete — mark matching cron as deleted
|
|
317
|
+
if (c.name === 'CronDelete' && c.input?.id) {
|
|
318
|
+
const job = cronJobs.find(j => j.jobId === c.input.id);
|
|
319
|
+
if (job) job.status = 'deleted';
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (c.type === 'text') {
|
|
323
|
+
textContent += c.text;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
timeline.push({
|
|
328
|
+
type: 'assistant',
|
|
329
|
+
timestamp: obj.timestamp,
|
|
330
|
+
text: textContent.trim().slice(0, 400),
|
|
331
|
+
tools: toolUses,
|
|
332
|
+
model: msg.model,
|
|
333
|
+
stopReason: msg.stop_reason
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Track tool results for agent completion + cron job IDs
|
|
338
|
+
if (obj.type === 'tool_result' || (obj.message?.content || []).some(c => c.type === 'tool_result')) {
|
|
339
|
+
const results = obj.message?.content?.filter(c => c.type === 'tool_result') || [];
|
|
340
|
+
if (obj.type === 'tool_result' && obj.tool_use_id) {
|
|
341
|
+
const agent = agents.find(a => a.id === obj.tool_use_id);
|
|
342
|
+
if (agent) agent.status = 'completed';
|
|
343
|
+
// Capture cron job ID from result
|
|
344
|
+
const cron = cronJobs.find(j => j.toolUseId === obj.tool_use_id);
|
|
345
|
+
if (cron) {
|
|
346
|
+
const text = typeof obj.content === 'string' ? obj.content : JSON.stringify(obj.content || '');
|
|
347
|
+
const match = text.match(/([a-f0-9]{8})/);
|
|
348
|
+
if (match) cron.jobId = match[1];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
if (r.tool_use_id) {
|
|
353
|
+
const agent = agents.find(a => a.id === r.tool_use_id);
|
|
354
|
+
if (agent) agent.status = 'completed';
|
|
355
|
+
const cron = cronJobs.find(j => j.toolUseId === r.tool_use_id);
|
|
356
|
+
if (cron) {
|
|
357
|
+
const text = typeof r.content === 'string' ? r.content : JSON.stringify(r.content || '');
|
|
358
|
+
const match = text.match(/([a-f0-9]{8})/);
|
|
359
|
+
if (match) cron.jobId = match[1];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// All events are now captured from the full timeline scan above
|
|
368
|
+
|
|
369
|
+
// Use the full timeline instead of just tail-based recentEvents
|
|
370
|
+
// Keep user messages and substantive assistant messages (with text or agents)
|
|
371
|
+
const allEvents = timeline.map(evt => {
|
|
372
|
+
// Mark events with content quality for client-side filtering
|
|
373
|
+
if (evt.type === 'user') {
|
|
374
|
+
evt.hasContent = true;
|
|
375
|
+
} else if (evt.type === 'remote-input') {
|
|
376
|
+
evt.hasContent = true;
|
|
377
|
+
} else if (evt.type === 'cron-trigger') {
|
|
378
|
+
evt.hasContent = false; // cron prompts are repetitive, hide by default
|
|
379
|
+
evt.isCron = true;
|
|
380
|
+
} else if (evt.type === 'assistant') {
|
|
381
|
+
const hasText = evt.text && evt.text.trim().length > 10;
|
|
382
|
+
const hasAgent = (evt.tools || []).some(t => t.tool === 'Agent');
|
|
383
|
+
const hasSkill = (evt.tools || []).some(t => t.tool === 'Skill');
|
|
384
|
+
evt.hasContent = hasText || hasAgent || hasSkill;
|
|
385
|
+
evt.isToolOnly = !hasText && (evt.tools || []).length > 0;
|
|
386
|
+
evt.hasAgent = hasAgent;
|
|
387
|
+
} else if (evt.type === 'skill') {
|
|
388
|
+
evt.hasContent = true;
|
|
389
|
+
}
|
|
390
|
+
return evt;
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
title,
|
|
395
|
+
totalUserMessages,
|
|
396
|
+
totalAssistantMessages,
|
|
397
|
+
totalToolCalls,
|
|
398
|
+
firstTimestamp,
|
|
399
|
+
lastActivity,
|
|
400
|
+
agents,
|
|
401
|
+
todos,
|
|
402
|
+
cronJobs: cronJobs.filter(j => j.status === 'active'),
|
|
403
|
+
recentEvents: allEvents,
|
|
404
|
+
timelineLength: timeline.length
|
|
405
|
+
};
|
|
406
|
+
} catch {
|
|
407
|
+
return {
|
|
408
|
+
title: null, totalUserMessages: 0, totalAssistantMessages: 0,
|
|
409
|
+
totalToolCalls: 0, agents: [], todos: [], cronJobs: [], recentEvents: [], timelineLength: 0
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function summarizeInput(toolName, input) {
|
|
415
|
+
if (!input) return '';
|
|
416
|
+
switch (toolName) {
|
|
417
|
+
case 'Bash': return input.command?.slice(0, 150) || '';
|
|
418
|
+
case 'Read': return input.file_path || '';
|
|
419
|
+
case 'Write': return input.file_path || '';
|
|
420
|
+
case 'Edit': return input.file_path || '';
|
|
421
|
+
case 'Grep': return `${input.pattern || ''} in ${input.path || '.'}`;
|
|
422
|
+
case 'Glob': return input.pattern || '';
|
|
423
|
+
case 'Agent': return `[${input.subagent_type || 'general'}] ${input.description || ''}`;
|
|
424
|
+
case 'WebFetch': return input.url?.slice(0, 100) || '';
|
|
425
|
+
case 'WebSearch': return input.query || '';
|
|
426
|
+
case 'TodoWrite': return `${(input.todos || []).length} items`;
|
|
427
|
+
case 'Skill': return input.skill || '';
|
|
428
|
+
case 'CronCreate': return input.prompt?.slice(0, 80) || '';
|
|
429
|
+
default: return JSON.stringify(input).slice(0, 120);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Build the full session list: metadata sessions + orphan JSONL conversations */
|
|
434
|
+
function buildSessionList(includeHistorical = true) {
|
|
435
|
+
const metaMap = getSessionMetadata();
|
|
436
|
+
const conversations = discoverAllConversations();
|
|
437
|
+
|
|
438
|
+
const sessionsById = new Map();
|
|
439
|
+
|
|
440
|
+
// First, add all sessions from metadata
|
|
441
|
+
for (const [sid, meta] of metaMap) {
|
|
442
|
+
sessionsById.set(sid, {
|
|
443
|
+
sessionId: sid,
|
|
444
|
+
pid: meta.pid,
|
|
445
|
+
alive: isProcessRunning(meta.pid),
|
|
446
|
+
cwd: meta.cwd,
|
|
447
|
+
entrypoint: meta.entrypoint || meta.kind || 'unknown',
|
|
448
|
+
startedAt: meta.startedAt,
|
|
449
|
+
hasMetadata: true
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Then, for each conversation JSONL, attach or create session entry
|
|
454
|
+
const results = [];
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
|
|
457
|
+
for (const conv of conversations) {
|
|
458
|
+
if (seen.has(conv.sessionId)) continue;
|
|
459
|
+
seen.add(conv.sessionId);
|
|
460
|
+
|
|
461
|
+
const meta = sessionsById.get(conv.sessionId);
|
|
462
|
+
const parsed = parseConversation(conv.path);
|
|
463
|
+
|
|
464
|
+
const session = {
|
|
465
|
+
sessionId: conv.sessionId,
|
|
466
|
+
pid: meta?.pid || null,
|
|
467
|
+
alive: meta?.alive || false,
|
|
468
|
+
cwd: meta?.cwd || null,
|
|
469
|
+
entrypoint: meta?.entrypoint || 'unknown',
|
|
470
|
+
startedAt: meta?.startedAt || (parsed.firstTimestamp ? new Date(parsed.firstTimestamp).getTime() : conv.lastModified),
|
|
471
|
+
project: conv.project,
|
|
472
|
+
fileSizeKB: Math.round(conv.size / 1024),
|
|
473
|
+
hasMetadata: !!meta,
|
|
474
|
+
title: parsed.title || null,
|
|
475
|
+
totalUserMessages: parsed.totalUserMessages,
|
|
476
|
+
totalAssistantMessages: parsed.totalAssistantMessages,
|
|
477
|
+
totalToolCalls: parsed.totalToolCalls,
|
|
478
|
+
lastActivity: parsed.lastActivity,
|
|
479
|
+
agents: parsed.agents,
|
|
480
|
+
todos: parsed.todos,
|
|
481
|
+
cronJobs: parsed.cronJobs,
|
|
482
|
+
recentEvents: parsed.recentEvents,
|
|
483
|
+
timelineLength: parsed.timelineLength
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
results.push(session);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Add metadata-only sessions (no JSONL found)
|
|
490
|
+
for (const [sid, meta] of metaMap) {
|
|
491
|
+
if (!seen.has(sid)) {
|
|
492
|
+
results.push({
|
|
493
|
+
sessionId: sid,
|
|
494
|
+
pid: meta.pid,
|
|
495
|
+
alive: isProcessRunning(meta.pid),
|
|
496
|
+
cwd: meta.cwd,
|
|
497
|
+
entrypoint: meta.entrypoint || meta.kind || 'unknown',
|
|
498
|
+
startedAt: meta.startedAt,
|
|
499
|
+
project: null,
|
|
500
|
+
hasMetadata: true,
|
|
501
|
+
title: null,
|
|
502
|
+
totalUserMessages: 0, totalAssistantMessages: 0, totalToolCalls: 0,
|
|
503
|
+
agents: [], todos: [], recentEvents: [], timelineLength: 0
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Sort: alive first, then by last activity desc
|
|
509
|
+
results.sort((a, b) => {
|
|
510
|
+
if (a.alive !== b.alive) return b.alive - a.alive;
|
|
511
|
+
const aTime = a.lastActivity ? new Date(a.lastActivity).getTime() : (a.startedAt || 0);
|
|
512
|
+
const bTime = b.lastActivity ? new Date(b.lastActivity).getTime() : (b.startedAt || 0);
|
|
513
|
+
return bTime - aTime;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Merge custom session names + generate suggestions + attach AI summaries
|
|
517
|
+
const names = loadSessionNames();
|
|
518
|
+
for (const s of results) {
|
|
519
|
+
s.customName = names[s.sessionId] || null;
|
|
520
|
+
// Also use AI summary brief name as a display name fallback
|
|
521
|
+
const cachedSummary = aiSummaryCache.get(s.sessionId);
|
|
522
|
+
if (cachedSummary) {
|
|
523
|
+
s.aiSummary = cachedSummary;
|
|
524
|
+
}
|
|
525
|
+
s.displayName = s.customName || cachedSummary?.briefName || s.title || s.sessionId.slice(0, 8);
|
|
526
|
+
s.suggestedName = suggestSessionName(s);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return includeHistorical ? results : results.filter(s => s.alive);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Express + WebSocket ──────────────────────────────────
|
|
533
|
+
|
|
534
|
+
const app = express();
|
|
535
|
+
const server = http.createServer(app);
|
|
536
|
+
const wss = new WebSocketServer({ server });
|
|
537
|
+
|
|
538
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
539
|
+
app.use(express.json());
|
|
540
|
+
|
|
541
|
+
// Session rename API
|
|
542
|
+
app.put('/api/sessions/:id/name', (req, res) => {
|
|
543
|
+
const { name } = req.body;
|
|
544
|
+
if (typeof name !== 'string') return res.status(400).json({ error: 'name must be a string' });
|
|
545
|
+
const names = loadSessionNames();
|
|
546
|
+
if (name.trim()) {
|
|
547
|
+
names[req.params.id] = name.trim();
|
|
548
|
+
} else {
|
|
549
|
+
delete names[req.params.id]; // empty = remove custom name
|
|
550
|
+
}
|
|
551
|
+
saveSessionNames(names);
|
|
552
|
+
res.json({ ok: true, name: names[req.params.id] || null });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Session title rename — modifies the ai-title in the JSONL file (only for completed sessions)
|
|
556
|
+
app.put('/api/sessions/:id/title', (req, res) => {
|
|
557
|
+
const { title } = req.body;
|
|
558
|
+
if (typeof title !== 'string') return res.status(400).json({ error: 'title must be a string' });
|
|
559
|
+
|
|
560
|
+
const sessions = buildSessionList(true);
|
|
561
|
+
const session = sessions.find(s => s.sessionId === req.params.id);
|
|
562
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
563
|
+
if (session.alive) return res.status(400).json({ error: 'Cannot modify title of active session — JSONL is still being written' });
|
|
564
|
+
|
|
565
|
+
// Find the JSONL file for this session
|
|
566
|
+
const conversations = discoverAllConversations();
|
|
567
|
+
const conv = conversations.find(c => c.sessionId === req.params.id);
|
|
568
|
+
if (!conv) return res.status(404).json({ error: 'JSONL file not found for session' });
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const content = fs.readFileSync(conv.path, 'utf8');
|
|
572
|
+
const lines = content.split('\n');
|
|
573
|
+
const newTitle = title.trim();
|
|
574
|
+
let replaced = false;
|
|
575
|
+
|
|
576
|
+
// Find and replace existing ai-title line
|
|
577
|
+
const updatedLines = lines.map(line => {
|
|
578
|
+
try {
|
|
579
|
+
const obj = JSON.parse(line);
|
|
580
|
+
if (obj.type === 'ai-title') {
|
|
581
|
+
replaced = true;
|
|
582
|
+
return JSON.stringify({ ...obj, aiTitle: newTitle });
|
|
583
|
+
}
|
|
584
|
+
} catch {}
|
|
585
|
+
return line;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// If no ai-title line existed, prepend one
|
|
589
|
+
if (!replaced && newTitle) {
|
|
590
|
+
updatedLines.unshift(JSON.stringify({ type: 'ai-title', sessionId: req.params.id, aiTitle: newTitle }));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
fs.writeFileSync(conv.path, updatedLines.join('\n'), 'utf8');
|
|
594
|
+
res.json({ ok: true, title: newTitle });
|
|
595
|
+
} catch (e) {
|
|
596
|
+
res.status(500).json({ error: e.message });
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
app.get('/api/sessions/:id/suggest-name', (req, res) => {
|
|
601
|
+
const sessions = buildSessionList(true);
|
|
602
|
+
const session = sessions.find(s => s.sessionId === req.params.id);
|
|
603
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
604
|
+
res.json({ suggestion: session.suggestedName });
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Session close (kill) API — terminates the Claude Code process
|
|
608
|
+
app.post('/api/sessions/:id/close', (req, res) => {
|
|
609
|
+
const sessions = buildSessionList(true);
|
|
610
|
+
const session = sessions.find(s => s.sessionId === req.params.id);
|
|
611
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
612
|
+
if (!session.alive) return res.status(400).json({ error: 'Session is not active' });
|
|
613
|
+
if (!session.pid) return res.status(400).json({ error: 'No PID found for session' });
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
process.kill(session.pid, 'SIGTERM');
|
|
617
|
+
res.json({ ok: true, pid: session.pid, message: `Sent SIGTERM to PID ${session.pid}` });
|
|
618
|
+
} catch (e) {
|
|
619
|
+
if (e.code === 'ESRCH') {
|
|
620
|
+
res.json({ ok: true, pid: session.pid, message: 'Process already terminated' });
|
|
621
|
+
} else {
|
|
622
|
+
res.status(500).json({ error: e.message });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// REST API
|
|
628
|
+
app.get('/api/sessions', (req, res) => {
|
|
629
|
+
const includeHistorical = req.query.historical !== 'false';
|
|
630
|
+
const limit = parseInt(req.query.limit) || 0;
|
|
631
|
+
let sessions = buildSessionList(includeHistorical);
|
|
632
|
+
if (limit > 0) sessions = sessions.slice(0, limit);
|
|
633
|
+
res.json(sessions);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
app.get('/api/sessions/:id', (req, res) => {
|
|
637
|
+
const sessions = buildSessionList(true);
|
|
638
|
+
const session = sessions.find(s => s.sessionId === req.params.id);
|
|
639
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
640
|
+
// Attach cached AI summary if available
|
|
641
|
+
const cached = aiSummaryCache.get(req.params.id);
|
|
642
|
+
if (cached) session.aiSummary = cached;
|
|
643
|
+
res.json(session);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
app.get('/api/stats', (req, res) => {
|
|
647
|
+
const sessions = buildSessionList(true);
|
|
648
|
+
res.json({
|
|
649
|
+
totalSessions: sessions.length,
|
|
650
|
+
activeSessions: sessions.filter(s => s.alive).length,
|
|
651
|
+
totalMessages: sessions.reduce((s, x) => s + x.totalUserMessages + x.totalAssistantMessages, 0),
|
|
652
|
+
totalToolCalls: sessions.reduce((s, x) => s + x.totalToolCalls, 0),
|
|
653
|
+
totalAgentSpawns: sessions.reduce((s, x) => s + (x.agents?.length || 0), 0),
|
|
654
|
+
totalActiveCronJobs: sessions.reduce((s, x) => s + (x.cronJobs?.length || 0), 0),
|
|
655
|
+
projects: [...new Set(sessions.map(s => s.project).filter(Boolean))]
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
app.get('/api/cron-jobs', (req, res) => {
|
|
660
|
+
const sessions = buildSessionList(true);
|
|
661
|
+
const allCrons = [];
|
|
662
|
+
for (const s of sessions) {
|
|
663
|
+
for (const cj of (s.cronJobs || [])) {
|
|
664
|
+
allCrons.push({
|
|
665
|
+
...cj,
|
|
666
|
+
sessionId: s.sessionId,
|
|
667
|
+
sessionTitle: s.title,
|
|
668
|
+
sessionAlive: s.alive,
|
|
669
|
+
project: s.project
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
res.json(allCrons);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ── Settings & Memory APIs ─────────────────────────────
|
|
677
|
+
|
|
678
|
+
const GLOBAL_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
|
|
679
|
+
const GLOBAL_CLAUDE_MD = path.join(CLAUDE_DIR, 'CLAUDE.md');
|
|
680
|
+
const GLOBAL_CLAUDE_LOCAL_MD = path.join(CLAUDE_DIR, 'CLAUDE.local.md');
|
|
681
|
+
|
|
682
|
+
/** Read a JSON file safely */
|
|
683
|
+
function readJsonFile(filePath) {
|
|
684
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Read a text file safely */
|
|
688
|
+
function readTextFile(filePath) {
|
|
689
|
+
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Parse YAML-like frontmatter from a markdown file's content.
|
|
693
|
+
* Returns { meta: { key: value, ... }, body: string } */
|
|
694
|
+
function parseFrontmatter(content) {
|
|
695
|
+
if (!content) return { meta: {}, body: '' };
|
|
696
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
697
|
+
if (!fmMatch) return { meta: {}, body: content.trim() };
|
|
698
|
+
const meta = {};
|
|
699
|
+
for (const line of fmMatch[1].split('\n')) {
|
|
700
|
+
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
701
|
+
if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
702
|
+
}
|
|
703
|
+
return { meta, body: fmMatch[2].trim() };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Convert project slug to display name (last segment after --) */
|
|
707
|
+
function displayNameFromSlug(slug) {
|
|
708
|
+
return slug.split('--').pop();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Read Copilot custom agents from ~/.copilot/agents/ */
|
|
712
|
+
function readCopilotAgents() {
|
|
713
|
+
const agents = [];
|
|
714
|
+
try {
|
|
715
|
+
const files = fs.readdirSync(COPILOT_AGENTS_DIR).filter(f => f.endsWith('.md'));
|
|
716
|
+
for (const f of files) {
|
|
717
|
+
const content = readTextFile(path.join(COPILOT_AGENTS_DIR, f));
|
|
718
|
+
if (!content) continue;
|
|
719
|
+
const { meta } = parseFrontmatter(content);
|
|
720
|
+
agents.push({
|
|
721
|
+
name: meta.name || f.replace('.md', ''),
|
|
722
|
+
file: f,
|
|
723
|
+
description: meta.description || '',
|
|
724
|
+
path: path.join(COPILOT_AGENTS_DIR, f)
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
} catch {}
|
|
728
|
+
return agents;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Discover all project directories under ~/.claude/projects/ */
|
|
732
|
+
function discoverProjects() {
|
|
733
|
+
const projects = [];
|
|
734
|
+
try {
|
|
735
|
+
const dirs = fs.readdirSync(PROJECTS_DIR);
|
|
736
|
+
for (const dir of dirs) {
|
|
737
|
+
const dirPath = path.join(PROJECTS_DIR, dir);
|
|
738
|
+
const stat = fs.statSync(dirPath);
|
|
739
|
+
if (stat.isDirectory()) {
|
|
740
|
+
projects.push({ slug: dir, path: dirPath });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
} catch {}
|
|
744
|
+
return projects;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Mask sensitive values in env/settings */
|
|
748
|
+
function maskSensitive(obj) {
|
|
749
|
+
if (!obj) return obj;
|
|
750
|
+
const masked = { ...obj };
|
|
751
|
+
const sensitiveKeys = /token|secret|key|password|auth/i;
|
|
752
|
+
for (const [k, v] of Object.entries(masked)) {
|
|
753
|
+
if (sensitiveKeys.test(k) && typeof v === 'string') {
|
|
754
|
+
masked[k] = v.slice(0, 4) + '***' + v.slice(-4);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return masked;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Mask sensitive env values in MCP server configs */
|
|
761
|
+
function maskMcpEnv(servers) {
|
|
762
|
+
const result = {};
|
|
763
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
764
|
+
result[name] = { ...config };
|
|
765
|
+
if (config.env) result[name].env = maskSensitive(config.env);
|
|
766
|
+
}
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Parse a memory .md file with YAML frontmatter into a structured object */
|
|
771
|
+
function parseMemoryFile(filePath, projectSlug) {
|
|
772
|
+
const content = readTextFile(filePath);
|
|
773
|
+
if (!content) return null;
|
|
774
|
+
const f = path.basename(filePath);
|
|
775
|
+
const displayProject = displayNameFromSlug(projectSlug);
|
|
776
|
+
|
|
777
|
+
if (f === 'MEMORY.md') {
|
|
778
|
+
return { project: projectSlug, displayProject, file: f, isIndex: true, content, path: filePath };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const { meta, body } = parseFrontmatter(content);
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
project: projectSlug, displayProject, file: f, isIndex: false,
|
|
785
|
+
name: meta.name || f.replace('.md', ''),
|
|
786
|
+
type: meta.type || 'unknown',
|
|
787
|
+
description: meta.description || '',
|
|
788
|
+
body,
|
|
789
|
+
path: filePath
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Resolve a session's cwd to find the repo-level .claude directory */
|
|
794
|
+
function findRepoClaudeDir(cwd) {
|
|
795
|
+
if (!cwd) return null;
|
|
796
|
+
let dir = path.resolve(cwd);
|
|
797
|
+
// Walk up to find .claude directory (max 5 levels)
|
|
798
|
+
for (let i = 0; i < 5; i++) {
|
|
799
|
+
const candidate = path.join(dir, '.claude');
|
|
800
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
801
|
+
return candidate;
|
|
802
|
+
}
|
|
803
|
+
const parent = path.dirname(dir);
|
|
804
|
+
if (parent === dir) break;
|
|
805
|
+
dir = parent;
|
|
806
|
+
}
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// GET /api/projects — returns all projects with their settings, memory, MCP, CLAUDE.md
|
|
811
|
+
app.get('/api/projects', (req, res) => {
|
|
812
|
+
const globalSettings = readJsonFile(GLOBAL_SETTINGS_FILE);
|
|
813
|
+
const globalClaudeMd = readTextFile(GLOBAL_CLAUDE_MD);
|
|
814
|
+
const globalClaudeLocalMd = readTextFile(GLOBAL_CLAUDE_LOCAL_MD);
|
|
815
|
+
const globalClaudeJson = readJsonFile(GLOBAL_CLAUDE_JSON);
|
|
816
|
+
const globalMcpServers = globalClaudeJson?.mcpServers || {};
|
|
817
|
+
|
|
818
|
+
if (globalSettings?.env) globalSettings.env = maskSensitive(globalSettings.env);
|
|
819
|
+
|
|
820
|
+
const projects = discoverProjects();
|
|
821
|
+
const projectList = projects.map(proj => {
|
|
822
|
+
const projSettings = readJsonFile(path.join(proj.path, 'settings.json'));
|
|
823
|
+
const memDir = path.join(proj.path, 'memory');
|
|
824
|
+
let memoryCount = 0;
|
|
825
|
+
let memories = [];
|
|
826
|
+
if (fs.existsSync(memDir)) {
|
|
827
|
+
try {
|
|
828
|
+
const files = fs.readdirSync(memDir).filter(f => f.endsWith('.md'));
|
|
829
|
+
memoryCount = files.filter(f => f !== 'MEMORY.md').length;
|
|
830
|
+
memories = files.map(f => parseMemoryFile(path.join(memDir, f), proj.slug)).filter(Boolean);
|
|
831
|
+
} catch {}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// MCP from project settings
|
|
835
|
+
const projectMcp = projSettings?.mcpServers || {};
|
|
836
|
+
|
|
837
|
+
// Project-level instructions (CLAUDE.md, copilot-instructions.md)
|
|
838
|
+
const workDir = slugToPath(proj.slug);
|
|
839
|
+
let projectClaudeMd = null, projectClaudeMdPath = null;
|
|
840
|
+
let projectCopilotInstructions = null, projectCopilotInstructionsPath = null;
|
|
841
|
+
if (workDir) {
|
|
842
|
+
// CLAUDE.md — check both repo root and .claude/ dir
|
|
843
|
+
const claudeDir = findRepoClaudeDir(workDir);
|
|
844
|
+
if (claudeDir) {
|
|
845
|
+
const candidates = [
|
|
846
|
+
path.join(claudeDir, 'CLAUDE.md'), // .claude/CLAUDE.md
|
|
847
|
+
path.join(path.dirname(claudeDir), 'CLAUDE.md') // repo-root/CLAUDE.md
|
|
848
|
+
];
|
|
849
|
+
for (const cmdPath of candidates) {
|
|
850
|
+
const content = readTextFile(cmdPath);
|
|
851
|
+
if (content) {
|
|
852
|
+
projectClaudeMd = content;
|
|
853
|
+
projectClaudeMdPath = cmdPath;
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// .github/copilot-instructions.md
|
|
859
|
+
const copilotInstPath = path.join(workDir, '.github', 'copilot-instructions.md');
|
|
860
|
+
const copilotContent = readTextFile(copilotInstPath);
|
|
861
|
+
if (copilotContent) {
|
|
862
|
+
projectCopilotInstructions = copilotContent;
|
|
863
|
+
projectCopilotInstructionsPath = copilotInstPath;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
slug: proj.slug,
|
|
869
|
+
displayName: displayNameFromSlug(proj.slug),
|
|
870
|
+
settings: projSettings,
|
|
871
|
+
memoryCount,
|
|
872
|
+
memories,
|
|
873
|
+
mcpServers: maskMcpEnv(projectMcp),
|
|
874
|
+
claudeMd: projectClaudeMd,
|
|
875
|
+
claudeMdPath: projectClaudeMdPath,
|
|
876
|
+
claudeMdLines: projectClaudeMd ? projectClaudeMd.split('\n').length : 0,
|
|
877
|
+
copilotInstructions: projectCopilotInstructions,
|
|
878
|
+
copilotInstructionsPath: projectCopilotInstructionsPath,
|
|
879
|
+
copilotInstructionsLines: projectCopilotInstructions ? projectCopilotInstructions.split('\n').length : 0
|
|
880
|
+
};
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
res.json({
|
|
884
|
+
global: {
|
|
885
|
+
settings: globalSettings,
|
|
886
|
+
settingsPath: GLOBAL_SETTINGS_FILE,
|
|
887
|
+
claudeMd: globalClaudeMd,
|
|
888
|
+
claudeMdPath: GLOBAL_CLAUDE_MD,
|
|
889
|
+
claudeMdLines: globalClaudeMd ? globalClaudeMd.split('\n').length : 0,
|
|
890
|
+
claudeLocalMd: globalClaudeLocalMd,
|
|
891
|
+
claudeLocalMdPath: GLOBAL_CLAUDE_LOCAL_MD,
|
|
892
|
+
claudeLocalMdLines: globalClaudeLocalMd ? globalClaudeLocalMd.split('\n').length : 0,
|
|
893
|
+
mcpServers: maskMcpEnv(globalMcpServers),
|
|
894
|
+
mcpServersPath: GLOBAL_CLAUDE_JSON
|
|
895
|
+
},
|
|
896
|
+
projects: projectList
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// GET /api/settings — returns global + project-specific settings
|
|
901
|
+
// Accepts ?project=slug&cwd=path to scope to a specific project
|
|
902
|
+
app.get('/api/settings', (req, res) => {
|
|
903
|
+
const projectSlug = req.query.project;
|
|
904
|
+
const sessionCwd = req.query.cwd;
|
|
905
|
+
|
|
906
|
+
const globalSettings = readJsonFile(GLOBAL_SETTINGS_FILE);
|
|
907
|
+
const globalClaudeMd = readTextFile(GLOBAL_CLAUDE_MD);
|
|
908
|
+
|
|
909
|
+
// Mask env values
|
|
910
|
+
if (globalSettings?.env) {
|
|
911
|
+
globalSettings.env = maskSensitive(globalSettings.env);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Discover project-level settings (scoped to requested project if provided)
|
|
915
|
+
const projects = discoverProjects();
|
|
916
|
+
const projectConfigs = [];
|
|
917
|
+
for (const proj of projects) {
|
|
918
|
+
if (projectSlug && proj.slug !== projectSlug) continue;
|
|
919
|
+
const localSettings = readJsonFile(path.join(proj.path, 'settings.json'));
|
|
920
|
+
projectConfigs.push({
|
|
921
|
+
slug: proj.slug,
|
|
922
|
+
displayName: displayNameFromSlug(proj.slug),
|
|
923
|
+
settings: localSettings,
|
|
924
|
+
hasMemory: fs.existsSync(path.join(proj.path, 'memory'))
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Resolve repo-level .claude from session's cwd
|
|
929
|
+
let repoSettings = null;
|
|
930
|
+
let repoClaudeMd = null;
|
|
931
|
+
let repoClaudeDir = null;
|
|
932
|
+
if (sessionCwd) {
|
|
933
|
+
repoClaudeDir = findRepoClaudeDir(sessionCwd);
|
|
934
|
+
}
|
|
935
|
+
if (repoClaudeDir) {
|
|
936
|
+
try {
|
|
937
|
+
repoSettings = readJsonFile(path.join(repoClaudeDir, 'settings.local.json'));
|
|
938
|
+
repoClaudeMd = readTextFile(path.join(repoClaudeDir, 'CLAUDE.md'));
|
|
939
|
+
} catch {}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Read MCP server configs from ~/.claude.json and project-level settings
|
|
943
|
+
const globalClaudeJson = readJsonFile(GLOBAL_CLAUDE_JSON);
|
|
944
|
+
const globalMcpServers = globalClaudeJson?.mcpServers || {};
|
|
945
|
+
|
|
946
|
+
// Check project-level MCP servers (from project settings.json under mcpServers key)
|
|
947
|
+
let projectMcpServers = {};
|
|
948
|
+
if (projectSlug) {
|
|
949
|
+
const projPath = path.join(PROJECTS_DIR, projectSlug, 'settings.json');
|
|
950
|
+
const projSettings = readJsonFile(projPath);
|
|
951
|
+
if (projSettings?.mcpServers) projectMcpServers = projSettings.mcpServers;
|
|
952
|
+
}
|
|
953
|
+
// Also check repo-level .claude.json for MCP servers
|
|
954
|
+
let repoMcpServers = {};
|
|
955
|
+
if (sessionCwd) {
|
|
956
|
+
const repoClaudeJson = path.join(sessionCwd, '.claude.json');
|
|
957
|
+
const repoData = readJsonFile(repoClaudeJson);
|
|
958
|
+
if (repoData?.mcpServers) repoMcpServers = repoData.mcpServers;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
res.json({
|
|
962
|
+
global: {
|
|
963
|
+
settings: globalSettings,
|
|
964
|
+
settingsPath: GLOBAL_SETTINGS_FILE,
|
|
965
|
+
claudeMd: globalClaudeMd,
|
|
966
|
+
claudeMdPath: GLOBAL_CLAUDE_MD,
|
|
967
|
+
claudeMdLines: globalClaudeMd ? globalClaudeMd.split('\n').length : 0,
|
|
968
|
+
mcpServers: maskMcpEnv(globalMcpServers),
|
|
969
|
+
mcpServersPath: GLOBAL_CLAUDE_JSON
|
|
970
|
+
},
|
|
971
|
+
repo: {
|
|
972
|
+
settings: repoSettings,
|
|
973
|
+
settingsPath: repoClaudeDir ? path.join(repoClaudeDir, 'settings.local.json') : null,
|
|
974
|
+
claudeMd: repoClaudeMd ? repoClaudeMd.slice(0, 2000) + (repoClaudeMd.length > 2000 ? '\n... (truncated)' : '') : null,
|
|
975
|
+
claudeMdPath: repoClaudeDir ? path.join(repoClaudeDir, 'CLAUDE.md') : null,
|
|
976
|
+
claudeMdLines: repoClaudeMd ? repoClaudeMd.split('\n').length : 0,
|
|
977
|
+
mcpServers: maskMcpEnv(repoMcpServers)
|
|
978
|
+
},
|
|
979
|
+
projects: projectConfigs,
|
|
980
|
+
projectMcpServers: maskMcpEnv(projectMcpServers)
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// GET /api/memory — returns memory, optionally scoped to a project
|
|
985
|
+
// Accepts ?project=slug to filter to a specific project
|
|
986
|
+
app.get('/api/memory', (req, res) => {
|
|
987
|
+
const projectSlug = req.query.project;
|
|
988
|
+
const projects = discoverProjects();
|
|
989
|
+
const allMemory = [];
|
|
990
|
+
|
|
991
|
+
for (const proj of projects) {
|
|
992
|
+
if (projectSlug && proj.slug !== projectSlug) continue;
|
|
993
|
+
const memDir = path.join(proj.path, 'memory');
|
|
994
|
+
if (!fs.existsSync(memDir)) continue;
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
const files = fs.readdirSync(memDir).filter(f => f.endsWith('.md'));
|
|
998
|
+
for (const f of files) {
|
|
999
|
+
const parsed = parseMemoryFile(path.join(memDir, f), proj.slug);
|
|
1000
|
+
if (parsed) allMemory.push(parsed);
|
|
1001
|
+
}
|
|
1002
|
+
} catch {}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
res.json(allMemory);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// DELETE /api/memory — delete a memory file
|
|
1009
|
+
app.delete('/api/memory', (req, res) => {
|
|
1010
|
+
const { filePath } = req.body;
|
|
1011
|
+
if (!filePath || !filePath.includes('.claude')) {
|
|
1012
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
1013
|
+
}
|
|
1014
|
+
// Safety: only allow deleting files under ~/.claude/projects/*/memory/
|
|
1015
|
+
const normalized = path.resolve(filePath);
|
|
1016
|
+
if (!normalized.includes(path.join('.claude', 'projects')) || !normalized.includes('memory')) {
|
|
1017
|
+
return res.status(403).json({ error: 'Can only delete memory files' });
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
fs.unlinkSync(normalized);
|
|
1021
|
+
res.json({ ok: true });
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
res.status(500).json({ error: e.message });
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
// PUT /api/memory — update a memory file's content
|
|
1028
|
+
app.put('/api/memory', (req, res) => {
|
|
1029
|
+
const { filePath, content } = req.body;
|
|
1030
|
+
if (!filePath || !filePath.includes('.claude') || typeof content !== 'string') {
|
|
1031
|
+
return res.status(400).json({ error: 'Invalid request' });
|
|
1032
|
+
}
|
|
1033
|
+
const normalized = path.resolve(filePath);
|
|
1034
|
+
if (!normalized.includes(path.join('.claude', 'projects')) || !normalized.includes('memory')) {
|
|
1035
|
+
return res.status(403).json({ error: 'Can only edit memory files' });
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
fs.writeFileSync(normalized, content, 'utf8');
|
|
1039
|
+
res.json({ ok: true });
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
res.status(500).json({ error: e.message });
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// PUT /api/settings/global — update global CLAUDE.md
|
|
1046
|
+
app.put('/api/settings/global-claude-md', (req, res) => {
|
|
1047
|
+
const { content } = req.body;
|
|
1048
|
+
if (typeof content !== 'string') return res.status(400).json({ error: 'content required' });
|
|
1049
|
+
try {
|
|
1050
|
+
fs.writeFileSync(GLOBAL_CLAUDE_MD, content, 'utf8');
|
|
1051
|
+
res.json({ ok: true });
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
res.status(500).json({ error: e.message });
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// AI Summary via claude -p
|
|
1058
|
+
// ── AI Summary Persistence ─────────────────────────────
|
|
1059
|
+
function loadAISummaries() {
|
|
1060
|
+
try {
|
|
1061
|
+
return JSON.parse(fs.readFileSync(AI_SUMMARIES_FILE, 'utf8'));
|
|
1062
|
+
} catch {
|
|
1063
|
+
return {};
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function saveAISummary(sessionId, data) {
|
|
1068
|
+
try {
|
|
1069
|
+
if (!fs.existsSync(AI_SUMMARIES_DIR)) fs.mkdirSync(AI_SUMMARIES_DIR, { recursive: true });
|
|
1070
|
+
const all = loadAISummaries();
|
|
1071
|
+
all[sessionId] = data;
|
|
1072
|
+
fs.writeFileSync(AI_SUMMARIES_FILE, JSON.stringify(all, null, 2), 'utf8');
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
console.error('[AI Summary] Failed to save:', e.message);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const aiSummaryCache = new Map(); // in-memory hot cache
|
|
1079
|
+
// Pre-load from disk on startup
|
|
1080
|
+
(() => {
|
|
1081
|
+
const saved = loadAISummaries();
|
|
1082
|
+
for (const [id, data] of Object.entries(saved)) aiSummaryCache.set(id, data);
|
|
1083
|
+
console.log(`[AI Summary] Loaded ${Object.keys(saved).length} cached summaries from disk`);
|
|
1084
|
+
})();
|
|
1085
|
+
|
|
1086
|
+
app.post('/api/sessions/:id/ai-summary', async (req, res) => {
|
|
1087
|
+
const sessions = buildSessionList(true);
|
|
1088
|
+
const session = sessions.find(s => s.sessionId === req.params.id);
|
|
1089
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1090
|
+
|
|
1091
|
+
// Check cache (valid for 10 min)
|
|
1092
|
+
const cached = aiSummaryCache.get(req.params.id);
|
|
1093
|
+
if (cached && (Date.now() - cached.generatedAt < AI_SUMMARY_CACHE_TTL_MS) && !req.body.force) {
|
|
1094
|
+
return res.json(cached);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Build context from session events
|
|
1098
|
+
const events = session.recentEvents || [];
|
|
1099
|
+
const userMsgs = events
|
|
1100
|
+
.filter(e => e.type === 'user' || e.type === 'remote-input')
|
|
1101
|
+
.map(e => e.text || '')
|
|
1102
|
+
.filter(t => t.length > 5);
|
|
1103
|
+
const assistantMsgs = events
|
|
1104
|
+
.filter(e => e.type === 'assistant' && e.text && e.text.length > 10)
|
|
1105
|
+
.map(e => e.text);
|
|
1106
|
+
const todos = (session.todos || []).map(t => `[${t.status}] ${t.content}`);
|
|
1107
|
+
const agents = (session.agents || []).map(a => `${a.subagentType}: ${a.description} (${a.status})`);
|
|
1108
|
+
|
|
1109
|
+
// Take a representative sample to fit in prompt
|
|
1110
|
+
const sampleUser = userMsgs.slice(0, 15).join('\n---\n');
|
|
1111
|
+
const sampleAssistant = assistantMsgs.slice(0, 10).join('\n---\n');
|
|
1112
|
+
const todoList = todos.join('\n');
|
|
1113
|
+
const agentList = agents.slice(0, 10).join('\n');
|
|
1114
|
+
|
|
1115
|
+
const contextText = [
|
|
1116
|
+
`Session: ${session.title || session.sessionId}`,
|
|
1117
|
+
`Project: ${session.project || 'unknown'}`,
|
|
1118
|
+
`Messages: ${session.totalUserMessages} user, ${session.totalAssistantMessages} assistant`,
|
|
1119
|
+
`Tool calls: ${session.totalToolCalls}`,
|
|
1120
|
+
`Agents: ${(session.agents || []).length}`,
|
|
1121
|
+
'',
|
|
1122
|
+
'=== User Messages (sample) ===',
|
|
1123
|
+
sampleUser,
|
|
1124
|
+
'',
|
|
1125
|
+
'=== Assistant Responses (sample) ===',
|
|
1126
|
+
sampleAssistant,
|
|
1127
|
+
'',
|
|
1128
|
+
todos.length > 0 ? '=== Tasks ===\n' + todoList : '',
|
|
1129
|
+
agents.length > 0 ? '=== Sub-Agents ===\n' + agentList : ''
|
|
1130
|
+
].filter(Boolean).join('\n');
|
|
1131
|
+
|
|
1132
|
+
const prompt = `You are analyzing a Claude Code session transcript. Provide THREE sections in your response:
|
|
1133
|
+
|
|
1134
|
+
## Brief Name
|
|
1135
|
+
A short name (under 60 characters) for this session that captures its main purpose. Examples: "Hypernet migration planning", "Code review skill refactor", "Dashboard AI summary feature". No quotes, no period.
|
|
1136
|
+
|
|
1137
|
+
## Summary
|
|
1138
|
+
A concise summary (3-5 sentences) of what this session accomplished. Focus on the user's goals and what was delivered.
|
|
1139
|
+
|
|
1140
|
+
## Lessons Learned
|
|
1141
|
+
List 2-5 key lessons or insights from this session that would be useful for future work. Focus on:
|
|
1142
|
+
- Non-obvious decisions or approaches that worked well
|
|
1143
|
+
- Problems encountered and how they were solved
|
|
1144
|
+
- Patterns or techniques worth reusing
|
|
1145
|
+
|
|
1146
|
+
Keep the total response under 400 words. Use plain text, no markdown headers beyond the three section headers above. Write in the same language as the user messages (if Chinese, respond in Chinese).
|
|
1147
|
+
|
|
1148
|
+
=== SESSION DATA ===
|
|
1149
|
+
${contextText}`;
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
const child = spawn('claude', ['-p', '--max-turns', '1'], {
|
|
1153
|
+
shell: true,
|
|
1154
|
+
env: { ...process.env },
|
|
1155
|
+
cwd: os.homedir(),
|
|
1156
|
+
timeout: 120000
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
let stdout = '';
|
|
1160
|
+
let stderr = '';
|
|
1161
|
+
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
1162
|
+
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
1163
|
+
|
|
1164
|
+
child.on('close', (code) => {
|
|
1165
|
+
if (code !== 0) {
|
|
1166
|
+
console.error('[AI Summary] claude -p exited with code', code, stderr);
|
|
1167
|
+
return res.status(500).json({ error: 'claude -p failed (exit ' + code + '): ' + stderr.slice(0, 200) });
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const output = stdout.trim();
|
|
1171
|
+
// Parse sections
|
|
1172
|
+
let briefName = '';
|
|
1173
|
+
let summary = output;
|
|
1174
|
+
let lessons = '';
|
|
1175
|
+
const briefMatch = output.match(/##\s*Brief\s*Name\s*\n([\s\S]*?)(?=##\s*Summary|$)/i);
|
|
1176
|
+
const summaryMatch = output.match(/##\s*Summary\s*\n([\s\S]*?)(?=##\s*Lessons|$)/i);
|
|
1177
|
+
const lessonsMatch = output.match(/##\s*Lessons?\s*Learned?([\s\S]*?)$/i);
|
|
1178
|
+
if (briefMatch) briefName = briefMatch[1].trim().replace(/^["']|["']$/g, '').slice(0, 80);
|
|
1179
|
+
if (summaryMatch) summary = summaryMatch[1].trim();
|
|
1180
|
+
if (lessonsMatch) lessons = lessonsMatch[1].trim();
|
|
1181
|
+
|
|
1182
|
+
const result = { briefName, summary, lessons, generatedAt: Date.now(), sessionId: req.params.id };
|
|
1183
|
+
aiSummaryCache.set(req.params.id, result);
|
|
1184
|
+
saveAISummary(req.params.id, result);
|
|
1185
|
+
res.json(result);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
child.on('error', (err) => {
|
|
1189
|
+
console.error('[AI Summary] spawn error:', err);
|
|
1190
|
+
res.status(500).json({ error: err.message });
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// Send prompt via stdin
|
|
1194
|
+
child.stdin.write(prompt);
|
|
1195
|
+
child.stdin.end();
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
res.status(500).json({ error: e.message });
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
// ── Claude CLI Status ─────────────────────────────────
|
|
1202
|
+
app.get('/api/claude-cli-status', (req, res) => {
|
|
1203
|
+
if (!claudeCliStatus.checkedAt || req.query.refresh) checkClaudeCli();
|
|
1204
|
+
res.json({
|
|
1205
|
+
...claudeCliStatus,
|
|
1206
|
+
installInstructions: claudeCliStatus.available ? null : {
|
|
1207
|
+
npm: 'npm install -g @anthropic-ai/claude-code',
|
|
1208
|
+
info: 'https://docs.anthropic.com/en/docs/claude-code'
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// WebSocket: push updates
|
|
1214
|
+
wss.on('connection', (ws) => {
|
|
1215
|
+
console.log('[WS] Client connected');
|
|
1216
|
+
const sendUpdate = () => {
|
|
1217
|
+
if (ws.readyState === ws.OPEN) {
|
|
1218
|
+
// Only send active sessions + recent 20 historical for live updates
|
|
1219
|
+
const active = buildSessionList(true).slice(0, WS_MAX_SESSIONS);
|
|
1220
|
+
ws.send(JSON.stringify({ type: 'sessions', data: active }));
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
sendUpdate();
|
|
1224
|
+
const interval = setInterval(sendUpdate, WS_PUSH_INTERVAL_MS);
|
|
1225
|
+
ws.on('close', () => { clearInterval(interval); console.log('[WS] Client disconnected'); });
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// ── Skills & Commands APIs ─────────────────────────────
|
|
1229
|
+
|
|
1230
|
+
const CLAUDE_BUILTIN_COMMANDS = [
|
|
1231
|
+
{ name: '/help', description: 'Get help with using Claude Code' },
|
|
1232
|
+
{ name: '/clear', description: 'Clear conversation history' },
|
|
1233
|
+
{ name: '/compact', description: 'Compact conversation to save context' },
|
|
1234
|
+
{ name: '/config', description: 'View/modify configuration' },
|
|
1235
|
+
{ name: '/cost', description: 'Show token usage and cost for this session' },
|
|
1236
|
+
{ name: '/doctor', description: 'Check Claude Code installation health' },
|
|
1237
|
+
{ name: '/init', description: 'Initialize project with CLAUDE.md' },
|
|
1238
|
+
{ name: '/login', description: 'Switch accounts or auth method' },
|
|
1239
|
+
{ name: '/logout', description: 'Sign out of current account' },
|
|
1240
|
+
{ name: '/memory', description: 'View or edit CLAUDE.md instructions' },
|
|
1241
|
+
{ name: '/model', description: 'Switch AI model' },
|
|
1242
|
+
{ name: '/permissions', description: 'View or update tool permissions' },
|
|
1243
|
+
{ name: '/review', description: 'Review a pull request' },
|
|
1244
|
+
{ name: '/status', description: 'Show current session status' },
|
|
1245
|
+
{ name: '/terminal-setup', description: 'Install shell integration (Shift+Enter)' },
|
|
1246
|
+
{ name: '/vim', description: 'Toggle vim keybindings' },
|
|
1247
|
+
{ name: '/bug', description: 'Report a bug' }
|
|
1248
|
+
];
|
|
1249
|
+
|
|
1250
|
+
const COPILOT_BUILTIN_COMMANDS = [
|
|
1251
|
+
{ name: '/explain', description: 'Explain selected code' },
|
|
1252
|
+
{ name: '/fix', description: 'Fix problems in selected code' },
|
|
1253
|
+
{ name: '/tests', description: 'Generate tests for selected code' },
|
|
1254
|
+
{ name: '/doc', description: 'Generate documentation' },
|
|
1255
|
+
{ name: '/generate', description: 'Generate code based on prompt' },
|
|
1256
|
+
{ name: '/optimize', description: 'Optimize selected code' },
|
|
1257
|
+
{ name: '/new', description: 'Scaffold a new project or file' },
|
|
1258
|
+
{ name: '/newNotebook', description: 'Create a new Jupyter notebook' },
|
|
1259
|
+
{ name: '/search', description: 'Search workspace for relevant code' },
|
|
1260
|
+
{ name: '/setupTests', description: 'Set up testing framework' },
|
|
1261
|
+
{ name: '/startDebugging', description: 'Start debugging session' },
|
|
1262
|
+
{ name: '/runCommand', description: 'Run a VS Code command' }
|
|
1263
|
+
];
|
|
1264
|
+
|
|
1265
|
+
const COPILOT_BUILTIN_PARTICIPANTS = [
|
|
1266
|
+
{ name: '@workspace', description: 'Ask about your workspace and code' },
|
|
1267
|
+
{ name: '@terminal', description: 'Ask about terminal commands and output' },
|
|
1268
|
+
{ name: '@vscode', description: 'Ask about VS Code features and settings' },
|
|
1269
|
+
{ name: '@github', description: 'Ask about GitHub repos, issues, and PRs' }
|
|
1270
|
+
];
|
|
1271
|
+
|
|
1272
|
+
/** Read skill/command .md files from a directory */
|
|
1273
|
+
function readCommandFiles(dirPath) {
|
|
1274
|
+
const commands = [];
|
|
1275
|
+
try {
|
|
1276
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
|
|
1277
|
+
for (const f of files) {
|
|
1278
|
+
const content = readTextFile(path.join(dirPath, f));
|
|
1279
|
+
if (!content) continue;
|
|
1280
|
+
const name = f.replace(/\.md$/, '').replace(/\.prompt$/, '');
|
|
1281
|
+
const { meta } = parseFrontmatter(content);
|
|
1282
|
+
let description = meta.description || '';
|
|
1283
|
+
// If no frontmatter description, use first non-empty line as description
|
|
1284
|
+
if (!description) {
|
|
1285
|
+
const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('---') && !l.startsWith('#'));
|
|
1286
|
+
if (firstLine) description = firstLine.trim().slice(0, 120);
|
|
1287
|
+
}
|
|
1288
|
+
commands.push({ name: '/' + name, file: f, description, path: path.join(dirPath, f) });
|
|
1289
|
+
}
|
|
1290
|
+
} catch {}
|
|
1291
|
+
return commands;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Convert a project slug (e.g. 'q--src-AdsAppsMT') to an actual filesystem path.
|
|
1295
|
+
* Slug format: segments joined by '--', where first segment is the drive letter (on Windows).
|
|
1296
|
+
* Within each segment, '-' may represent either a literal '-' or a path separator '/'.
|
|
1297
|
+
* We try the most likely interpretation: replace '--' with '/' for the drive separator,
|
|
1298
|
+
* then for remaining '-' characters, try replacing them with '/' and check if the path exists. */
|
|
1299
|
+
function slugToPath(slug) {
|
|
1300
|
+
// Split on '--' to get segments
|
|
1301
|
+
const parts = slug.split('--');
|
|
1302
|
+
if (parts.length < 2) return null;
|
|
1303
|
+
const drive = parts[0]; // e.g. 'q' or 'C'
|
|
1304
|
+
// Reconstruct: first try simply joining remaining parts with path.sep
|
|
1305
|
+
// parts[1..n] each represent a directory name, with internal '-' being literal
|
|
1306
|
+
const candidate = drive + ':/' + parts.slice(1).join('/');
|
|
1307
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1308
|
+
|
|
1309
|
+
// If that doesn't exist, try replacing '-' with '/' in each part
|
|
1310
|
+
// This handles cases like 'Users-lying' -> 'Users/lying'
|
|
1311
|
+
const expanded = parts.slice(1).map(p => p.replace(/-/g, '/'));
|
|
1312
|
+
const candidate2 = drive + ':/' + expanded.join('/');
|
|
1313
|
+
if (fs.existsSync(candidate2)) return candidate2;
|
|
1314
|
+
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Discover all project working directories from project slugs */
|
|
1319
|
+
function discoverProjectWorkDirs() {
|
|
1320
|
+
const dirs = [];
|
|
1321
|
+
try {
|
|
1322
|
+
const slugs = fs.readdirSync(PROJECTS_DIR).filter(s => {
|
|
1323
|
+
return fs.statSync(path.join(PROJECTS_DIR, s)).isDirectory();
|
|
1324
|
+
});
|
|
1325
|
+
for (const slug of slugs) {
|
|
1326
|
+
const candidate = slugToPath(slug);
|
|
1327
|
+
if (candidate && fs.existsSync(candidate)) {
|
|
1328
|
+
dirs.push({ slug, workDir: candidate });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
} catch {}
|
|
1332
|
+
return dirs;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// GET /api/claude-code/skills — Claude Code slash commands & skills by scope
|
|
1336
|
+
app.get('/api/claude-code/skills', (req, res) => {
|
|
1337
|
+
const globalCmdsDir = path.join(CLAUDE_DIR, 'commands');
|
|
1338
|
+
|
|
1339
|
+
// Scan all known projects for .claude/commands/
|
|
1340
|
+
const projectSkills = [];
|
|
1341
|
+
for (const { slug, workDir } of discoverProjectWorkDirs()) {
|
|
1342
|
+
const cmdsDir = path.join(workDir, '.claude', 'commands');
|
|
1343
|
+
const cmds = readCommandFiles(cmdsDir);
|
|
1344
|
+
if (cmds.length > 0) {
|
|
1345
|
+
projectSkills.push({ project: displayNameFromSlug(slug), dir: cmdsDir, commands: cmds });
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
res.json({
|
|
1350
|
+
builtin: CLAUDE_BUILTIN_COMMANDS,
|
|
1351
|
+
projects: projectSkills,
|
|
1352
|
+
global: readCommandFiles(globalCmdsDir),
|
|
1353
|
+
globalDir: globalCmdsDir
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// GET /api/copilot/skills — Copilot slash commands & prompts by scope
|
|
1358
|
+
app.get('/api/copilot/skills', (req, res) => {
|
|
1359
|
+
// Scan all known projects for .github/prompts/
|
|
1360
|
+
const projectPrompts = [];
|
|
1361
|
+
for (const { slug, workDir } of discoverProjectWorkDirs()) {
|
|
1362
|
+
const promptsDir = path.join(workDir, '.github', 'prompts');
|
|
1363
|
+
const prompts = [];
|
|
1364
|
+
try {
|
|
1365
|
+
const files = fs.readdirSync(promptsDir).filter(f => f.endsWith('.prompt.md'));
|
|
1366
|
+
for (const f of files) {
|
|
1367
|
+
const content = readTextFile(path.join(promptsDir, f));
|
|
1368
|
+
let description = '';
|
|
1369
|
+
if (content) {
|
|
1370
|
+
const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('---') && !l.startsWith('#'));
|
|
1371
|
+
if (firstLine) description = firstLine.trim().slice(0, 120);
|
|
1372
|
+
}
|
|
1373
|
+
prompts.push({ name: '#' + f.replace('.prompt.md', ''), file: f, description, path: path.join(promptsDir, f) });
|
|
1374
|
+
}
|
|
1375
|
+
} catch {}
|
|
1376
|
+
if (prompts.length > 0) {
|
|
1377
|
+
projectPrompts.push({ project: displayNameFromSlug(slug), dir: promptsDir, commands: prompts });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Custom agents from ~/.copilot/agents/
|
|
1382
|
+
const agents = readCopilotAgents().map(a => ({ ...a, name: '@' + a.name }));
|
|
1383
|
+
|
|
1384
|
+
res.json({
|
|
1385
|
+
builtinCommands: COPILOT_BUILTIN_COMMANDS,
|
|
1386
|
+
builtinParticipants: COPILOT_BUILTIN_PARTICIPANTS,
|
|
1387
|
+
projects: projectPrompts,
|
|
1388
|
+
global: agents,
|
|
1389
|
+
globalDir: COPILOT_AGENTS_DIR
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
// ── Copilot Session Support ────────────────────────────
|
|
1394
|
+
|
|
1395
|
+
/** Parse a Copilot agent-mode JSONL session file */
|
|
1396
|
+
function parseCopilotSession(jsonlPath) {
|
|
1397
|
+
try {
|
|
1398
|
+
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
1399
|
+
const lines = content.trim().split('\n');
|
|
1400
|
+
|
|
1401
|
+
let sessionId = null, model = null, startTime = null, lastActivity = null;
|
|
1402
|
+
let totalUserMessages = 0, totalAssistantMessages = 0, totalToolCalls = 0;
|
|
1403
|
+
const timeline = [];
|
|
1404
|
+
|
|
1405
|
+
for (const line of lines) {
|
|
1406
|
+
try {
|
|
1407
|
+
const obj = JSON.parse(line);
|
|
1408
|
+
if (obj.timestamp) lastActivity = obj.timestamp;
|
|
1409
|
+
|
|
1410
|
+
switch (obj.type) {
|
|
1411
|
+
case 'session.start':
|
|
1412
|
+
sessionId = obj.data?.sessionId;
|
|
1413
|
+
model = obj.data?.selectedModel;
|
|
1414
|
+
startTime = obj.data?.startTime || obj.timestamp;
|
|
1415
|
+
break;
|
|
1416
|
+
case 'session.model_change':
|
|
1417
|
+
model = obj.data?.newModel;
|
|
1418
|
+
break;
|
|
1419
|
+
case 'user.message':
|
|
1420
|
+
totalUserMessages++;
|
|
1421
|
+
timeline.push({
|
|
1422
|
+
type: 'user',
|
|
1423
|
+
timestamp: obj.timestamp,
|
|
1424
|
+
text: (obj.data?.content || '').slice(0, 400)
|
|
1425
|
+
});
|
|
1426
|
+
break;
|
|
1427
|
+
case 'assistant.message':
|
|
1428
|
+
totalAssistantMessages++;
|
|
1429
|
+
timeline.push({
|
|
1430
|
+
type: 'assistant',
|
|
1431
|
+
timestamp: obj.timestamp,
|
|
1432
|
+
text: (obj.data?.content || '').slice(0, 400),
|
|
1433
|
+
hasContent: (obj.data?.content || '').trim().length > 10
|
|
1434
|
+
});
|
|
1435
|
+
break;
|
|
1436
|
+
case 'tool.execution_start':
|
|
1437
|
+
totalToolCalls++;
|
|
1438
|
+
timeline.push({
|
|
1439
|
+
type: 'assistant',
|
|
1440
|
+
timestamp: obj.timestamp,
|
|
1441
|
+
text: '',
|
|
1442
|
+
tools: [{ tool: obj.data?.toolName || '?', input: JSON.stringify(obj.data?.arguments || {}).slice(0, 120) }],
|
|
1443
|
+
isToolOnly: true
|
|
1444
|
+
});
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
} catch {}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return {
|
|
1451
|
+
sessionId: sessionId || path.basename(jsonlPath, '.jsonl'),
|
|
1452
|
+
model,
|
|
1453
|
+
startTime,
|
|
1454
|
+
lastActivity,
|
|
1455
|
+
totalUserMessages,
|
|
1456
|
+
totalAssistantMessages,
|
|
1457
|
+
totalToolCalls,
|
|
1458
|
+
recentEvents: timeline
|
|
1459
|
+
};
|
|
1460
|
+
} catch {
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Build list of all Copilot agent-mode sessions */
|
|
1466
|
+
function buildCopilotSessionList() {
|
|
1467
|
+
const results = [];
|
|
1468
|
+
try {
|
|
1469
|
+
const files = fs.readdirSync(COPILOT_SESSION_STATE_DIR).filter(f => f.endsWith('.jsonl'));
|
|
1470
|
+
for (const f of files) {
|
|
1471
|
+
const fullPath = path.join(COPILOT_SESSION_STATE_DIR, f);
|
|
1472
|
+
const stat = fs.statSync(fullPath);
|
|
1473
|
+
const parsed = parseCopilotSession(fullPath);
|
|
1474
|
+
if (!parsed) continue;
|
|
1475
|
+
|
|
1476
|
+
results.push({
|
|
1477
|
+
sessionId: parsed.sessionId,
|
|
1478
|
+
alive: false, // Copilot agent sessions don't have persistent PIDs we can check
|
|
1479
|
+
entrypoint: 'copilot-agent',
|
|
1480
|
+
startedAt: parsed.startTime ? new Date(parsed.startTime).getTime() : stat.mtimeMs,
|
|
1481
|
+
model: parsed.model,
|
|
1482
|
+
fileSizeKB: Math.round(stat.size / 1024),
|
|
1483
|
+
totalUserMessages: parsed.totalUserMessages,
|
|
1484
|
+
totalAssistantMessages: parsed.totalAssistantMessages,
|
|
1485
|
+
totalToolCalls: parsed.totalToolCalls,
|
|
1486
|
+
lastActivity: parsed.lastActivity,
|
|
1487
|
+
recentEvents: parsed.recentEvents,
|
|
1488
|
+
displayName: parsed.recentEvents.find(e => e.type === 'user')?.text?.slice(0, 60) || parsed.sessionId.slice(0, 8)
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
} catch {}
|
|
1492
|
+
|
|
1493
|
+
results.sort((a, b) => {
|
|
1494
|
+
const aTime = a.lastActivity ? new Date(a.lastActivity).getTime() : (a.startedAt || 0);
|
|
1495
|
+
const bTime = b.lastActivity ? new Date(b.lastActivity).getTime() : (b.startedAt || 0);
|
|
1496
|
+
return bTime - aTime;
|
|
1497
|
+
});
|
|
1498
|
+
return results;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Build Copilot settings/config data */
|
|
1502
|
+
function buildCopilotSettings() {
|
|
1503
|
+
const config = readJsonFile(COPILOT_CONFIG);
|
|
1504
|
+
const mcpConfig = readJsonFile(COPILOT_MCP_CONFIG);
|
|
1505
|
+
const mcpServers = mcpConfig?.mcpServers || {};
|
|
1506
|
+
|
|
1507
|
+
// Custom agents
|
|
1508
|
+
const agents = readCopilotAgents();
|
|
1509
|
+
|
|
1510
|
+
// Copilot instructions from all known projects
|
|
1511
|
+
const projectInstructions = [];
|
|
1512
|
+
for (const { slug, workDir } of discoverProjectWorkDirs()) {
|
|
1513
|
+
const instrPaths = [
|
|
1514
|
+
path.join(workDir, '.github', 'copilot-instructions.md'),
|
|
1515
|
+
path.join(workDir, 'copilot-instructions.md')
|
|
1516
|
+
];
|
|
1517
|
+
for (const p of instrPaths) {
|
|
1518
|
+
const content = readTextFile(p);
|
|
1519
|
+
if (content) {
|
|
1520
|
+
projectInstructions.push({
|
|
1521
|
+
project: displayNameFromSlug(slug),
|
|
1522
|
+
content: content.slice(0, 3000) + (content.length > 3000 ? '\n... (truncated)' : ''),
|
|
1523
|
+
path: p,
|
|
1524
|
+
lines: content.split('\n').length
|
|
1525
|
+
});
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Reusable prompts from all known projects
|
|
1532
|
+
const prompts = [];
|
|
1533
|
+
const promptsDirs = [];
|
|
1534
|
+
for (const { slug, workDir } of discoverProjectWorkDirs()) {
|
|
1535
|
+
const promptsDir = path.join(workDir, '.github', 'prompts');
|
|
1536
|
+
try {
|
|
1537
|
+
const files = fs.readdirSync(promptsDir).filter(f => f.endsWith('.prompt.md'));
|
|
1538
|
+
for (const f of files) {
|
|
1539
|
+
prompts.push({ file: f, name: f.replace('.prompt.md', ''), path: path.join(promptsDir, f), project: displayNameFromSlug(slug) });
|
|
1540
|
+
}
|
|
1541
|
+
if (files.length > 0) promptsDirs.push(promptsDir);
|
|
1542
|
+
} catch {}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return {
|
|
1546
|
+
config,
|
|
1547
|
+
configPath: COPILOT_CONFIG,
|
|
1548
|
+
mcpServers: maskMcpEnv(mcpServers),
|
|
1549
|
+
mcpConfigPath: COPILOT_MCP_CONFIG,
|
|
1550
|
+
agents,
|
|
1551
|
+
agentsDir: COPILOT_AGENTS_DIR,
|
|
1552
|
+
projectInstructions,
|
|
1553
|
+
prompts,
|
|
1554
|
+
promptsDirs
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// GET /api/copilot/sessions
|
|
1559
|
+
app.get('/api/copilot/sessions', (req, res) => {
|
|
1560
|
+
res.json(buildCopilotSessionList());
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
// GET /api/copilot/settings
|
|
1564
|
+
app.get('/api/copilot/settings', (req, res) => {
|
|
1565
|
+
res.json(buildCopilotSettings());
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// GET /api/agents — returns list of available AI agents with status
|
|
1569
|
+
app.get('/api/agents', (req, res) => {
|
|
1570
|
+
const claudeSessions = buildSessionList(true);
|
|
1571
|
+
const copilotSessions = buildCopilotSessionList();
|
|
1572
|
+
|
|
1573
|
+
res.json([
|
|
1574
|
+
{
|
|
1575
|
+
id: 'claude-code',
|
|
1576
|
+
name: 'Claude Code',
|
|
1577
|
+
shortName: 'CC',
|
|
1578
|
+
icon: 'C',
|
|
1579
|
+
color: '#f78166',
|
|
1580
|
+
activeSessions: claudeSessions.filter(s => s.alive).length,
|
|
1581
|
+
totalSessions: claudeSessions.length,
|
|
1582
|
+
hasSettings: true
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
id: 'github-copilot',
|
|
1586
|
+
name: 'GitHub Copilot',
|
|
1587
|
+
shortName: 'GHC',
|
|
1588
|
+
icon: 'G',
|
|
1589
|
+
color: '#3fb950',
|
|
1590
|
+
activeSessions: 0, // Copilot agent sessions don't expose PID
|
|
1591
|
+
totalSessions: copilotSessions.length,
|
|
1592
|
+
hasSettings: true
|
|
1593
|
+
}
|
|
1594
|
+
]);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
// Start server only when run directly (not when imported by tests)
|
|
1598
|
+
if (require.main === module) {
|
|
1599
|
+
checkClaudeCli(); // Check claude CLI availability at startup
|
|
1600
|
+
server.listen(PORT, () => {
|
|
1601
|
+
const sessions = buildSessionList(true);
|
|
1602
|
+
console.log(`\n AgentPulse — AI Agent Management Dashboard`);
|
|
1603
|
+
console.log(` ──────────────────────────────────────────`);
|
|
1604
|
+
console.log(` Local: http://localhost:${PORT}`);
|
|
1605
|
+
console.log(` Sessions: ${sessions.filter(s => s.alive).length} active / ${sessions.length} total`);
|
|
1606
|
+
console.log(` Projects: ${[...new Set(sessions.map(s => s.project).filter(Boolean))].join(', ')}`);
|
|
1607
|
+
console.log(` Claude: ${claudeCliStatus.available ? 'v' + claudeCliStatus.version : '✗ not found — install: npm i -g @anthropic-ai/claude-code'}\n`);
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
module.exports = {
|
|
1612
|
+
isProcessRunning,
|
|
1613
|
+
parseConversation,
|
|
1614
|
+
summarizeInput,
|
|
1615
|
+
suggestSessionName,
|
|
1616
|
+
loadSessionNames,
|
|
1617
|
+
saveSessionNames,
|
|
1618
|
+
maskSensitive,
|
|
1619
|
+
maskMcpEnv,
|
|
1620
|
+
parseFrontmatter,
|
|
1621
|
+
displayNameFromSlug,
|
|
1622
|
+
readCopilotAgents,
|
|
1623
|
+
parseMemoryFile,
|
|
1624
|
+
findRepoClaudeDir,
|
|
1625
|
+
buildSessionList,
|
|
1626
|
+
parseCopilotSession,
|
|
1627
|
+
buildCopilotSessionList,
|
|
1628
|
+
buildCopilotSettings,
|
|
1629
|
+
slugToPath,
|
|
1630
|
+
discoverProjectWorkDirs,
|
|
1631
|
+
readCommandFiles,
|
|
1632
|
+
checkClaudeCli,
|
|
1633
|
+
app,
|
|
1634
|
+
server,
|
|
1635
|
+
PORT
|
|
1636
|
+
};
|