fix: correct Task tool description extraction for subagent window titles

- Add parseTaskDescriptionsFromTerminalData() to parse raw terminal data in
  interactive sessions (not just one-shot mode)
- Strip ANSI codes before regex matching since terminal output has embedded
  codes like [1mExplore[0m
- Fix getTaskDescriptionForSubagent() to check ALL sessions with matching
  project hash (not just the first one found) since multiple sessions can
  share the same working directory
- Add tool_result event forwarding for subagent SSE broadcasts

The pattern Explore(Description) in terminal output is now correctly parsed
and used as the subagent window title instead of extracting from the prompt.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 10:42:04 +01:00
co-authored by Claude Opus 4.5
parent f06240f4ef
commit fc940e153b
5 changed files with 519 additions and 54 deletions
+112
View File
@@ -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<number, string> = new Map(); // timestamp -> description
private static readonly TASK_DESCRIPTION_MAX_AGE_MS = 30000; // Keep descriptions for 30 seconds
constructor(config: Partial<SessionConfig> & {
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 {
+168 -18
View File
@@ -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<string, unknown>;
toolUseId?: string; // For linking to tool_result
fullInput: Record<string, unknown>; // 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<string, unknown>;
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<string>();
// Map of agentId -> Map of toolUseId -> tool name (for linking tool_result to tool_call)
private pendingToolCalls = new Map<string, Map<string, string>>();
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<string, unknown>): Record<string, unknown> {
const truncated: Record<string, unknown> = {};
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;
}
/**
+133 -10
View File
@@ -437,6 +437,7 @@ class ClaudemanApp {
// 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.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
this.activeSubagentId = null; // Currently selected subagent for detail view
this.subagentPanelVisible = false;
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
@@ -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
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
: '';
const displayName = agent.description || agent.agentId.substring(0, 7);
html.push(`
@@ -5061,6 +5088,7 @@ class ClaudemanApp {
<div class="subagent-header">
<span class="subagent-icon">🤖</span>
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)}</span>
${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${agent.agentId}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${agent.agentId}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
@@ -5104,18 +5132,36 @@ class ClaudemanApp {
const activityHtml = activity.slice(-30).map(a => {
const time = new Date(a.timestamp).toLocaleTimeString();
if (a.type === 'tool') {
return `<div class="subagent-activity tool">
const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId);
return `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
<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>
<span class="detail">${toolDetail.primary}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${a.toolUseId}')">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${this.escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'progress') {
const icon = a.progressType === 'query_update' ? '⟳' : '✓';
return `<div class="subagent-activity progress">
} 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 `<div class="subagent-activity tool-result ${statusClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="detail">${a.query || a.progressType}</span>
<span class="name">${a.tool || 'result'}</span>
<span class="detail">${this.escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} 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 `<div class="subagent-activity progress${hookClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="detail">${displayText}</span>
</div>`;
} 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
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
: '';
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
: '';
detail.innerHTML = `
<div class="subagent-detail-header">
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${agent.agentId}')">
View Full Transcript
@@ -5141,6 +5195,7 @@ class ClaudemanApp {
<span>Tools: ${agent.toolCallCount}</span>
<span>Entries: ${agent.entryCount}</span>
<span>Size: ${(agent.fileSize / 1024).toFixed(1)}KB</span>
${tokenStats}
</div>
<div class="subagent-activity-log">
${activityHtml || '<div class="subagent-empty">No activity yet</div>'}
@@ -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
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
: '';
win.innerHTML = `
<div class="subagent-window-header">
<div class="subagent-window-title" title="${this.escapeHtml(agent.description || agentId)}">
<span class="icon">🤖</span>
<span class="id">${this.escapeHtml(truncatedTitle)}</span>
${modelBadge}
<span class="status ${agent.status}">${agent.status}</span>
</div>
<div class="subagent-window-actions">
@@ -5700,12 +5794,26 @@ class ClaudemanApp {
<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">
} 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 `<div class="activity-line result-line${statusClass}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-detail">${this.escapeHtml(a.query || a.progressType)}</span>
<span class="tool-name">${a.tool || '→'}</span>
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} 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 `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
</div>`;
} 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) {
+92
View File
@@ -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 {
+14 -26
View File
@@ -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;