diff --git a/src/session.ts b/src/session.ts index 6b51d2bd..36070d3f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -69,6 +69,11 @@ const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; // - Single-char escapes: ESC = or ESC > const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; +// Pattern to match Task tool invocations in terminal output +// Matches: "Explore(Description)", "Task(Description)", "Bash(Description)", etc. +// The prefix characters vary (●, ·, ✶, etc.) so we don't require them +// We look for the tool name followed by (description) +const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)/g; // ============================================================================ // Claude CLI PATH Resolution @@ -425,6 +430,11 @@ export class Session extends EventEmitter { toolsUpdate: (tools: ActiveBashTool[]) => void; } | null = null; + // Task descriptions parsed from terminal output (e.g., "Explore(Description)") + // Used to correlate with SubagentWatcher discoveries for better window titles + private _recentTaskDescriptions: Map = new Map(); // timestamp -> description + private static readonly TASK_DESCRIPTION_MAX_AGE_MS = 30000; // Keep descriptions for 30 seconds + constructor(config: Partial & { workingDir: string; mode?: SessionMode; @@ -893,6 +903,10 @@ export class Session extends EventEmitter { // Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens") this.parseTokensFromStatusLine(data); + // Parse task descriptions from terminal output (e.g., "Explore(Check files)") + // This enables correlating subagent windows with their short descriptions + this.parseTaskDescriptionsFromTerminalData(data); + // Detect if Claude is working or at prompt // The prompt line contains "❯" when waiting for input if (data.includes('❯') || data.includes('\u276f')) { @@ -1305,10 +1319,108 @@ export class Session extends EventEmitter { } else if (trimmed) { this._textOutput.append(line + '\n'); } + + // Parse task descriptions from terminal output (e.g., "Explore(Description)") + // This captures the short description from Claude Code's Task tool output + this.parseTaskDescriptionsFromLine(cleanLine); } // Note: BufferAccumulator auto-trims when max size exceeded } + /** + * Parse task descriptions from raw terminal data (may contain multiple lines). + * Called from interactive mode's onData handler. + */ + private parseTaskDescriptionsFromTerminalData(data: string): void { + // Quick pre-check: skip if no parentheses present + if (!data.includes('(') || !data.includes(')')) return; + + // Split by newlines and process each line + const lines = data.split(/\r?\n/); + for (const line of lines) { + this.parseTaskDescriptionsFromLine(line); + } + } + + /** + * Parse task descriptions from terminal output. + * Claude Code outputs Task tool calls as "ToolName(Description)" in the terminal. + * We capture these descriptions to use as window titles for subagents. + */ + private parseTaskDescriptionsFromLine(line: string): void { + // Quick pre-check: skip expensive regex if no common tool patterns present + if (!line.includes('(') || !line.includes(')')) return; + + // Strip ANSI codes before matching - terminal output has embedded codes like [1mExplore[0m + const cleanLine = line.replace(ANSI_ESCAPE_PATTERN, ''); + + // Reset regex lastIndex for global pattern + TASK_TOOL_PATTERN.lastIndex = 0; + + let match; + while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) { + const description = match[2].trim(); + if (description && description.length > 0) { + const now = Date.now(); + this._recentTaskDescriptions.set(now, description); + + // Cleanup old entries + this.cleanupOldTaskDescriptions(); + } + } + } + + /** + * Remove task descriptions older than TASK_DESCRIPTION_MAX_AGE_MS. + */ + private cleanupOldTaskDescriptions(): void { + const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS; + for (const [timestamp] of this._recentTaskDescriptions) { + if (timestamp < cutoff) { + this._recentTaskDescriptions.delete(timestamp); + } + } + } + + /** + * Get recent task descriptions parsed from terminal output. + * Returns descriptions sorted by timestamp (most recent first). + */ + getRecentTaskDescriptions(): Array<{ timestamp: number; description: string }> { + this.cleanupOldTaskDescriptions(); + const results: Array<{ timestamp: number; description: string }> = []; + for (const [timestamp, description] of this._recentTaskDescriptions) { + results.push({ timestamp, description }); + } + return results.sort((a, b) => b.timestamp - a.timestamp); + } + + /** + * Find a task description that was parsed close to a given timestamp. + * Used to correlate with SubagentWatcher discoveries. + * + * @param subagentStartTime - The timestamp when the subagent was discovered + * @param maxAgeMs - Maximum age difference to consider (default 10 seconds) + * @returns The matching description or undefined + */ + findTaskDescriptionNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined { + this.cleanupOldTaskDescriptions(); + + // Find the most recent description that was parsed before or around the subagent start time + let bestMatch: { timestamp: number; description: string } | undefined; + let bestDiff = Infinity; + + for (const [timestamp, description] of this._recentTaskDescriptions) { + const diff = Math.abs(subagentStartTime - timestamp); + if (diff < maxAgeMs && diff < bestDiff) { + bestMatch = { timestamp, description }; + bestDiff = diff; + } + } + + return bestMatch?.description; + } + // Parse token count from Claude's status line in interactive mode // Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens" private parseTokensFromStatusLine(data: string): void { diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index e44cc552..57abe934 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -27,6 +27,10 @@ export interface SubagentInfo { entryCount: number; fileSize: number; description?: string; // Task description from first user message + model?: string; // Full model name (e.g., "claude-sonnet-4-20250514") + modelShort?: 'haiku' | 'sonnet' | 'opus'; // Short model identifier + totalInputTokens?: number; // Running total of input tokens + totalOutputTokens?: number; // Running total of output tokens } export interface SubagentToolCall { @@ -35,6 +39,8 @@ export interface SubagentToolCall { timestamp: string; tool: string; input: Record; + toolUseId?: string; // For linking to tool_result + fullInput: Record; // Complete input object (input is truncated for display) } export interface SubagentProgress { @@ -44,6 +50,8 @@ export interface SubagentProgress { progressType: 'query_update' | 'search_results_received' | string; query?: string; resultCount?: number; + hookEvent?: string; // e.g., "PostToolUse" + hookName?: string; // e.g., "PostToolUse:Read" } export interface SubagentMessage { @@ -61,25 +69,48 @@ export interface SubagentTranscriptEntry { sessionId: string; message?: { role: string; + model?: string; // Model used for this message (e.g., "claude-sonnet-4-20250514") + usage?: { + input_tokens?: number; + output_tokens?: number; + }; content: string | Array<{ type: 'text' | 'tool_use' | 'tool_result'; text?: string; name?: string; + id?: string; // tool_use_id for tool_use blocks + tool_use_id?: string; // tool_use_id for tool_result blocks input?: Record; - content?: string; + content?: string | Array<{ type: string; text?: string }>; // tool_result content + is_error?: boolean; // For tool_result errors }>; }; data?: { type: string; query?: string; resultCount?: number; + hookEvent?: string; // e.g., "PostToolUse" + hookName?: string; // e.g., "PostToolUse:Read" + tool_name?: string; // Tool name for hook events }; } +export interface SubagentToolResult { + agentId: string; + sessionId: string; + timestamp: string; + toolUseId: string; + tool?: string; // Tool name (looked up from pending) + preview: string; // First 500 chars of result + contentLength: number; // Total length of result + isError: boolean; // Whether result is an error +} + export interface SubagentEvents { 'subagent:discovered': (info: SubagentInfo) => void; 'subagent:updated': (info: SubagentInfo) => void; 'subagent:tool_call': (data: SubagentToolCall) => void; + 'subagent:tool_result': (data: SubagentToolResult) => void; 'subagent:progress': (data: SubagentProgress) => void; 'subagent:message': (data: SubagentMessage) => void; 'subagent:completed': (info: SubagentInfo) => void; @@ -105,11 +136,24 @@ export class SubagentWatcher extends EventEmitter { private livenessInterval: NodeJS.Timeout | null = null; private _isRunning = false; private knownSubagentDirs = new Set(); + // Map of agentId -> Map of toolUseId -> tool name (for linking tool_result to tool_call) + private pendingToolCalls = new Map>(); constructor() { super(); } + /** + * Extract short model identifier from full model name + */ + private extractModelShort(model: string): 'haiku' | 'sonnet' | 'opus' | undefined { + const lower = model.toLowerCase(); + if (lower.includes('haiku')) return 'haiku'; + if (lower.includes('sonnet')) return 'sonnet'; + if (lower.includes('opus')) return 'opus'; + return undefined; + } + // ========== Public API ========== /** @@ -787,8 +831,26 @@ export class SubagentWatcher extends EventEmitter { * Process a transcript entry and emit appropriate events */ private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void { - // Check if this is first user message and description is missing const info = this.agentInfo.get(agentId); + + // Extract model from assistant messages (first one sets the model) + if (info && entry.type === 'assistant' && entry.message?.model && !info.model) { + info.model = entry.message.model; + info.modelShort = this.extractModelShort(entry.message.model); + this.emit('subagent:updated', info); + } + + // Aggregate token usage from messages + if (info && entry.message?.usage) { + if (entry.message.usage.input_tokens) { + info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens; + } + if (entry.message.usage.output_tokens) { + info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens; + } + } + + // Check if this is first user message and description is missing if (info && !info.description && entry.type === 'user' && entry.message?.content) { let text: string | undefined; if (typeof entry.message.content === 'string') { @@ -813,6 +875,11 @@ export class SubagentWatcher extends EventEmitter { progressType: entry.data.type, query: entry.data.query, resultCount: entry.data.resultCount, + // Extract hook event info if present + hookEvent: entry.data.hookEvent, + hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name + ? `${entry.data.hookEvent}:${entry.data.tool_name}` + : undefined), }; this.emit('subagent:progress', progress); } else if (entry.type === 'assistant' && entry.message?.content) { @@ -832,12 +899,22 @@ export class SubagentWatcher extends EventEmitter { } else { for (const content of entry.message.content) { if (content.type === 'tool_use' && content.name) { + // Store toolUseId for linking to results + if (content.id) { + if (!this.pendingToolCalls.has(agentId)) { + this.pendingToolCalls.set(agentId, new Map()); + } + this.pendingToolCalls.get(agentId)!.set(content.id, content.name); + } + const toolCall: SubagentToolCall = { agentId, sessionId, timestamp: entry.timestamp, tool: content.name, - input: content.input || {}, + input: this.getTruncatedInput(content.name, content.input || {}), + toolUseId: content.id, + fullInput: content.input || {}, }; this.emit('subagent:tool_call', toolCall); @@ -846,6 +923,22 @@ export class SubagentWatcher extends EventEmitter { if (agentInfo) { agentInfo.toolCallCount++; } + } else if (content.type === 'tool_result' && content.tool_use_id) { + // Extract tool result + const resultContent = this.extractToolResultContent(content.content); + const toolName = this.pendingToolCalls.get(agentId)?.get(content.tool_use_id); + + const toolResult: SubagentToolResult = { + agentId, + sessionId, + timestamp: entry.timestamp, + toolUseId: content.tool_use_id, + tool: toolName, + preview: resultContent.substring(0, 500), + contentLength: resultContent.length, + isError: content.is_error || false, + }; + this.emit('subagent:tool_result', toolResult); } else if (content.type === 'text' && content.text) { const text = content.text.trim(); if (text.length > 0) { @@ -862,27 +955,84 @@ export class SubagentWatcher extends EventEmitter { } } } else if (entry.type === 'user' && entry.message?.content) { - // Handle both string and array content formats - let userText: string | undefined; + // Handle both string and array content formats - also check for tool_result in user messages if (typeof entry.message.content === 'string') { - userText = entry.message.content.trim(); + const userText = entry.message.content.trim(); + if (userText.length > 0 && userText.length < 500) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'user', + text: userText, + }; + this.emit('subagent:message', message); + } } else { - const firstContent = entry.message.content[0]; - if (firstContent?.type === 'text' && firstContent.text) { - userText = firstContent.text.trim(); + // Check for tool_result blocks in user messages (common pattern) + for (const content of entry.message.content) { + if (content.type === 'tool_result' && content.tool_use_id) { + const resultContent = this.extractToolResultContent(content.content); + const toolName = this.pendingToolCalls.get(agentId)?.get(content.tool_use_id); + + const toolResult: SubagentToolResult = { + agentId, + sessionId, + timestamp: entry.timestamp, + toolUseId: content.tool_use_id, + tool: toolName, + preview: resultContent.substring(0, 500), + contentLength: resultContent.length, + isError: content.is_error || false, + }; + this.emit('subagent:tool_result', toolResult); + } else if (content.type === 'text' && content.text) { + const userText = content.text.trim(); + if (userText.length > 0 && userText.length < 500) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'user', + text: userText, + }; + this.emit('subagent:message', message); + } + } } } - if (userText && userText.length > 0 && userText.length < 500) { - const message: SubagentMessage = { - agentId, - sessionId, - timestamp: entry.timestamp, - role: 'user', - text: userText, - }; - this.emit('subagent:message', message); + } + } + + /** + * Extract text content from tool_result content field + */ + private extractToolResultContent(content: string | Array<{ type: string; text?: string }> | undefined): string { + if (!content) return ''; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(c => c.type === 'text' && c.text) + .map(c => c.text) + .join('\n'); + } + return ''; + } + + /** + * Get truncated input for display (keeps primary param, truncates large content) + */ + private getTruncatedInput(_tool: string, input: Record): Record { + const truncated: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (typeof value === 'string' && value.length > 100) { + // Keep short preview of long strings + truncated[key] = value.substring(0, 100) + '...'; + } else { + truncated[key] = value; } } + return truncated; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index d924a974..1d5edc50 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -437,6 +437,7 @@ class ClaudemanApp { // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress + this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; this.subagentWindows = new Map(); // Map @@ -1652,6 +1653,29 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('subagent:tool_result', (e) => { + const data = JSON.parse(e.data); + // Store tool result by toolUseId for later lookup + if (!this.subagentToolResults.has(data.agentId)) { + this.subagentToolResults.set(data.agentId, new Map()); + } + this.subagentToolResults.get(data.agentId).set(data.toolUseId, data); + + // Add to activity stream + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'tool_result', ...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); @@ -5051,6 +5075,9 @@ class ClaudemanApp { const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; const hasWindow = this.subagentWindows.has(agent.agentId); const canKill = agent.status === 'active' || agent.status === 'idle'; + const modelBadge = agent.modelShort + ? `${agent.modelShort}` + : ''; const displayName = agent.description || agent.agentId.substring(0, 7); html.push(` @@ -5061,6 +5088,7 @@ class ClaudemanApp {
🤖 ${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} + ${modelBadge} ${agent.status} ${canKill ? `` : ''} ` : ''} + ${toolDetail.hasMore ? `` : ''}
`; - } else if (a.type === 'progress') { - const icon = a.progressType === 'query_update' ? '⟳' : '✓'; - return `
+ } else if (a.type === 'tool_result') { + const icon = a.isError ? '❌' : '📄'; + const statusClass = a.isError ? 'error' : ''; + const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; + const preview = a.preview.length > 80 ? a.preview.substring(0, 80) + '...' : a.preview; + return `
${time} ${icon} - ${a.query || a.progressType} + ${a.tool || 'result'} + ${this.escapeHtml(preview)}${sizeInfo} +
`; + } else if (a.type === 'progress') { + // Check for hook events + const isHook = a.hookEvent || a.hookName; + const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); + const hookClass = isHook ? ' hook' : ''; + const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); + return `
+ ${time} + ${icon} + ${displayText}
`; } else if (a.type === 'message') { const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text; @@ -5129,9 +5175,17 @@ class ClaudemanApp { }).join(''); const detailTitle = agent.description || `Agent ${agent.agentId}`; + const modelBadge = agent.modelShort + ? `${agent.modelShort}` + : ''; + const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens) + ? `Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑` + : ''; + detail.innerHTML = `
${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)} + ${modelBadge} ${agent.status}
${activityHtml || '
No activity yet
'} @@ -5148,6 +5203,31 @@ class ClaudemanApp { `; } + toggleToolParams(toolUseId) { + const el = document.getElementById(`tool-params-${toolUseId}`); + if (!el) return; + const btn = el.previousElementSibling; + if (el.style.display === 'none') { + el.style.display = 'block'; + if (btn) btn.textContent = '▼'; + } else { + el.style.display = 'none'; + if (btn) btn.textContent = '▶'; + } + } + + formatTokenCount(count) { + if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; + if (count >= 1000) return (count / 1000).toFixed(1) + 'k'; + return count.toString(); + } + + formatBytes(bytes) { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB'; + return bytes + 'B'; + } + getToolIcon(tool) { const icons = { WebSearch: '🔍', @@ -5178,6 +5258,16 @@ class ClaudemanApp { return ''; } + getToolDetailExpanded(tool, input, fullInput, toolUseId) { + const primary = this.getToolDetail(tool, input); + // Check if there are additional params beyond the primary one + const primaryKeys = ['query', 'url', 'file_path', 'command', 'pattern']; + const inputKeys = Object.keys(fullInput || input || {}); + const extraKeys = inputKeys.filter(k => !primaryKeys.includes(k)); + const hasMore = extraKeys.length > 0 || (fullInput && JSON.stringify(fullInput).length > 100); + return { primary, hasMore, fullInput: fullInput || input }; + } + async killSubagent(agentId) { try { const res = await fetch(`/api/subagents/${agentId}`, { method: 'DELETE' }); @@ -5449,11 +5539,15 @@ class ClaudemanApp { const windowTitle = agent.description || agentId.substring(0, 7); const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; + const modelBadge = agent.modelShort + ? `${agent.modelShort}` + : ''; win.innerHTML = `
🤖 ${this.escapeHtml(truncatedTitle)} + ${modelBadge} ${agent.status}
@@ -5700,12 +5794,26 @@ class ClaudemanApp { ${a.tool} ${this.escapeHtml(this.getToolDetail(a.tool, a.input))}
`; - } else if (a.type === 'progress') { - const icon = a.progressType === 'query_update' ? '⟳' : '✓'; - return `
+ } else if (a.type === 'tool_result') { + const icon = a.isError ? '❌' : '📄'; + const statusClass = a.isError ? ' error' : ''; + const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; + const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; + return `
${time} ${icon} - ${this.escapeHtml(a.query || a.progressType)} + ${a.tool || '→'} + ${this.escapeHtml(preview)}${sizeInfo} +
`; + } else if (a.type === 'progress') { + // Check for hook events + const isHook = a.hookEvent || a.hookName; + const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); + const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); + return `
+ ${time} + ${icon} + ${this.escapeHtml(displayText)}
`; } else if (a.type === 'message') { const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; @@ -5750,6 +5858,21 @@ class ClaudemanApp { titleContainer.title = agent.description || agentId; } + // Update or add model badge + let modelBadge = win.querySelector('.subagent-window-title .subagent-model-badge'); + if (agent.modelShort) { + if (!modelBadge) { + modelBadge = document.createElement('span'); + modelBadge.className = `subagent-model-badge ${agent.modelShort}`; + const statusEl = win.querySelector('.subagent-window-title .status'); + if (statusEl) { + statusEl.insertAdjacentElement('beforebegin', modelBadge); + } + } + modelBadge.className = `subagent-model-badge ${agent.modelShort}`; + modelBadge.textContent = agent.modelShort; + } + // Update status const statusEl = win.querySelector('.subagent-window-title .status'); if (statusEl) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a72fd8f3..537a8d3a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3971,6 +3971,98 @@ kbd { color: var(--text); } +/* Model badges */ +.subagent-model-badge { + font-size: 0.55rem; + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.subagent-model-badge.haiku { + background: #4ade80; + color: #052e16; +} + +.subagent-model-badge.sonnet { + background: #60a5fa; + color: #1e3a5f; +} + +.subagent-model-badge.opus { + background: #c084fc; + color: #3b0764; +} + +/* Hook events */ +.subagent-activity.progress.hook .icon { + color: #f59e0b; +} + +.activity-line.hook-line .tool-icon { + color: #f59e0b; +} + +/* Tool result activity */ +.subagent-activity.tool-result .icon { + color: var(--success); +} + +.subagent-activity.tool-result.error .icon { + color: var(--red); +} + +.activity-line.result-line .tool-icon { + color: var(--success); +} + +.activity-line.result-line.error .tool-icon { + color: var(--red); +} + +.activity-line.result-line .tool-detail { + color: var(--text-dim); + font-style: italic; +} + +/* Tool params expansion */ +.tool-expand-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.6rem; + padding: 0.1rem 0.3rem; + margin-left: 0.3rem; + transition: all 0.15s; +} + +.tool-expand-btn:hover { + background: var(--bg-hover); + color: var(--text); +} + +.tool-params-expanded { + background: var(--bg-dark); + border-left: 2px solid var(--accent); + margin: 0.3rem 0 0.3rem 2rem; + padding: 0.4rem 0.6rem; + border-radius: 0 4px 4px 0; + font-size: 0.65rem; + max-height: 150px; + overflow-y: auto; +} + +.tool-params-expanded pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + color: var(--text-dim); +} + /* ========== Subagent Windows (Floating) ========== */ .subagent-window { diff --git a/src/web/server.ts b/src/web/server.ts index 8bf25206..746e45e2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -28,7 +28,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 { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; @@ -339,6 +339,10 @@ export class WebServer extends EventEmitter { this.broadcast('subagent:tool_call', data); }); + subagentWatcher.on('subagent:tool_result', (data: SubagentToolResult) => { + this.broadcast('subagent:tool_result', data); + }); + subagentWatcher.on('subagent:progress', (data: SubagentProgress) => { this.broadcast('subagent:progress', data); }); @@ -370,35 +374,19 @@ export class WebServer extends EventEmitter { * matches the subagent's project hash, then checking for recent tasks. */ private getTaskDescriptionForSubagent(info: SubagentInfo): string | undefined { - // Find the session whose working directory matches this subagent's project + // Find sessions whose working directory matches this subagent's project + // Check ALL matching sessions since multiple sessions may have the same working directory + const subagentStartTime = new Date(info.startedAt).getTime(); + for (const session of this.sessions.values()) { const sessionProjectHash = subagentWatcher.getProjectHashForDir(session.workingDir); if (sessionProjectHash === info.projectHash) { - // Found the owning session - check its TaskTracker for recent tasks - // Get all tasks from the tracker - const allTasks = Array.from(session.taskTracker.getAllTasks().values()); - if (allTasks.length > 0) { - // Sort by start time descending to get most recent first - const recentTasks = allTasks.sort((a, b) => (b.startTime || 0) - (a.startTime || 0)); - - // Check if there's a task that started recently (within 5 seconds of subagent discovery) - const subagentStartTime = new Date(info.startedAt).getTime(); - for (const task of recentTasks) { - const taskStartTime = task.startTime || 0; - const timeDiff = Math.abs(subagentStartTime - taskStartTime); - // If the task started within 5 seconds of the subagent, use its description - if (timeDiff < 5000 && task.description) { - return task.description; - } - } - - // Fallback: use the most recent running task's description - const runningTask = recentTasks.find(t => t.status === 'running'); - if (runningTask?.description) { - return runningTask.description; - } + // Found a matching session - check for recent task descriptions parsed from terminal + // The Session parses "Explore(Description)" patterns from Claude Code output + const description = session.findTaskDescriptionNear(subagentStartTime, 15000); + if (description) { + return description; } - break; // Found the session, no need to continue } } return undefined;