UNPKG

claude-code-templates

Version:

Component templates and tracking system for Claude Code

837 lines (731 loc) 28.3 kB
const chalk = require('chalk'); const fs = require('fs-extra'); const path = require('path'); const express = require('express'); const open = require('open'); const os = require('os'); const readline = require('readline'); class TeamsDashboard { constructor(options = {}) { this.options = options; this.app = express(); this.port = 3338; this.httpServer = null; this.homeDir = os.homedir(); this.claudeDir = path.join(this.homeDir, '.claude'); this.projectsDir = path.join(this.claudeDir, 'projects'); this.sessionCache = new Map(); } async initialize() { if (!(await fs.pathExists(this.projectsDir))) { throw new Error(`Claude Code projects directory not found at ${this.projectsDir}`); } this.sessions = await this.discoverTeamSessions(); this.setupWebServer(); } async discoverTeamSessions() { const sessions = []; try { const projectDirs = await fs.readdir(this.projectsDir); for (const projectDir of projectDirs) { const projectPath = path.join(this.projectsDir, projectDir); const stat = await fs.stat(projectPath); if (!stat.isDirectory()) continue; const entries = await fs.readdir(projectPath); for (const entry of entries) { const entryPath = path.join(projectPath, entry); const entryStat = await fs.stat(entryPath); if (!entryStat.isDirectory()) continue; const subagentsDir = path.join(entryPath, 'subagents'); if (!(await fs.pathExists(subagentsDir))) continue; const agentFiles = (await fs.readdir(subagentsDir)) .filter(f => f.startsWith('agent-') && f.endsWith('.jsonl') && !f.includes('acompact-')); // Skip auto-compaction agents if (agentFiles.length === 0) continue; // Verify this is a real Team session (has TeamCreate/SendMessage/teammate-message) const leadFile = entryPath + '.jsonl'; const isTeam = await this.detectTeamProtocol(leadFile); if (!isTeam) continue; // Read metadata efficiently: first/last lines only const metadata = await this.readSessionMetadata(entryPath, subagentsDir, agentFiles); sessions.push({ id: entry, projectDir, projectName: this.decodeProjectDir(projectDir), path: entryPath, subagentsDir, agentFiles, agentCount: agentFiles.length, ...metadata }); } } } catch (error) { console.warn(chalk.yellow('Warning: Error discovering team sessions'), error.message); } // Sort newest first sessions.sort((a, b) => new Date(b.startTime || 0) - new Date(a.startTime || 0)); return sessions; } decodeProjectDir(encoded) { // Convert "-Users-macbookpro16-Documents-..." to a readable path return encoded.replace(/^-/, '/').replace(/-/g, '/'); } async detectTeamProtocol(leadFilePath) { // Scan lead JSONL for Team protocol tool_use: TeamCreate or SendMessage // These are the definitive signals — teammate-message XML can appear in documentation text if (!(await fs.pathExists(leadFilePath))) return false; try { const content = await fs.readFile(leadFilePath, 'utf8'); return content.includes('"TeamCreate"') || content.includes('"SendMessage"'); } catch (e) { return false; } } async readSessionMetadata(sessionPath, subagentsDir, agentFiles) { let startTime = null; let endTime = null; let leadAgentId = null; let gitBranch = null; let teammateNames = new Map(); // Read lead session JSONL for start time (skip non-message types like file-history-snapshot) const leadFile = sessionPath + '.jsonl'; if (await fs.pathExists(leadFile)) { try { const firstLines = await this.readFirstNLines(leadFile, 5); for (const line of firstLines) { try { const parsed = JSON.parse(line); if (parsed.timestamp && (parsed.type === 'user' || parsed.type === 'assistant')) { if (!startTime) startTime = parsed.timestamp; if (!gitBranch) gitBranch = parsed.gitBranch; break; } } catch (e) { /* skip malformed */ } } const lastLine = await this.readLastLine(leadFile); if (lastLine) { const parsed = JSON.parse(lastLine); if (parsed.timestamp && (!endTime || new Date(parsed.timestamp) > new Date(endTime))) { endTime = parsed.timestamp; } } } catch (e) { /* skip */ } } // Read each agent's first line for agent IDs and timestamps for (const agentFile of agentFiles) { const filePath = path.join(subagentsDir, agentFile); try { const firstLine = await this.readFirstLine(filePath); if (firstLine) { const parsed = JSON.parse(firstLine); const agentId = parsed.agentId || agentFile.replace('agent-', '').replace('.jsonl', ''); if (!leadAgentId) leadAgentId = agentId; // Try to extract teammate name from teammate-message tags const content = parsed.message?.content || ''; const nameMatch = content.match(/teammate_id="([^"]+)"/); if (nameMatch) { teammateNames.set(agentId, nameMatch[1]); } if (!startTime || new Date(parsed.timestamp) < new Date(startTime)) { startTime = parsed.timestamp; } } const lastLine = await this.readLastLine(filePath); if (lastLine) { const parsed = JSON.parse(lastLine); if (!endTime || new Date(parsed.timestamp) > new Date(endTime)) { endTime = parsed.timestamp; } } } catch (e) { /* skip */ } } return { startTime, endTime, gitBranch, leadAgentId, teammateNames: Object.fromEntries(teammateNames), duration: startTime && endTime ? new Date(endTime) - new Date(startTime) : null }; } async readFirstLine(filePath) { const lines = await this.readFirstNLines(filePath, 1); return lines.length > 0 ? lines[0] : null; } async readFirstNLines(filePath, n) { return new Promise((resolve) => { const lines = []; const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); rl.on('line', (line) => { lines.push(line.trim()); if (lines.length >= n) { rl.close(); stream.destroy(); resolve(lines); } }); rl.on('close', () => resolve(lines)); rl.on('error', () => resolve(lines)); }); } async readLastLine(filePath) { try { const stats = await fs.stat(filePath); const bufferSize = Math.min(8192, stats.size); if (bufferSize === 0) return null; const nativeFs = require('fs'); const fd = nativeFs.openSync(filePath, 'r'); const buffer = Buffer.alloc(bufferSize); nativeFs.readSync(fd, buffer, 0, bufferSize, stats.size - bufferSize); nativeFs.closeSync(fd); const content = buffer.toString('utf8'); const lines = content.split('\n').filter(l => l.trim()); return lines.length > 0 ? lines[lines.length - 1].trim() : null; } catch (e) { return null; } } async parseFullSession(sessionId) { if (this.sessionCache.has(sessionId)) { return this.sessionCache.get(sessionId); } const session = this.sessions.find(s => s.id === sessionId); if (!session) return null; const events = []; const agents = new Map(); const teammateNames = new Map(); // Parse lead session const leadFile = session.path + '.jsonl'; if (await fs.pathExists(leadFile)) { const leadEvents = await this.parseJSONLFile(leadFile, 'lead'); events.push(...leadEvents); // Extract teammate names from lead's SendMessage/TeamCreate tool calls for (const event of leadEvents) { this.extractTeammateInfo(event, teammateNames); } // Register lead as an agent const leadStart = leadEvents.length > 0 ? leadEvents[0].timestamp : null; const leadEnd = leadEvents.length > 0 ? leadEvents[leadEvents.length - 1].timestamp : null; agents.set('lead', { id: 'lead', name: 'Lead', eventCount: leadEvents.length, startTime: leadStart, endTime: leadEnd, toolsUsed: this.countToolsUsed(leadEvents), messageCount: leadEvents.filter(e => e.type === 'assistant' && e.hasText).length, isLead: true }); } // Parse each agent file const agentEventMap = new Map(); for (const agentFile of session.agentFiles) { const filePath = path.join(session.subagentsDir, agentFile); const agentId = agentFile.replace('agent-', '').replace('.jsonl', ''); const agentEvents = await this.parseJSONLFile(filePath, agentId); events.push(...agentEvents); agentEventMap.set(agentId, agentEvents); } // Resolve agent names by matching Task spawns (with input.name) to agent start times const nameMap = this.resolveAgentNames(events, agentEventMap); // Build agent info for (const [agentId, agentEvents] of agentEventMap) { const agentStart = agentEvents.length > 0 ? agentEvents[0].timestamp : null; const agentEnd = agentEvents.length > 0 ? agentEvents[agentEvents.length - 1].timestamp : null; const resolvedName = nameMap.get(agentId); // For unnamed agents, find who spawned them from teammate-message XML tags let spawnedBy = null; let taskSubject = null; if (!resolvedName) { for (const evt of agentEvents) { if (evt.type === 'user' && evt.textContent) { // Only match real teammate messages (content starts with the tag) const trimmed = evt.textContent.trim(); if (trimmed.startsWith('<teammate-message')) { const m = trimmed.match(/<teammate-message\s+teammate_id="([^"]+)"/); if (m) { spawnedBy = m[1]; } // Extract task subject from JSON payload inside the tag const subjectMatch = trimmed.match(/"subject"\s*:\s*"([^"]+)"/); if (subjectMatch) { taskSubject = subjectMatch[1]; } if (spawnedBy) break; } } } } // If still no name and no spawner, try to match this agent to a lead Task spawn by timestamp if (!resolvedName && !spawnedBy) { const agentStart = agentEvents.length > 0 ? new Date(agentEvents[0].timestamp).getTime() : 0; for (const event of events.filter(e => e.agentId === 'lead')) { for (const tool of event.toolUse || []) { if ((tool.name === 'Task' || tool.name === 'Agent') && !tool.input?.name) { const spawnTs = new Date(event.timestamp).getTime(); if (Math.abs(agentStart - spawnTs) < 5000) { spawnedBy = 'Lead'; break; } } } if (spawnedBy) break; } } agents.set(agentId, { id: agentId, name: resolvedName || taskSubject || (spawnedBy ? `${spawnedBy}/${agentId.substring(0, 4)}` : agentId.substring(0, 7)), eventCount: agentEvents.length, startTime: agentStart, endTime: agentEnd, toolsUsed: this.countToolsUsed(agentEvents), messageCount: agentEvents.filter(e => e.type === 'assistant' && e.hasText).length, spawnedBy }); } // Sort events chronologically events.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Build result const result = { id: session.id, projectName: session.projectName, gitBranch: session.gitBranch, startTime: session.startTime, endTime: session.endTime, duration: session.duration, agents: Object.fromEntries(agents), teammateNames: Object.fromEntries(teammateNames), events, communications: this.extractCommunications(events), tasks: this.extractTasks(events), stats: this.calculateSessionStats(events, agents) }; this.sessionCache.set(sessionId, result); return result; } resolveAgentNames(allEvents, agentEventMap) { const nameMap = new Map(); // Collect all Task tool_use blocks with input.name (agent spawn calls) const spawns = []; for (const event of allEvents) { for (const tool of event.toolUse || []) { if ((tool.name === 'Task' || tool.name === 'Agent') && tool.input?.name) { spawns.push({ name: tool.input.name, timestamp: new Date(event.timestamp).getTime() }); } } } spawns.sort((a, b) => a.timestamp - b.timestamp); // Get agent start times const agentStarts = []; for (const [agentId, events] of agentEventMap) { if (events.length > 0) { agentStarts.push({ id: agentId, timestamp: new Date(events[0].timestamp).getTime() }); } } agentStarts.sort((a, b) => a.timestamp - b.timestamp); // Match each spawn to the closest agent that starts within 2 minutes after const usedAgents = new Set(); for (const spawn of spawns) { let bestId = null, bestDiff = Infinity; for (const agent of agentStarts) { if (usedAgents.has(agent.id)) continue; const diff = agent.timestamp - spawn.timestamp; if (diff >= -2000 && diff < bestDiff && diff < 120000) { bestDiff = diff; bestId = agent.id; } } if (bestId) { nameMap.set(bestId, spawn.name); usedAgents.add(bestId); } } return nameMap; } async parseJSONLFile(filePath, agentId) { const events = []; try { const content = await fs.readFile(filePath, 'utf8'); const lines = content.split('\n').filter(l => l.trim()); for (const line of lines) { try { const parsed = JSON.parse(line); // Skip file-history-snapshot and other non-message types if (parsed.type === 'file-history-snapshot') continue; const msg = parsed.message || {}; const contentBlocks = Array.isArray(msg.content) ? msg.content : []; const textContent = typeof msg.content === 'string' ? msg.content : contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('\n'); const toolUseBlocks = contentBlocks.filter(b => b.type === 'tool_use'); const toolResultBlocks = contentBlocks.filter(b => b.type === 'tool_result'); events.push({ agentId: parsed.agentId || agentId, type: parsed.type, // 'user' or 'assistant' role: msg.role, timestamp: parsed.timestamp, textContent: textContent.substring(0, 2000), // Truncate for display hasText: textContent.length > 0, toolUse: toolUseBlocks.map(t => ({ name: t.name, id: t.id, input: t.input })), toolResults: toolResultBlocks.map(t => ({ tool_use_id: t.tool_use_id, content: typeof t.content === 'string' ? t.content.substring(0, 500) : JSON.stringify(t.content).substring(0, 500) })), model: msg.model, isSidechain: parsed.isSidechain }); } catch (e) { /* skip malformed lines */ } } } catch (e) { /* skip unreadable files */ } return events; } extractTeammateInfo(event, teammateNames) { // From teammate-message XML tags const text = event.textContent || ''; const matches = text.matchAll(/teammate_id="([^"]+)"/g); for (const match of matches) { const name = match[1]; // The teammate_id is the name of the SENDER, which sends to event.agentId // We store it as a known name if (!teammateNames.has(name)) { // We don't know the agentId for the sender directly, but we can infer } } // From SendMessage tool_use for (const tool of event.toolUse || []) { if (tool.name === 'SendMessage' && tool.input) { if (tool.input.recipient) { // recipient is a name } } if ((tool.name === 'Task' || tool.name === 'Agent') && tool.input?.name) { // Task tool spawning a named teammate const agentName = tool.input.name; // We'd need to correlate with agentId later } } // From teammate-message: the teammate_id is the sender name const teammateMatch = text.match(/<teammate-message\s+teammate_id="([^"]+)"/); if (teammateMatch) { // This message was RECEIVED by event.agentId FROM teammate_id // So teammate_id is a name we want to track const senderName = teammateMatch[1]; // We can't directly map senderName to an agentId from this alone // but we store it for display } } extractCommunications(events) { const communications = []; for (const event of events) { // Check for teammate-message in user messages (must start with the tag to avoid documentation text) if (event.type === 'user' && event.textContent && event.textContent.trim().startsWith('<teammate-message')) { const match = event.textContent.match(/<teammate-message\s+teammate_id="([^"]+)"(?:\s+color="([^"]*)")?>\n?([\s\S]*?)\n?<\/teammate-message>/); if (match) { let messageContent = match[3]; let parsedContent = null; try { parsedContent = JSON.parse(messageContent); } catch (e) { parsedContent = { text: messageContent }; } communications.push({ timestamp: event.timestamp, from: match[1], to: event.agentId, color: match[2] || null, content: parsedContent, messageType: parsedContent?.type || 'message', raw: messageContent.substring(0, 1000) }); } } // Check for SendMessage tool_use in assistant messages for (const tool of event.toolUse || []) { if (tool.name === 'SendMessage' && tool.input) { communications.push({ timestamp: event.timestamp, from: event.agentId, to: tool.input.recipient || 'unknown', content: { type: tool.input.type || 'message', text: tool.input.content || '', summary: tool.input.summary || '' }, messageType: tool.input.type || 'message', raw: (tool.input.content || '').substring(0, 1000) }); } } } communications.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); return communications; } extractTasks(events) { const tasks = []; const taskMap = new Map(); for (const event of events) { for (const tool of event.toolUse || []) { if (tool.name === 'TaskCreate' && tool.input) { const task = { id: null, // Will be set from tool result subject: tool.input.subject, description: tool.input.description, activeForm: tool.input.activeForm, createdBy: event.agentId, createdAt: event.timestamp, updates: [] }; tasks.push(task); } if (tool.name === 'TaskUpdate' && tool.input) { const update = { taskId: tool.input.taskId, status: tool.input.status, owner: tool.input.owner, updatedBy: event.agentId, updatedAt: event.timestamp, subject: tool.input.subject }; // Find matching task and add update const existingTask = tasks.find(t => t.id === tool.input.taskId || (!t.id && tasks.indexOf(t).toString() === tool.input.taskId) ); if (existingTask) { existingTask.updates.push(update); if (tool.input.status) existingTask.latestStatus = tool.input.status; if (tool.input.owner) existingTask.owner = tool.input.owner; } else { tasks.push({ id: tool.input.taskId, subject: tool.input.subject || `Task #${tool.input.taskId}`, createdBy: 'unknown', createdAt: event.timestamp, latestStatus: tool.input.status, owner: tool.input.owner, updates: [update] }); } } } } return tasks; } countToolsUsed(events) { const toolCounts = {}; for (const event of events) { for (const tool of event.toolUse || []) { toolCounts[tool.name] = (toolCounts[tool.name] || 0) + 1; } } return toolCounts; } calculateSessionStats(events, agents) { const totalEvents = events.length; const userEvents = events.filter(e => e.type === 'user').length; const assistantEvents = events.filter(e => e.type === 'assistant').length; const toolUsages = events.reduce((sum, e) => sum + (e.toolUse?.length || 0), 0); const toolBreakdown = {}; for (const event of events) { for (const tool of event.toolUse || []) { toolBreakdown[tool.name] = (toolBreakdown[tool.name] || 0) + 1; } } return { totalEvents, userEvents, assistantEvents, toolUsages, agentCount: agents.size, toolBreakdown }; } setupWebServer() { this.app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); this.app.use(express.static(path.join(__dirname, 'teams-dashboard-web'))); // List all team sessions (lightweight) this.app.get('/api/sessions', async (req, res) => { try { this.sessions = await this.discoverTeamSessions(); res.json({ sessions: this.sessions.map(s => ({ id: s.id, projectName: s.projectName, agentCount: s.agentCount, startTime: s.startTime, endTime: s.endTime, duration: s.duration, gitBranch: s.gitBranch, teammateNames: s.teammateNames })), count: this.sessions.length, timestamp: new Date().toISOString() }); } catch (error) { res.status(500).json({ error: 'Failed to load sessions' }); } }); // Global summary stats this.app.get('/api/summary', async (req, res) => { try { res.json({ totalSessions: this.sessions.length, totalAgents: this.sessions.reduce((sum, s) => sum + s.agentCount, 0), timestamp: new Date().toISOString() }); } catch (error) { res.status(500).json({ error: 'Failed to load summary' }); } }); // Full session detail (triggers parse) this.app.get('/api/sessions/:id', async (req, res) => { try { const session = await this.parseFullSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); res.json({ id: session.id, projectName: session.projectName, gitBranch: session.gitBranch, startTime: session.startTime, endTime: session.endTime, duration: session.duration, agents: session.agents, teammateNames: session.teammateNames, stats: session.stats }); } catch (error) { res.status(500).json({ error: 'Failed to load session' }); } }); // Timeline (paginated) this.app.get('/api/sessions/:id/timeline', async (req, res) => { try { const session = await this.parseFullSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const page = parseInt(req.query.page) || 0; const pageSize = parseInt(req.query.pageSize) || 50; const start = page * pageSize; const events = session.events.slice(start, start + pageSize); res.json({ events, total: session.events.length, page, pageSize, hasMore: start + pageSize < session.events.length }); } catch (error) { res.status(500).json({ error: 'Failed to load timeline' }); } }); // Communications this.app.get('/api/sessions/:id/communications', async (req, res) => { try { const session = await this.parseFullSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); res.json({ communications: session.communications, count: session.communications.length }); } catch (error) { res.status(500).json({ error: 'Failed to load communications' }); } }); // Tasks this.app.get('/api/sessions/:id/tasks', async (req, res) => { try { const session = await this.parseFullSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); res.json({ tasks: session.tasks, count: session.tasks.length }); } catch (error) { res.status(500).json({ error: 'Failed to load tasks' }); } }); // Single teammate detail this.app.get('/api/sessions/:id/teammates/:agentId', async (req, res) => { try { const session = await this.parseFullSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const agent = session.agents[req.params.agentId]; if (!agent) return res.status(404).json({ error: 'Agent not found' }); // Get agent-specific events const agentEvents = session.events .filter(e => e.agentId === req.params.agentId) .slice(0, 100); res.json({ ...agent, recentEvents: agentEvents }); } catch (error) { res.status(500).json({ error: 'Failed to load teammate' }); } }); // Main route this.app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'teams-dashboard-web', 'index.html')); }); } async startServer() { return new Promise((resolve) => { this.httpServer = this.app.listen(this.port, async () => { console.log(chalk.green(`Teams dashboard started at http://localhost:${this.port}`)); resolve(); }); }); } async openBrowser() { const url = `http://localhost:${this.port}`; console.log(chalk.blue('Opening browser to Teams Dashboard...')); try { await open(url); } catch (error) { console.log(chalk.yellow('Could not open browser automatically. Please visit:')); console.log(chalk.cyan(url)); } } stop() { if (this.httpServer) { this.httpServer.close(); } console.log(chalk.yellow('Teams dashboard stopped')); } } async function runTeamsDashboard(options = {}) { console.log(chalk.blue('Starting Agent Teams Dashboard...')); const dashboard = new TeamsDashboard(options); try { await dashboard.initialize(); await dashboard.startServer(); await dashboard.openBrowser(); console.log(chalk.green('Teams dashboard is running!')); console.log(chalk.cyan(`Access at: http://localhost:${dashboard.port}`)); console.log(chalk.gray('Press Ctrl+C to stop the server')); process.on('SIGINT', () => { console.log(chalk.yellow('\nShutting down teams dashboard...')); dashboard.stop(); process.exit(0); }); await new Promise(() => {}); } catch (error) { console.error(chalk.red('Failed to start teams dashboard:'), error.message); process.exit(1); } } module.exports = { runTeamsDashboard, TeamsDashboard };