mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: add real-time subagent visibility - monitor Claude Code background agents
- Add SubagentWatcher service that discovers and tails Claude Code's subagent transcript files (~/.claude/projects/.../subagents/agent-*.jsonl) - Track tool calls, progress events, and messages in real-time via file watching - Add SSE events for subagent:discovered, subagent:tool_call, subagent:progress, subagent:message, subagent:completed - Add REST API endpoints: GET /api/subagents, /api/subagents/:id, /api/subagents/:id/transcript - Add subagent panel in Monitor section showing active agents and their activity - Add floating draggable windows that auto-open for each active agent - Add standalone CLI tool: scripts/watch-subagents.ts for terminal monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <id> # Watch specific session
|
||||
* npx tsx scripts/watch-subagents.ts --agent <id> # 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<string, unknown>;
|
||||
content?: string;
|
||||
}>;
|
||||
};
|
||||
data?: {
|
||||
type: string;
|
||||
query?: string;
|
||||
resultCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
class SubagentWatcher {
|
||||
private claudeDir = join(homedir(), '.claude/projects');
|
||||
private filePositions = new Map<string, number>();
|
||||
private seenAgents = new Set<string>();
|
||||
private watchedFiles = new Set<string>();
|
||||
|
||||
// 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, unknown>): string {
|
||||
const icons: Record<string, string> = {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, number>();
|
||||
private fileWatchers = new Map<string, FSWatcher>();
|
||||
private dirWatchers = new Map<string, FSWatcher>();
|
||||
private agentInfo = new Map<string, SubagentInfo>();
|
||||
private idleTimers = new Map<string, NodeJS.Timeout>();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
private knownSubagentDirs = new Set<string>();
|
||||
|
||||
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<SubagentTranscriptEntry[]> {
|
||||
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<number> {
|
||||
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, unknown>): string {
|
||||
const icons: Record<string, string> = {
|
||||
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();
|
||||
@@ -372,6 +372,14 @@ class ClaudemanApp {
|
||||
|
||||
// Ralph loop/todo state per session
|
||||
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
|
||||
|
||||
// Subagent (Claude Code background agent) tracking
|
||||
this.subagents = new Map(); // Map<agentId, SubagentInfo>
|
||||
this.subagentActivity = new Map(); // Map<agentId, activity[]> - recent tool calls/progress
|
||||
this.activeSubagentId = null; // Currently selected subagent for detail view
|
||||
this.subagentPanelVisible = false;
|
||||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||||
this.subagentWindowZIndex = 1000;
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
@@ -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 = '<div class="subagent-empty">No background agents detected</div>';
|
||||
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(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}"
|
||||
onclick="app.selectSubagent('${agent.agentId}')">
|
||||
<div class="subagent-header">
|
||||
<span class="subagent-icon">🤖</span>
|
||||
<span class="subagent-id">${agent.agentId.substring(0, 7)}</span>
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${agent.agentId}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${hasWindow ? '✕' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="subagent-meta">
|
||||
<span class="subagent-tools">${agent.toolCallCount} tools</span>
|
||||
${lastTool ? `<span class="subagent-last-tool">${this.getToolIcon(lastTool)} ${lastTool}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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 = '<div class="subagent-empty">Select an agent to view details</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = this.subagents.get(this.activeSubagentId);
|
||||
const activity = this.subagentActivity.get(this.activeSubagentId) || [];
|
||||
|
||||
if (!agent) {
|
||||
detail.innerHTML = '<div class="subagent-empty">Agent not found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const activityHtml = activity.slice(-30).map(a => {
|
||||
const time = new Date(a.timestamp).toLocaleTimeString();
|
||||
if (a.type === 'tool') {
|
||||
return `<div class="subagent-activity tool">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="name">${a.tool}</span>
|
||||
<span class="detail">${this.getToolDetail(a.tool, a.input)}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
const icon = a.progressType === 'query_update' ? '⟳' : '✓';
|
||||
return `<div class="subagent-activity progress">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="detail">${a.query || a.progressType}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'message') {
|
||||
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
|
||||
return `<div class="subagent-activity message">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">💬</span>
|
||||
<span class="detail">${this.escapeHtml(preview)}</span>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="subagent-detail-header">
|
||||
<span class="subagent-id">Agent ${agent.agentId}</span>
|
||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${agent.agentId}')">
|
||||
View Full Transcript
|
||||
</button>
|
||||
</div>
|
||||
<div class="subagent-detail-stats">
|
||||
<span>Tools: ${agent.toolCallCount}</span>
|
||||
<span>Entries: ${agent.entryCount}</span>
|
||||
<span>Size: ${(agent.fileSize / 1024).toFixed(1)}KB</span>
|
||||
</div>
|
||||
<div class="subagent-activity-log">
|
||||
${activityHtml || '<div class="subagent-empty">No activity yet</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Subagent ${agentId} Transcript</title>
|
||||
<style>
|
||||
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Subagent ${agentId} Transcript (${data.data.entryCount} entries)</h2>
|
||||
<pre>${this.escapeHtml(content)}</pre>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} 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 = `
|
||||
<div class="subagent-window-header">
|
||||
<div class="subagent-window-title">
|
||||
<span class="icon">🤖</span>
|
||||
<span class="id">${agentId.substring(0, 7)}</span>
|
||||
<span class="status ${agent.status}">${agent.status}</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.minimizeSubagentWindow('${agentId}')" title="Minimize">─</button>
|
||||
<button onclick="app.closeSubagentWindow('${agentId}')" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagent-window-body" id="subagent-window-body-${agentId}">
|
||||
<div class="subagent-empty">Loading activity...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="subagent-empty">No activity yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = activity.slice(-100).map(a => {
|
||||
const time = new Date(a.timestamp).toLocaleTimeString();
|
||||
if (a.type === 'tool') {
|
||||
return `<div class="activity-line">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="tool-name">${a.tool}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
const icon = a.progressType === 'query_update' ? '⟳' : '✓';
|
||||
return `<div class="activity-line progress-line">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(a.query || a.progressType)}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'message') {
|
||||
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
||||
return `<div class="message-line">
|
||||
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
||||
</div>`;
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -272,6 +272,22 @@
|
||||
<div class="monitor-section-body" id="backgroundTasksBody">
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitor-section" id="subagentSection">
|
||||
<div class="monitor-section-header">
|
||||
<span>Subagents</span>
|
||||
<span class="subagent-badge" id="subagentBadge" style="display: none;"></span>
|
||||
</div>
|
||||
<div class="monitor-section-body">
|
||||
<div class="subagent-container">
|
||||
<div class="subagent-list" id="subagentList">
|
||||
<div class="subagent-empty">No background agents detected</div>
|
||||
</div>
|
||||
<div class="subagent-detail" id="subagentDetail">
|
||||
<div class="subagent-empty">Select an agent to view details</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user