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/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
+ };