From 32e0d6126c056492473369f2167953fbd27d7a69 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 26 Jan 2026 11:06:35 +0100 Subject: [PATCH] fix: read subagent descriptions directly from parent transcript Instead of timing-based correlation between terminal output and subagent files, now reads the Task tool's description parameter directly from the parent session's transcript. This provides 100% reliable mapping between agent IDs and their short descriptions, fixing issues where multiple agents spawned in quick succession would get the same window title. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 12 +++- package.json | 2 +- src/subagent-watcher.ts | 148 ++++++++++++++++++++++++++++------------ src/web/server.ts | 43 ++---------- 4 files changed, 120 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed6e2133..ee5a070f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1379 (must match `package.json`) +**Version**: 0.1380 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -582,6 +582,16 @@ Placeholders replaced: `./scripts/screen-manager.sh` - Interactive bash tool for managing screen sessions. Commands: `list`, `attach N`, `kill N,M`, `kill-all`, `info N`. Requires `jq` and `screen`. +## Web UI Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+Enter` | Quick-start session | +| `Ctrl+W` | Close session | +| `Ctrl+Tab` | Next session | +| `Ctrl+K` | Kill all sessions | +| `Ctrl+L` | Clear terminal | + ## Documentation - `docs/respawn-state-machine.md` - Respawn controller states, idle detection, auto-accept diff --git a/package.json b/package.json index 89291876..3fba90af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1379", + "version": "0.1380", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 57abe934..866e6de2 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -553,6 +553,73 @@ export class SubagentWatcher extends EventEmitter { return truncated.replace(/[.!?,:\s]+$/, ''); } + /** + * Extract the short description from the parent session's transcript. + * This is the most reliable method because it reads the actual Task tool call + * that spawned this agent, which contains the description parameter. + * + * The parent transcript contains: + * 1. An assistant message with tool_use for Task, containing input.description + * 2. A progress entry with data.agentId and parentToolUseID linking them + */ + private extractDescriptionFromParentTranscript( + projectHash: string, + sessionId: string, + agentId: string + ): string | undefined { + try { + // The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl + const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`); + if (!existsSync(transcriptPath)) return undefined; + + const content = readFileSync(transcriptPath, 'utf8'); + const lines = content.split('\n').filter((l) => l.trim()); + + // First pass: Find the progress entry for this agent to get parentToolUseID + let parentToolUseID: string | undefined; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'progress' && entry.data?.agentId === agentId && entry.parentToolUseID) { + parentToolUseID = entry.parentToolUseID; + break; + } + } catch { + // Skip malformed lines + } + } + + if (!parentToolUseID) return undefined; + + // Second pass: Find the Task tool_use with this ID and extract description + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'assistant' && entry.message?.content) { + const content = entry.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if ( + block.type === 'tool_use' && + block.id === parentToolUseID && + block.name === 'Task' && + block.input?.description + ) { + return block.input.description; + } + } + } + } + } catch { + // Skip malformed lines + } + } + } catch { + // Failed to read transcript + } + return undefined; + } + /** * Extract description from agent file by finding first user message */ @@ -683,41 +750,13 @@ export class SubagentWatcher extends EventEmitter { // Initial info const stat = statSync(filePath); - // Extract description from first user message in the JSONL - // The Task tool passes a "description" (short title) and "prompt" (full task) - // We want the short description, which may be in the first line or sentence - let description: string | undefined; - try { - const content = readFileSync(filePath, 'utf8'); - const lines = content.split('\n').filter((l) => l.trim()); + // Extract description - prefer reading from parent transcript (most reliable) + // The parent transcript has the exact Task tool call with description parameter + let description = this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId); - // Try each line until we find a user message with text - for (const line of lines.slice(0, 5)) { - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - // Handle both formats: content can be a string or an array of content blocks - let text: string | undefined; - if (typeof entry.message.content === 'string') { - text = entry.message.content.trim(); - } else if (Array.isArray(entry.message.content)) { - const firstContent = entry.message.content[0]; - if (firstContent?.type === 'text' && firstContent.text) { - text = firstContent.text.trim(); - } - } - - if (text) { - description = this.extractSmartTitle(text); - break; - } - } - } catch { - // Skip malformed lines - } - } - } catch { - // Failed to read description, continue without it + // Fallback: extract a smart title from the subagent's prompt if parent lookup failed + if (!description) { + description = this.extractDescriptionFromFile(filePath); } const info: SubagentInfo = { @@ -764,7 +803,16 @@ export class SubagentWatcher extends EventEmitter { // Retry description extraction if missing (race condition fix) if (!existingInfo.description) { - const extractedDescription = this.extractDescriptionFromFile(filePath); + // First try parent transcript (most reliable) + let extractedDescription = this.extractDescriptionFromParentTranscript( + existingInfo.projectHash, + existingInfo.sessionId, + agentId + ); + // Fallback to subagent file + if (!extractedDescription) { + extractedDescription = this.extractDescriptionFromFile(filePath); + } if (extractedDescription) { existingInfo.description = extractedDescription; this.emit('subagent:updated', existingInfo); @@ -852,17 +900,29 @@ export class SubagentWatcher extends EventEmitter { // 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') { - text = entry.message.content.trim(); - } else if (Array.isArray(entry.message.content)) { - const firstContent = entry.message.content[0]; - if (firstContent?.type === 'text' && firstContent.text) { - text = firstContent.text.trim(); + // First try parent transcript (most reliable) + let description = this.extractDescriptionFromParentTranscript( + info.projectHash, + info.sessionId, + agentId + ); + // Fallback: extract smart title from the prompt content + if (!description) { + let text: string | undefined; + if (typeof entry.message.content === 'string') { + text = entry.message.content.trim(); + } else if (Array.isArray(entry.message.content)) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + text = firstContent.text.trim(); + } + } + if (text) { + description = this.extractSmartTitle(text); } } - if (text) { - info.description = this.extractSmartTitle(text); + if (description) { + info.description = description; this.emit('subagent:updated', info); } } diff --git a/src/web/server.ts b/src/web/server.ts index 746e45e2..4efe2835 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -318,16 +318,13 @@ export class WebServer extends EventEmitter { /** * Set up event listeners for subagent watcher. * Broadcasts real-time subagent activity to SSE clients. + * + * The SubagentWatcher now extracts descriptions directly from the parent session's + * transcript, which contains the exact Task tool call with the description parameter. + * This is more reliable than the previous timing-based correlation approach. */ private setupSubagentWatcherListeners(): void { subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => { - // Try to get a better description from the session's TaskTracker - // The TaskTracker has access to params.description from the Task tool call - const betterDescription = this.getTaskDescriptionForSubagent(info); - if (betterDescription && betterDescription !== info.description) { - info.description = betterDescription; - subagentWatcher.updateDescription(info.agentId, betterDescription); - } this.broadcast('subagent:discovered', info); }); @@ -360,38 +357,6 @@ export class WebServer extends EventEmitter { }); } - /** - * Get the short description from TaskTracker for a discovered subagent. - * - * When Claude Code spawns a subagent via the Task tool, it passes two parameters: - * - description: Short title like "Explore Python scripts" (3-5 words) - * - prompt: Full detailed task prompt - * - * The TaskTracker parses the Task tool call and has access to params.description, - * while the SubagentWatcher only sees the JSONL file which contains the full prompt. - * - * This method correlates them by finding the session whose working directory - * matches the subagent's project hash, then checking for recent tasks. - */ - private getTaskDescriptionForSubagent(info: SubagentInfo): string | undefined { - // 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 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; - } - } - } - return undefined; - } - private async setupRoutes(): Promise { // Serve static files await this.app.register(fastifyStatic, {