diff --git a/scripts/watch-subagents.ts b/scripts/watch-subagents.ts new file mode 100644 index 00000000..621f24e4 --- /dev/null +++ b/scripts/watch-subagents.ts @@ -0,0 +1,411 @@ +#!/usr/bin/env npx tsx +/** + * Real-time subagent transcript watcher + * + * Usage: + * npx tsx scripts/watch-subagents.ts # Watch all recent + * npx tsx scripts/watch-subagents.ts --session # Watch specific session + * npx tsx scripts/watch-subagents.ts --agent # Watch specific agent + * npx tsx scripts/watch-subagents.ts --list # List active subagents + */ + +import { watch, statSync, readdirSync, existsSync, readFileSync } from 'fs'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { homedir } from 'os'; +import { join, basename, dirname } from 'path'; + +// ANSI colors +const colors = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', +}; + +const c = colors; + +interface TranscriptEntry { + type: 'user' | 'assistant' | 'progress'; + timestamp: string; + agentId: string; + sessionId: string; + message?: { + role: string; + content: Array<{ + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + input?: Record; + content?: string; + }>; + }; + data?: { + type: string; + query?: string; + resultCount?: number; + }; +} + +class SubagentWatcher { + private claudeDir = join(homedir(), '.claude/projects'); + private filePositions = new Map(); + private seenAgents = new Set(); + private watchedFiles = new Set(); + + // Find all subagent directories + findSubagentDirs(): string[] { + const dirs: string[] = []; + + if (!existsSync(this.claudeDir)) { + console.error(`${c.red}Claude projects directory not found: ${this.claudeDir}${c.reset}`); + return dirs; + } + + const projects = readdirSync(this.claudeDir); + for (const project of projects) { + const projectPath = join(this.claudeDir, project); + try { + const sessions = readdirSync(projectPath); + for (const session of sessions) { + const subagentDir = join(projectPath, session, 'subagents'); + if (existsSync(subagentDir)) { + dirs.push(subagentDir); + } + } + } catch { + // Not a directory or no access + } + } + + return dirs; + } + + // Find recently modified agent transcripts + findRecentAgents(minutes: number = 60): Array<{ path: string; agentId: string; mtime: Date }> { + const agents: Array<{ path: string; agentId: string; mtime: Date }> = []; + const cutoff = Date.now() - minutes * 60 * 1000; + + for (const dir of this.findSubagentDirs()) { + try { + const files = readdirSync(dir); + for (const file of files) { + if (file.endsWith('.jsonl')) { + const filePath = join(dir, file); + const stat = statSync(filePath); + if (stat.mtimeMs > cutoff) { + const agentId = file.replace('agent-', '').replace('.jsonl', ''); + agents.push({ path: filePath, agentId, mtime: stat.mtime }); + } + } + } + } catch { + // Skip inaccessible directories + } + } + + return agents.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + } + + // Format a tool call nicely + formatToolCall(name: string, input: Record): string { + const icons: Record = { + WebSearch: '🔍', + WebFetch: '🌐', + Read: '📖', + Write: '📝', + Edit: '✏️', + Bash: '💻', + Glob: '📁', + Grep: '🔎', + Task: '🤖', + }; + + const icon = icons[name] || '🔧'; + let details = ''; + + if (name === 'WebSearch' && input.query) { + details = `"${input.query}"`; + } else if (name === 'WebFetch' && input.url) { + details = input.url as string; + } else if (name === 'Read' && input.file_path) { + details = input.file_path as string; + } else if (name === 'Write' && input.file_path) { + details = input.file_path as string; + } else if (name === 'Edit' && input.file_path) { + details = input.file_path as string; + } else if (name === 'Bash' && input.command) { + const cmd = input.command as string; + details = cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd; + } else if (name === 'Glob' && input.pattern) { + details = input.pattern as string; + } else if (name === 'Grep' && input.pattern) { + details = input.pattern as string; + } else if (name === 'Task' && input.description) { + details = input.description as string; + } else { + // Show first key-value pair + const keys = Object.keys(input); + if (keys.length > 0) { + const val = input[keys[0]]; + details = typeof val === 'string' ? val.substring(0, 50) : JSON.stringify(val).substring(0, 50); + } + } + + return `${icon} ${c.cyan}${name}${c.reset}: ${c.dim}${details}${c.reset}`; + } + + // Format a progress event + formatProgress(data: { type: string; query?: string; resultCount?: number }): string { + if (data.type === 'query_update') { + return `${c.yellow}⟳${c.reset} Searching: ${c.dim}"${data.query}"${c.reset}`; + } else if (data.type === 'search_results_received') { + return `${c.green}✓${c.reset} Got ${data.resultCount} results for: ${c.dim}"${data.query}"${c.reset}`; + } + return `${c.dim}Progress: ${data.type}${c.reset}`; + } + + // Format timestamp + formatTime(timestamp: string): string { + const date = new Date(timestamp); + return `${c.dim}${date.toLocaleTimeString()}${c.reset}`; + } + + // Process a single transcript entry + processEntry(entry: TranscriptEntry, agentId: string): void { + const time = this.formatTime(entry.timestamp); + const prefix = `${c.magenta}[${agentId.substring(0, 7)}]${c.reset}`; + + if (entry.type === 'progress' && entry.data) { + console.log(`${time} ${prefix} ${this.formatProgress(entry.data)}`); + } else if (entry.type === 'assistant' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'tool_use' && content.name) { + console.log(`${time} ${prefix} ${this.formatToolCall(content.name, content.input || {})}`); + } else if (content.type === 'text' && content.text) { + // Show first 200 chars of text responses + const text = content.text.trim(); + if (text.length > 0) { + const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + console.log(`${time} ${prefix} ${c.white}💬 ${preview.replace(/\n/g, ' ')}${c.reset}`); + } + } + } + } else if (entry.type === 'user' && entry.message?.content) { + // User messages are usually tool results or initial prompt + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + const text = firstContent.text.trim(); + if (text.length < 100 && !text.includes('{')) { + console.log(`${time} ${prefix} ${c.blue}📥 User: ${text.substring(0, 80)}${c.reset}`); + } + } + } + } + + // Tail a single file from a position + async tailFile(filePath: string, fromPosition: number = 0): Promise { + return new Promise((resolve) => { + const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); + let position = fromPosition; + let lineCount = 0; + + const stream = createReadStream(filePath, { start: fromPosition }); + const rl = createInterface({ input: stream }); + + rl.on('line', (line) => { + position += Buffer.byteLength(line, 'utf8') + 1; // +1 for newline + lineCount++; + + try { + const entry = JSON.parse(line) as TranscriptEntry; + this.processEntry(entry, agentId); + } catch { + // Skip malformed lines + } + }); + + rl.on('close', () => { + resolve(position); + }); + }); + } + + // Watch a file for changes + watchFile(filePath: string): void { + if (this.watchedFiles.has(filePath)) return; + this.watchedFiles.add(filePath); + + const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); + + // Initial read + this.tailFile(filePath, 0).then((position) => { + this.filePositions.set(filePath, position); + }); + + // Watch for changes + watch(filePath, async (eventType) => { + if (eventType === 'change') { + const currentPos = this.filePositions.get(filePath) || 0; + const newPos = await this.tailFile(filePath, currentPos); + this.filePositions.set(filePath, newPos); + } + }); + + if (!this.seenAgents.has(agentId)) { + this.seenAgents.add(agentId); + console.log(`\n${c.bgMagenta}${c.white} NEW AGENT ${c.reset} ${c.bold}${agentId}${c.reset} - ${filePath}\n`); + } + } + + // Watch a directory for new agent files + watchDirectory(dir: string): void { + // Watch existing files + try { + const files = readdirSync(dir); + for (const file of files) { + if (file.endsWith('.jsonl')) { + this.watchFile(join(dir, file)); + } + } + } catch { + return; + } + + // Watch for new files + watch(dir, (eventType, filename) => { + if (filename?.endsWith('.jsonl')) { + const filePath = join(dir, filename); + if (existsSync(filePath)) { + this.watchFile(filePath); + } + } + }); + } + + // Watch all subagent directories + watchAll(): void { + console.log(`${c.bold}${c.cyan}🔭 Watching for subagent activity...${c.reset}\n`); + + const dirs = this.findSubagentDirs(); + console.log(`${c.dim}Found ${dirs.length} subagent directories${c.reset}\n`); + + // Watch for recent activity first + const recent = this.findRecentAgents(30); // Last 30 minutes + if (recent.length > 0) { + console.log(`${c.yellow}Recent agents (last 30 min):${c.reset}`); + for (const agent of recent.slice(0, 5)) { + console.log(` ${c.magenta}${agent.agentId}${c.reset} - ${c.dim}${agent.mtime.toLocaleTimeString()}${c.reset}`); + } + console.log(''); + } + + // Watch all directories + for (const dir of dirs) { + this.watchDirectory(dir); + } + + // Also watch for new session directories + const projects = readdirSync(this.claudeDir); + for (const project of projects) { + const projectPath = join(this.claudeDir, project); + try { + watch(projectPath, (eventType, filename) => { + if (filename) { + const subagentDir = join(projectPath, filename, 'subagents'); + if (existsSync(subagentDir)) { + this.watchDirectory(subagentDir); + } + } + }); + } catch { + // Skip + } + } + + console.log(`${c.green}✓ Watching... Press Ctrl+C to stop${c.reset}\n`); + console.log(`${c.dim}${'─'.repeat(60)}${c.reset}\n`); + } + + // List recent agents + listAgents(): void { + const agents = this.findRecentAgents(120); // Last 2 hours + + if (agents.length === 0) { + console.log(`${c.yellow}No recent subagent activity found${c.reset}`); + return; + } + + console.log(`${c.bold}Recent Subagents (last 2 hours):${c.reset}\n`); + + for (const agent of agents) { + const stat = statSync(agent.path); + const lines = this.countLines(agent.path); + const sessionId = basename(dirname(dirname(agent.path))); + + console.log(`${c.magenta}${agent.agentId}${c.reset}`); + console.log(` Session: ${c.dim}${sessionId.substring(0, 8)}...${c.reset}`); + console.log(` Modified: ${c.dim}${agent.mtime.toLocaleString()}${c.reset}`); + console.log(` Size: ${c.dim}${(stat.size / 1024).toFixed(1)}KB${c.reset} (${lines} entries)`); + console.log(` Path: ${c.dim}${agent.path}${c.reset}`); + console.log(''); + } + } + + private countLines(filePath: string): number { + try { + const content = readFileSync(filePath, 'utf8'); + return content.split('\n').filter((l: string) => l.trim()).length; + } catch { + return 0; + } + } + + // Watch a specific agent + watchAgent(agentId: string): void { + const agents = this.findRecentAgents(1440); // Last 24 hours + const agent = agents.find(a => a.agentId.startsWith(agentId)); + + if (!agent) { + console.log(`${c.red}Agent not found: ${agentId}${c.reset}`); + console.log(`${c.dim}Try: npx tsx scripts/watch-subagents.ts --list${c.reset}`); + return; + } + + console.log(`${c.bold}${c.cyan}🔭 Watching agent ${agent.agentId}${c.reset}\n`); + this.watchFile(agent.path); + } +} + +// Main +const args = process.argv.slice(2); + +const watcher = new SubagentWatcher(); + +if (args.includes('--list') || args.includes('-l')) { + watcher.listAgents(); +} else if (args.includes('--agent') || args.includes('-a')) { + const idx = args.indexOf('--agent') !== -1 ? args.indexOf('--agent') : args.indexOf('-a'); + const agentId = args[idx + 1]; + if (!agentId) { + console.log(`${c.red}Please provide an agent ID${c.reset}`); + process.exit(1); + } + watcher.watchAgent(agentId); +} else { + watcher.watchAll(); +} + +// Keep process running +process.on('SIGINT', () => { + console.log(`\n${c.yellow}Stopped watching${c.reset}`); + process.exit(0); +}); diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts new file mode 100644 index 00000000..58282a8b --- /dev/null +++ b/src/subagent-watcher.ts @@ -0,0 +1,595 @@ +/** + * @fileoverview Subagent Watcher - Real-time monitoring of Claude Code background agents + * + * Watches ~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl files + * and emits structured events for tool calls, progress, and messages. + */ + +import { EventEmitter } from 'events'; +import { watch, statSync, readdirSync, existsSync, readFileSync, FSWatcher } from 'fs'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { homedir } from 'os'; +import { join, basename } from 'path'; + +// ========== Types ========== + +export interface SubagentInfo { + agentId: string; + sessionId: string; + projectHash: string; + filePath: string; + startedAt: string; + lastActivityAt: string; + status: 'active' | 'idle' | 'completed'; + toolCallCount: number; + entryCount: number; + fileSize: number; +} + +export interface SubagentToolCall { + agentId: string; + sessionId: string; + timestamp: string; + tool: string; + input: Record; +} + +export interface SubagentProgress { + agentId: string; + sessionId: string; + timestamp: string; + progressType: 'query_update' | 'search_results_received' | string; + query?: string; + resultCount?: number; +} + +export interface SubagentMessage { + agentId: string; + sessionId: string; + timestamp: string; + role: 'user' | 'assistant'; + text: string; +} + +export interface SubagentTranscriptEntry { + type: 'user' | 'assistant' | 'progress'; + timestamp: string; + agentId: string; + sessionId: string; + message?: { + role: string; + content: Array<{ + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + input?: Record; + content?: string; + }>; + }; + data?: { + type: string; + query?: string; + resultCount?: number; + }; +} + +export interface SubagentEvents { + 'subagent:discovered': (info: SubagentInfo) => void; + 'subagent:tool_call': (data: SubagentToolCall) => void; + 'subagent:progress': (data: SubagentProgress) => void; + 'subagent:message': (data: SubagentMessage) => void; + 'subagent:completed': (info: SubagentInfo) => void; + 'subagent:error': (error: Error, agentId?: string) => void; +} + +// ========== Constants ========== + +const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects'); +const IDLE_TIMEOUT_MS = 30000; // Consider agent idle after 30s of no activity +const POLL_INTERVAL_MS = 1000; // Check for new files every second + +// ========== SubagentWatcher Class ========== + +export class SubagentWatcher extends EventEmitter { + private filePositions = new Map(); + private fileWatchers = new Map(); + private dirWatchers = new Map(); + private agentInfo = new Map(); + private idleTimers = new Map(); + private pollInterval: NodeJS.Timeout | null = null; + private isRunning = false; + private knownSubagentDirs = new Set(); + + constructor() { + super(); + } + + // ========== Public API ========== + + /** + * Start watching for subagent activity + */ + start(): void { + if (this.isRunning) return; + this.isRunning = true; + + // Initial scan + this.scanForSubagents(); + + // Periodic scan for new subagent directories + this.pollInterval = setInterval(() => { + this.scanForSubagents(); + }, POLL_INTERVAL_MS); + } + + /** + * Stop watching + */ + stop(): void { + this.isRunning = false; + + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + for (const watcher of this.fileWatchers.values()) { + watcher.close(); + } + this.fileWatchers.clear(); + + for (const watcher of this.dirWatchers.values()) { + watcher.close(); + } + this.dirWatchers.clear(); + + for (const timer of this.idleTimers.values()) { + clearTimeout(timer); + } + this.idleTimers.clear(); + } + + /** + * Get all known subagents + */ + getSubagents(): SubagentInfo[] { + return Array.from(this.agentInfo.values()); + } + + /** + * Get subagents for a specific Claudeman session + * Maps Claudeman working directory to Claude's project hash + */ + getSubagentsForSession(workingDir: string): SubagentInfo[] { + const projectHash = this.getProjectHash(workingDir); + return Array.from(this.agentInfo.values()).filter( + (info) => info.projectHash === projectHash + ); + } + + /** + * Get a specific subagent's info + */ + getSubagent(agentId: string): SubagentInfo | undefined { + return this.agentInfo.get(agentId); + } + + /** + * Get recent subagents (modified within specified minutes) + */ + getRecentSubagents(minutes: number = 60): SubagentInfo[] { + const cutoff = Date.now() - minutes * 60 * 1000; + return Array.from(this.agentInfo.values()) + .filter((info) => new Date(info.lastActivityAt).getTime() > cutoff) + .sort((a, b) => + new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime() + ); + } + + /** + * Get transcript for a subagent (optionally limited to last N entries) + */ + async getTranscript(agentId: string, limit?: number): Promise { + const info = this.agentInfo.get(agentId); + if (!info) return []; + + const entries: SubagentTranscriptEntry[] = []; + + try { + const content = readFileSync(info.filePath, 'utf8'); + const lines = content.split('\n').filter((l) => l.trim()); + + for (const line of lines) { + try { + const entry = JSON.parse(line) as SubagentTranscriptEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + } catch { + // File read error + } + + if (limit && limit > 0) { + return entries.slice(-limit); + } + + return entries; + } + + /** + * Format transcript entries for display + */ + formatTranscript(entries: SubagentTranscriptEntry[]): string[] { + const lines: string[] = []; + + for (const entry of entries) { + if (entry.type === 'progress' && entry.data) { + lines.push(this.formatProgress(entry)); + } else if (entry.type === 'assistant' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'tool_use' && content.name) { + lines.push(this.formatToolCall(entry.timestamp, content.name, content.input || {})); + } else if (content.type === 'text' && content.text) { + const text = content.text.trim(); + if (text.length > 0) { + const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); + } + } + } + } else if (entry.type === 'user' && entry.message?.content) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + const text = firstContent.text.trim(); + if (text.length < 100 && !text.includes('{')) { + lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`); + } + } + } + } + + return lines; + } + + // ========== Private Methods ========== + + /** + * Convert working directory to Claude's project hash format + */ + private getProjectHash(workingDir: string): string { + return workingDir.replace(/\//g, '-'); + } + + /** + * Scan for all subagent directories + */ + private scanForSubagents(): void { + if (!existsSync(CLAUDE_PROJECTS_DIR)) return; + + try { + const projects = readdirSync(CLAUDE_PROJECTS_DIR); + + for (const project of projects) { + const projectPath = join(CLAUDE_PROJECTS_DIR, project); + + try { + const stat = statSync(projectPath); + if (!stat.isDirectory()) continue; + + const sessions = readdirSync(projectPath); + + for (const session of sessions) { + const sessionPath = join(projectPath, session); + + try { + const sessionStat = statSync(sessionPath); + if (!sessionStat.isDirectory()) continue; + + const subagentDir = join(sessionPath, 'subagents'); + if (existsSync(subagentDir)) { + this.watchSubagentDir(subagentDir, project, session); + } + } catch { + // Skip inaccessible session directories + } + } + } catch { + // Skip inaccessible project directories + } + } + } catch (error) { + this.emit('subagent:error', error as Error); + } + } + + /** + * Watch a subagent directory for new/updated files + */ + private watchSubagentDir(dir: string, projectHash: string, sessionId: string): void { + if (this.knownSubagentDirs.has(dir)) return; + this.knownSubagentDirs.add(dir); + + // Watch existing files + try { + const files = readdirSync(dir); + for (const file of files) { + if (file.endsWith('.jsonl')) { + this.watchAgentFile(join(dir, file), projectHash, sessionId); + } + } + } catch { + return; + } + + // Watch for new files + try { + const watcher = watch(dir, (_eventType, filename) => { + if (filename?.endsWith('.jsonl')) { + const filePath = join(dir, filename); + if (existsSync(filePath)) { + this.watchAgentFile(filePath, projectHash, sessionId); + } + } + }); + + this.dirWatchers.set(dir, watcher); + } catch { + // Watch failed + } + } + + /** + * Watch a specific agent transcript file + */ + private watchAgentFile(filePath: string, projectHash: string, sessionId: string): void { + if (this.fileWatchers.has(filePath)) return; + + const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); + + // Initial info + const stat = statSync(filePath); + const info: SubagentInfo = { + agentId, + sessionId, + projectHash, + filePath, + startedAt: stat.birthtime.toISOString(), + lastActivityAt: stat.mtime.toISOString(), + status: 'active', + toolCallCount: 0, + entryCount: 0, + fileSize: stat.size, + }; + + this.agentInfo.set(agentId, info); + this.emit('subagent:discovered', info); + + // Read existing content + this.tailFile(filePath, agentId, sessionId, 0).then((position) => { + this.filePositions.set(filePath, position); + }); + + // Watch for changes + try { + const watcher = watch(filePath, async (eventType) => { + if (eventType === 'change') { + const currentPos = this.filePositions.get(filePath) || 0; + const newPos = await this.tailFile(filePath, agentId, sessionId, currentPos); + this.filePositions.set(filePath, newPos); + + // Update info + const existingInfo = this.agentInfo.get(agentId); + if (existingInfo) { + try { + const newStat = statSync(filePath); + existingInfo.lastActivityAt = new Date().toISOString(); + existingInfo.fileSize = newStat.size; + existingInfo.status = 'active'; + } catch { + // Stat failed + } + + // Reset idle timer + this.resetIdleTimer(agentId); + } + } + }); + + this.fileWatchers.set(filePath, watcher); + this.resetIdleTimer(agentId); + } catch { + // Watch failed + } + } + + /** + * Tail a file from a specific position + */ + private async tailFile( + filePath: string, + agentId: string, + sessionId: string, + fromPosition: number + ): Promise { + return new Promise((resolve) => { + let position = fromPosition; + + const stream = createReadStream(filePath, { start: fromPosition }); + const rl = createInterface({ input: stream }); + + rl.on('line', (line) => { + position += Buffer.byteLength(line, 'utf8') + 1; + + try { + const entry = JSON.parse(line) as SubagentTranscriptEntry; + this.processEntry(entry, agentId, sessionId); + + // Update entry count + const info = this.agentInfo.get(agentId); + if (info) { + info.entryCount++; + } + } catch { + // Skip malformed lines + } + }); + + rl.on('close', () => { + resolve(position); + }); + + rl.on('error', () => { + resolve(position); + }); + }); + } + + /** + * Process a transcript entry and emit appropriate events + */ + private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void { + if (entry.type === 'progress' && entry.data) { + const progress: SubagentProgress = { + agentId, + sessionId, + timestamp: entry.timestamp, + progressType: entry.data.type, + query: entry.data.query, + resultCount: entry.data.resultCount, + }; + this.emit('subagent:progress', progress); + } else if (entry.type === 'assistant' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'tool_use' && content.name) { + const toolCall: SubagentToolCall = { + agentId, + sessionId, + timestamp: entry.timestamp, + tool: content.name, + input: content.input || {}, + }; + this.emit('subagent:tool_call', toolCall); + + // Update tool call count + const info = this.agentInfo.get(agentId); + if (info) { + info.toolCallCount++; + } + } else if (content.type === 'text' && content.text) { + const text = content.text.trim(); + if (text.length > 0) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'assistant', + text: text.substring(0, 500), // Limit text length + }; + this.emit('subagent:message', message); + } + } + } + } else if (entry.type === 'user' && entry.message?.content) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + const text = firstContent.text.trim(); + if (text.length > 0 && text.length < 500) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'user', + text, + }; + this.emit('subagent:message', message); + } + } + } + } + + /** + * Reset idle timer for an agent + */ + private resetIdleTimer(agentId: string): void { + const existing = this.idleTimers.get(agentId); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(() => { + const info = this.agentInfo.get(agentId); + if (info && info.status === 'active') { + info.status = 'idle'; + } + }, IDLE_TIMEOUT_MS); + + this.idleTimers.set(agentId, timer); + } + + /** + * Format a tool call for display + */ + private formatToolCall(timestamp: string, name: string, input: Record): string { + const icons: Record = { + WebSearch: '🔍', + WebFetch: '🌐', + Read: '📖', + Write: '📝', + Edit: '✏️', + Bash: '💻', + Glob: '📁', + Grep: '🔎', + Task: '🤖', + }; + + const icon = icons[name] || '🔧'; + let details = ''; + + if (name === 'WebSearch' && input.query) { + details = `"${input.query}"`; + } else if (name === 'WebFetch' && input.url) { + details = input.url as string; + } else if (name === 'Read' && input.file_path) { + details = input.file_path as string; + } else if ((name === 'Write' || name === 'Edit') && input.file_path) { + details = input.file_path as string; + } else if (name === 'Bash' && input.command) { + const cmd = input.command as string; + details = cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd; + } else if (name === 'Glob' && input.pattern) { + details = input.pattern as string; + } else if (name === 'Grep' && input.pattern) { + details = input.pattern as string; + } else if (name === 'Task' && input.description) { + details = input.description as string; + } + + return `${this.formatTime(timestamp)} ${icon} ${name}: ${details}`; + } + + /** + * Format a progress event for display + */ + private formatProgress(entry: SubagentTranscriptEntry): string { + const data = entry.data!; + if (data.type === 'query_update') { + return `${this.formatTime(entry.timestamp)} ⟳ Searching: "${data.query}"`; + } else if (data.type === 'search_results_received') { + return `${this.formatTime(entry.timestamp)} ✓ Got ${data.resultCount} results`; + } + return `${this.formatTime(entry.timestamp)} Progress: ${data.type}`; + } + + /** + * Format timestamp for display + */ + private formatTime(timestamp: string): string { + return new Date(timestamp).toLocaleTimeString(); + } +} + +// Export singleton instance +export const subagentWatcher = new SubagentWatcher(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 600cd373..71d68859 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -372,6 +372,14 @@ class ClaudemanApp { // Ralph loop/todo state per session this.ralphStates = new Map(); // Map + + // Subagent (Claude Code background agent) tracking + this.subagents = new Map(); // Map + this.subagentActivity = new Map(); // Map - recent tool calls/progress + this.activeSubagentId = null; // Currently selected subagent for detail view + this.subagentPanelVisible = false; + this.subagentWindows = new Map(); // Map + this.subagentWindowZIndex = 1000; this.ralphStatePanelCollapsed = true; // Default to collapsed // Tab alert states: Map @@ -1286,6 +1294,76 @@ class ClaudemanApp { message: data.reason || 'Claude has finished responding', }); }); + + // ========== Subagent Events (Claude Code Background Agents) ========== + + this.eventSource.addEventListener('subagent:discovered', (e) => { + const data = JSON.parse(e.data); + this.subagents.set(data.agentId, data); + this.subagentActivity.set(data.agentId, []); + this.renderSubagentPanel(); + // Auto-open window for new active agents + if (data.status === 'active') { + this.openSubagentWindow(data.agentId); + } + }); + + this.eventSource.addEventListener('subagent:tool_call', (e) => { + const data = JSON.parse(e.data); + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'tool', ...data }); + if (activity.length > 100) activity.shift(); // Keep last 100 entries + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + this.renderSubagentPanel(); + // Update floating window + if (this.subagentWindows.has(data.agentId)) { + this.renderSubagentWindowContent(data.agentId); + } + }); + + this.eventSource.addEventListener('subagent:progress', (e) => { + const data = JSON.parse(e.data); + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'progress', ...data }); + if (activity.length > 100) activity.shift(); + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + // Update floating window + if (this.subagentWindows.has(data.agentId)) { + this.renderSubagentWindowContent(data.agentId); + } + }); + + this.eventSource.addEventListener('subagent:message', (e) => { + const data = JSON.parse(e.data); + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'message', ...data }); + if (activity.length > 100) activity.shift(); + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + // Update floating window + if (this.subagentWindows.has(data.agentId)) { + this.renderSubagentWindowContent(data.agentId); + } + }); + + this.eventSource.addEventListener('subagent:completed', (e) => { + const data = JSON.parse(e.data); + const existing = this.subagents.get(data.agentId); + if (existing) { + existing.status = 'completed'; + this.subagents.set(data.agentId, existing); + } + this.renderSubagentPanel(); + this.updateSubagentWindows(); + }); } setConnectionStatus(status) { @@ -1339,6 +1417,15 @@ class ClaudemanApp { this.updateCost(); this.renderSessionTabs(); + // Load subagents + if (data.subagents) { + this.subagents.clear(); + data.subagents.forEach(s => { + this.subagents.set(s.agentId, s); + }); + this.renderSubagentPanel(); + } + // Auto-select first session if any if (this.sessions.size > 0 && !this.activeSessionId) { const firstSession = this.sessions.values().next().value; @@ -3714,6 +3801,396 @@ class ClaudemanApp { return this.getRalphTaskIcon(status); } + // ========== Subagent Panel (Claude Code Background Agents) ========== + + toggleSubagentPanel() { + this.subagentPanelVisible = !this.subagentPanelVisible; + const panel = this.$('subagentPanel'); + if (panel) { + panel.style.display = this.subagentPanelVisible ? 'block' : 'none'; + } + if (this.subagentPanelVisible) { + this.renderSubagentPanel(); + } + } + + renderSubagentPanel() { + const panel = this.$('subagentPanel'); + const list = this.$('subagentList'); + const badge = this.$('subagentBadge'); + + if (!list) return; + + // Update badge count + const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active').length; + if (badge) { + badge.textContent = activeCount > 0 ? activeCount : ''; + badge.style.display = activeCount > 0 ? 'inline-block' : 'none'; + } + + // If panel is not visible and there are active subagents, show indicator + if (!this.subagentPanelVisible && activeCount === 0) { + return; + } + + // Render subagent list + if (this.subagents.size === 0) { + list.innerHTML = '
No background agents detected
'; + return; + } + + const html = []; + const sorted = Array.from(this.subagents.values()).sort((a, b) => { + // Active first, then by last activity + if (a.status === 'active' && b.status !== 'active') return -1; + if (b.status === 'active' && a.status !== 'active') return 1; + return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(); + }); + + for (const agent of sorted) { + const isActive = this.activeSubagentId === agent.agentId; + const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; + const activity = this.subagentActivity.get(agent.agentId) || []; + const lastActivity = activity[activity.length - 1]; + const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; + const hasWindow = this.subagentWindows.has(agent.agentId); + + html.push(` +
+
+ 🤖 + ${agent.agentId.substring(0, 7)} + ${agent.status} + +
+
+ ${agent.toolCallCount} tools + ${lastTool ? `${this.getToolIcon(lastTool)} ${lastTool}` : ''} +
+
+ `); + } + + list.innerHTML = html.join(''); + } + + selectSubagent(agentId) { + this.activeSubagentId = agentId; + this.renderSubagentPanel(); + this.renderSubagentDetail(); + } + + renderSubagentDetail() { + const detail = this.$('subagentDetail'); + if (!detail) return; + + if (!this.activeSubagentId) { + detail.innerHTML = '
Select an agent to view details
'; + return; + } + + const agent = this.subagents.get(this.activeSubagentId); + const activity = this.subagentActivity.get(this.activeSubagentId) || []; + + if (!agent) { + detail.innerHTML = '
Agent not found
'; + return; + } + + const activityHtml = activity.slice(-30).map(a => { + const time = new Date(a.timestamp).toLocaleTimeString(); + if (a.type === 'tool') { + return `
+ ${time} + ${this.getToolIcon(a.tool)} + ${a.tool} + ${this.getToolDetail(a.tool, a.input)} +
`; + } else if (a.type === 'progress') { + const icon = a.progressType === 'query_update' ? '⟳' : '✓'; + return `
+ ${time} + ${icon} + ${a.query || a.progressType} +
`; + } else if (a.type === 'message') { + const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text; + return `
+ ${time} + 💬 + ${this.escapeHtml(preview)} +
`; + } + return ''; + }).join(''); + + detail.innerHTML = ` +
+ Agent ${agent.agentId} + ${agent.status} + +
+
+ Tools: ${agent.toolCallCount} + Entries: ${agent.entryCount} + Size: ${(agent.fileSize / 1024).toFixed(1)}KB +
+
+ ${activityHtml || '
No activity yet
'} +
+ `; + } + + getToolIcon(tool) { + const icons = { + WebSearch: '🔍', + WebFetch: '🌐', + Read: '📖', + Write: '📝', + Edit: '✏️', + Bash: '💻', + Glob: '📁', + Grep: '🔎', + Task: '🤖', + }; + return icons[tool] || '🔧'; + } + + getToolDetail(tool, input) { + if (!input) return ''; + if (tool === 'WebSearch' && input.query) return `"${input.query}"`; + if (tool === 'WebFetch' && input.url) return input.url; + if (tool === 'Read' && input.file_path) return input.file_path; + if ((tool === 'Write' || tool === 'Edit') && input.file_path) return input.file_path; + if (tool === 'Bash' && input.command) { + const cmd = input.command; + return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd; + } + if (tool === 'Glob' && input.pattern) return input.pattern; + if (tool === 'Grep' && input.pattern) return input.pattern; + return ''; + } + + async viewSubagentTranscript(agentId) { + try { + const res = await fetch(`/api/subagents/${agentId}/transcript?format=formatted`); + const data = await res.json(); + + if (!data.success) { + alert('Failed to load transcript'); + return; + } + + // Show in a modal or new window + const content = data.data.formatted.join('\n'); + const win = window.open('', '_blank', 'width=800,height=600'); + win.document.write(` + + + Subagent ${agentId} Transcript + + + +

Subagent ${agentId} Transcript (${data.data.entryCount} entries)

+
${this.escapeHtml(content)}
+ + + `); + } catch (err) { + console.error('Failed to load transcript:', err); + alert('Failed to load transcript: ' + err.message); + } + } + + // ========== Subagent Floating Windows ========== + + openSubagentWindow(agentId) { + // If window already exists, focus it + if (this.subagentWindows.has(agentId)) { + const existing = this.subagentWindows.get(agentId); + existing.element.style.zIndex = ++this.subagentWindowZIndex; + return; + } + + const agent = this.subagents.get(agentId); + if (!agent) return; + + // Calculate position (cascade from top-left) + const windowCount = this.subagentWindows.size; + const offsetX = 50 + (windowCount % 5) * 30; + const offsetY = 100 + (windowCount % 5) * 30; + + // Create window element + const win = document.createElement('div'); + win.className = 'subagent-window'; + win.id = `subagent-window-${agentId}`; + win.style.left = `${offsetX}px`; + win.style.top = `${offsetY}px`; + win.style.zIndex = ++this.subagentWindowZIndex; + + win.innerHTML = ` +
+
+ 🤖 + ${agentId.substring(0, 7)} + ${agent.status} +
+
+ + +
+
+
+
Loading activity...
+
+ `; + + document.body.appendChild(win); + + // Make draggable + this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + + // Store reference + this.subagentWindows.set(agentId, { + element: win, + minimized: false, + }); + + // Render content + this.renderSubagentWindowContent(agentId); + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.subagentWindowZIndex; + }); + } + + closeSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + windowData.element.remove(); + this.subagentWindows.delete(agentId); + } + } + + minimizeSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + windowData.element.style.display = 'none'; + windowData.minimized = true; + } + } + + restoreSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + windowData.element.style.display = 'flex'; + windowData.element.style.zIndex = ++this.subagentWindowZIndex; + windowData.minimized = false; + } + } + + makeWindowDraggable(win, handle) { + let isDragging = false; + let startX, startY, startLeft, startTop; + + handle.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'BUTTON') return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + startLeft = parseInt(win.style.left) || 0; + startTop = parseInt(win.style.top) || 0; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + win.style.left = `${startLeft + dx}px`; + win.style.top = `${startTop + dy}px`; + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); + } + + renderSubagentWindowContent(agentId) { + const body = document.getElementById(`subagent-window-body-${agentId}`); + if (!body) return; + + const activity = this.subagentActivity.get(agentId) || []; + + if (activity.length === 0) { + body.innerHTML = '
No activity yet
'; + return; + } + + const html = activity.slice(-100).map(a => { + const time = new Date(a.timestamp).toLocaleTimeString(); + if (a.type === 'tool') { + return `
+ ${time} + ${this.getToolIcon(a.tool)} + ${a.tool} + ${this.escapeHtml(this.getToolDetail(a.tool, a.input))} +
`; + } else if (a.type === 'progress') { + const icon = a.progressType === 'query_update' ? '⟳' : '✓'; + return `
+ ${time} + ${icon} + ${this.escapeHtml(a.query || a.progressType)} +
`; + } else if (a.type === 'message') { + const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; + return `
+ ${time} 💬 ${this.escapeHtml(preview)} +
`; + } + return ''; + }).join(''); + + body.innerHTML = html; + body.scrollTop = body.scrollHeight; + } + + // Update all open subagent windows + updateSubagentWindows() { + for (const agentId of this.subagentWindows.keys()) { + this.renderSubagentWindowContent(agentId); + + // Update status in header + const agent = this.subagents.get(agentId); + if (agent) { + const statusEl = document.querySelector(`#subagent-window-${agentId} .status`); + if (statusEl) { + statusEl.className = `status ${agent.status}`; + statusEl.textContent = agent.status; + } + } + } + } + + // Open windows for all active subagents + openAllActiveSubagentWindows() { + for (const [agentId, agent] of this.subagents) { + if (agent.status === 'active' && !this.subagentWindows.has(agentId)) { + this.openSubagentWindow(agentId); + } + } + } + // ========== Screen Sessions (in Monitor Panel) ========== async loadScreens() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 55685af9..4c5e0896 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -272,6 +272,22 @@
+
+
+ Subagents + +
+
+
+
+
No background agents detected
+
+
+
Select an agent to view details
+
+
+
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 21f47675..2e047dcf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2997,3 +2997,322 @@ kbd { border-radius: 8px 8px 0 0; } } + +/* ========== Subagent Panel (Claude Code Background Agents) ========== */ + +.subagent-container { + display: flex; + gap: 0.5rem; + height: 200px; +} + +.subagent-list { + flex: 0 0 200px; + overflow-y: auto; + border-right: 1px solid var(--border); + padding-right: 0.5rem; +} + +.subagent-detail { + flex: 1; + overflow-y: auto; + padding-left: 0.5rem; +} + +.subagent-empty { + color: var(--text-muted); + font-size: 0.75rem; + text-align: center; + padding: 1rem; +} + +.subagent-badge { + background: var(--accent); + color: white; + font-size: 0.65rem; + padding: 0.1rem 0.4rem; + border-radius: 8px; + margin-left: 0.5rem; +} + +.subagent-item { + padding: 0.5rem; + border-radius: 6px; + background: var(--bg-elevated); + margin-bottom: 0.4rem; + cursor: pointer; + transition: background 0.15s; +} + +.subagent-item:hover { + background: var(--bg-card); +} + +.subagent-item.selected { + background: var(--bg-card); + border: 1px solid var(--accent); +} + +.subagent-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.3rem; +} + +.subagent-icon { + font-size: 0.9rem; +} + +.subagent-id { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text); +} + +.subagent-status { + font-size: 0.6rem; + padding: 0.1rem 0.3rem; + border-radius: 4px; + margin-left: auto; +} + +.subagent-status.active { + background: var(--success); + color: white; +} + +.subagent-status.idle { + background: var(--warning); + color: black; +} + +.subagent-status.completed { + background: var(--text-muted); + color: white; +} + +.subagent-window-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.1rem 0.3rem; + font-size: 0.75rem; + margin-left: auto; +} + +.subagent-window-btn:hover { + color: var(--accent); +} + +.subagent-meta { + display: flex; + gap: 0.5rem; + font-size: 0.65rem; + color: var(--text-muted); +} + +.subagent-last-tool { + color: var(--accent); +} + +/* Subagent Detail */ + +.subagent-detail-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.subagent-detail-header .subagent-id { + font-size: 0.85rem; + font-weight: 600; +} + +.subagent-transcript-btn { + margin-left: auto; + font-size: 0.65rem; + padding: 0.2rem 0.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + cursor: pointer; +} + +.subagent-transcript-btn:hover { + background: var(--bg-elevated); +} + +.subagent-detail-stats { + display: flex; + gap: 1rem; + font-size: 0.7rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.subagent-activity-log { + font-family: var(--font-mono); + font-size: 0.7rem; + max-height: 150px; + overflow-y: auto; +} + +.subagent-activity { + display: flex; + gap: 0.4rem; + padding: 0.2rem 0; + border-bottom: 1px solid var(--border-subtle); +} + +.subagent-activity .time { + color: var(--text-muted); + flex: 0 0 60px; +} + +.subagent-activity .icon { + flex: 0 0 20px; +} + +.subagent-activity .name { + color: var(--accent); + flex: 0 0 80px; +} + +.subagent-activity .detail { + color: var(--text-dim); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subagent-activity.tool .icon { + color: var(--accent); +} + +.subagent-activity.progress .icon { + color: var(--warning); +} + +.subagent-activity.message .icon { + color: var(--text); +} + +/* ========== Subagent Windows (Floating) ========== */ + +.subagent-window { + position: fixed; + width: 500px; + height: 400px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.subagent-window-header { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + cursor: move; +} + +.subagent-window-title { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; +} + +.subagent-window-title .icon { + font-size: 1rem; +} + +.subagent-window-title .id { + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.subagent-window-title .status { + font-size: 0.6rem; + padding: 0.1rem 0.3rem; + border-radius: 4px; +} + +.subagent-window-title .status.active { + background: var(--success); + color: white; +} + +.subagent-window-actions { + display: flex; + gap: 0.3rem; +} + +.subagent-window-actions button { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.2rem 0.4rem; + font-size: 0.9rem; +} + +.subagent-window-actions button:hover { + color: var(--text); +} + +.subagent-window-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.subagent-window-body .activity-line { + display: flex; + gap: 0.4rem; + padding: 0.15rem 0; +} + +.subagent-window-body .activity-line .time { + color: var(--text-muted); + flex: 0 0 70px; +} + +.subagent-window-body .activity-line .tool-icon { + flex: 0 0 20px; +} + +.subagent-window-body .activity-line .tool-name { + color: var(--accent); + flex: 0 0 90px; +} + +.subagent-window-body .activity-line .tool-detail { + color: var(--text-dim); + flex: 1; +} + +.subagent-window-body .message-line { + padding: 0.3rem 0; + color: var(--text); +} + +.subagent-window-body .progress-line { + color: var(--warning); + font-style: italic; +} diff --git a/src/web/server.ts b/src/web/server.ts index 36796825..10031d88 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -27,6 +27,7 @@ import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { writeHooksConfig } from '../hooks-config.js'; +import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage } from '../subagent-watcher.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; @@ -288,6 +289,39 @@ export class WebServer extends EventEmitter { // Initialize spawn orchestrator this.spawnOrchestrator = new SpawnOrchestrator(); this.setupSpawnOrchestratorListeners(); + + // Set up subagent watcher listeners + this.setupSubagentWatcherListeners(); + } + + /** + * Set up event listeners for subagent watcher. + * Broadcasts real-time subagent activity to SSE clients. + */ + private setupSubagentWatcherListeners(): void { + subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => { + this.broadcast('subagent:discovered', info); + }); + + subagentWatcher.on('subagent:tool_call', (data: SubagentToolCall) => { + this.broadcast('subagent:tool_call', data); + }); + + subagentWatcher.on('subagent:progress', (data: SubagentProgress) => { + this.broadcast('subagent:progress', data); + }); + + subagentWatcher.on('subagent:message', (data: SubagentMessage) => { + this.broadcast('subagent:message', data); + }); + + subagentWatcher.on('subagent:completed', (info: SubagentInfo) => { + this.broadcast('subagent:completed', info); + }); + + subagentWatcher.on('subagent:error', (error: Error, agentId?: string) => { + console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message); + }); } private async setupRoutes(): Promise { @@ -1554,6 +1588,53 @@ export class WebServer extends EventEmitter { return { success: true, data: { agentId } }; }); + // ========== Subagent Monitoring (Claude Code Background Agents) ========== + + // List all known subagents + this.app.get('/api/subagents', async (req) => { + const { minutes } = req.query as { minutes?: string }; + const subagents = minutes + ? subagentWatcher.getRecentSubagents(parseInt(minutes, 10)) + : subagentWatcher.getSubagents(); + return { success: true, data: subagents }; + }); + + // Get subagents for a specific session (by working directory) + this.app.get('/api/sessions/:id/subagents', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`); + } + const subagents = subagentWatcher.getSubagentsForSession(session.workingDir); + return { success: true, data: subagents }; + }); + + // Get a specific subagent's info + this.app.get('/api/subagents/:agentId', async (req) => { + const { agentId } = req.params as { agentId: string }; + const info = subagentWatcher.getSubagent(agentId); + if (!info) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`); + } + return { success: true, data: info }; + }); + + // Get a subagent's transcript + this.app.get('/api/subagents/:agentId/transcript', async (req) => { + const { agentId } = req.params as { agentId: string }; + const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' }; + const limitNum = limit ? parseInt(limit, 10) : undefined; + const transcript = await subagentWatcher.getTranscript(agentId, limitNum); + + if (format === 'formatted') { + const formatted = subagentWatcher.formatTranscript(transcript); + return { success: true, data: { formatted, entryCount: transcript.length } }; + } + + return { success: true, data: transcript }; + }); + // ========== Hook Events ========== this.app.post('/api/hook-event', async (req) => { @@ -2314,6 +2395,7 @@ export class WebServer extends EventEmitter { scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), + subagents: subagentWatcher.getRecentSubagents(60), // Last hour of subagent activity timestamp: Date.now(), }; } @@ -2520,6 +2602,10 @@ export class WebServer extends EventEmitter { // Restore screen sessions from previous run await this.restoreScreenSessions(); + + // Start subagent watcher for Claude Code background agent visibility + subagentWatcher.start(); + console.log('Subagent watcher started - monitoring ~/.claude/projects for background agent activity'); } private async restoreScreenSessions(): Promise { @@ -2719,6 +2805,9 @@ export class WebServer extends EventEmitter { // Flush state store to prevent data loss from debounced saves this.store.flushAll(); + // Stop subagent watcher + subagentWatcher.stop(); + await this.app.close(); } }