From cec9608b663be7548a62599345639ae40a154314 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 17:40:28 +0100 Subject: [PATCH] ui: improve subagent window titles and readability - Extract better titles from task description (first line/sentence) - Make subagent window text white for better readability - Expand monitor panel to 700px when subagents tab is active - Add smooth width transition when switching tabs Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 25 +++- package.json | 2 +- src/subagent-watcher.ts | 188 ++++++++++++++++++++++++++++ src/web/public/app.js | 138 +++++++++++++++++---- src/web/public/index.html | 34 ++--- src/web/public/styles.css | 254 ++++++++++++++++++++++++++++++++++++-- src/web/server.ts | 24 ++++ 7 files changed, 610 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be29da70..edc31873 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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.1352 +**Version**: 0.1353 **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -222,6 +222,7 @@ claudeman reset # Reset all state | `src/spawn-orchestrator.ts` | Full agent lifecycle: spawn, monitor, budget, queue, cleanup | | `src/spawn-claude-md.ts` | Generates CLAUDE.md for spawned agent sessions | | `src/mcp-server.ts` | MCP server binary (`claudeman-mcp`) exposing spawn tools to Claude Code | +| `src/subagent-watcher.ts` | Monitors Claude Code background agents in `~/.claude/projects/*/subagents/*.jsonl` | | `src/tui/DirectAttach.ts` | Full-screen console attach with tab switching between sessions | | `scripts/claudeman-web.service` | Systemd user service for `claudeman web --https` (Restart=always) | @@ -245,6 +246,23 @@ Spawned agents are full-power Claude sessions in their own screen sessions, mana See `docs/spawn-protocol.md` for the full protocol flow, directory structure, resource governance, and MCP configuration. +### Subagent Watcher (Claude Code Background Agents) + +Monitors Claude Code's internal background agents (the `Task` tool) in real-time. Watches `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl` files and emits structured events. + +**Events**: `subagent:discovered`, `subagent:tool_call`, `subagent:progress`, `subagent:message`, `subagent:completed` + +**API**: +- `GET /api/subagents` - List all known subagents (optional `?minutes=60` for recent only) +- `GET /api/subagents/:agentId` - Get subagent info +- `GET /api/subagents/:agentId/transcript` - Get transcript (`?limit=N`, `?format=formatted`) +- `DELETE /api/subagents/:agentId` - Kill subagent process +- `GET /api/sessions/:id/subagents` - Get subagents for session's working directory + +**Status lifecycle**: `active` → `idle` (30s no activity) → `completed` (process exited or file stale) + +Implementation: `src/subagent-watcher.ts` - singleton `subagentWatcher` started on server boot. + ### Session Modes Sessions have a `mode` property (`SessionMode` type): @@ -400,7 +418,7 @@ Tab switch/new session fix: clear xterm → write buffer → resize PTY → Ctrl All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`. -Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `hook:`, `scheduled:`, `case:`, `screen:`, `init`. +Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `subagent:`, `hook:`, `scheduled:`, `case:`, `screen:`, `init`. Key events (see `app.js:handleSSEEvent()`): - `session:idle`, `session:working` - Status indicators @@ -408,6 +426,7 @@ Key events (see `app.js:handleSSEEvent()`): - `session:ralphLoopUpdate`, `session:ralphTodoUpdate`, `session:ralphCompletionDetected` - Ralph tracking - `respawn:detectionUpdate` - Idle detection status - `spawn:queued`, `spawn:started`, `spawn:completed`, `spawn:failed` - Agent lifecycle +- `subagent:discovered`, `subagent:tool_call`, `subagent:progress`, `subagent:message`, `subagent:completed` - Claude Code background agents - `hook:idle_prompt`, `hook:permission_prompt`, `hook:elicitation_dialog`, `hook:stop` - Claude Code hooks ### Frontend (app.js) @@ -501,6 +520,8 @@ All routes defined in `server.ts:buildServer()`. Key endpoint groups: - `/api/quick-start` - Create case + start session (`{mode?: 'claude'|'shell'}`) - `/api/cases`, `/api/screens` - Case and screen management - `/api/spawn/*` - Agent lifecycle (list, status, result, messages, cancel, trigger) +- `/api/subagents` - List/get/kill Claude Code background agents, get transcripts +- `/api/sessions/:id/subagents` - Get subagents for a specific session's working directory - `/api/hook-event` - Claude Code hook callbacks (`{event, sessionId, data?}`) diff --git a/package.json b/package.json index 122c7607..cb3f2090 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1352", + "version": "0.1353", "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 58282a8b..d6cec25f 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -11,6 +11,7 @@ import { createReadStream } from 'fs'; import { createInterface } from 'readline'; import { homedir } from 'os'; import { join, basename } from 'path'; +import { execSync } from 'child_process'; // ========== Types ========== @@ -25,6 +26,7 @@ export interface SubagentInfo { toolCallCount: number; entryCount: number; fileSize: number; + description?: string; // Task description from first user message } export interface SubagentToolCall { @@ -88,6 +90,7 @@ export interface SubagentEvents { 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 +const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s // ========== SubagentWatcher Class ========== @@ -98,6 +101,7 @@ export class SubagentWatcher extends EventEmitter { private agentInfo = new Map(); private idleTimers = new Map(); private pollInterval: NodeJS.Timeout | null = null; + private livenessInterval: NodeJS.Timeout | null = null; private isRunning = false; private knownSubagentDirs = new Set(); @@ -121,6 +125,56 @@ export class SubagentWatcher extends EventEmitter { this.pollInterval = setInterval(() => { this.scanForSubagents(); }, POLL_INTERVAL_MS); + + // Periodic liveness check for active subagents + this.startLivenessChecker(); + } + + /** + * Start periodic liveness checker + * Detects when subagent processes have exited but status is still active/idle + */ + private startLivenessChecker(): void { + if (this.livenessInterval) return; + + this.livenessInterval = setInterval(async () => { + for (const [agentId, info] of this.agentInfo) { + if (info.status === 'active' || info.status === 'idle') { + const alive = await this.checkSubagentAlive(agentId); + if (!alive) { + info.status = 'completed'; + this.emit('subagent:completed', info); + } + } + } + }, LIVENESS_CHECK_MS); + } + + /** + * Check if a subagent process is still running + */ + private async checkSubagentAlive(agentId: string): Promise { + const info = this.agentInfo.get(agentId); + if (!info) return false; + + // Method 1: Check if the process is still running + const pid = await this.findSubagentProcess(info.sessionId); + if (pid !== null) return true; + + // Method 2: Check if the transcript file was recently modified + // (within the last 60 seconds - gives some buffer for slow operations) + try { + const stat = statSync(info.filePath); + const mtime = stat.mtime.getTime(); + const now = Date.now(); + if (now - mtime < 60000) { + return true; + } + } catch { + // File doesn't exist or can't be read + } + + return false; } /** @@ -134,6 +188,11 @@ export class SubagentWatcher extends EventEmitter { this.pollInterval = null; } + if (this.livenessInterval) { + clearInterval(this.livenessInterval); + this.livenessInterval = null; + } + for (const watcher of this.fileWatchers.values()) { watcher.close(); } @@ -187,6 +246,88 @@ export class SubagentWatcher extends EventEmitter { ); } + /** + * Kill a subagent by its agent ID + * Finds the Claude process and sends SIGTERM + */ + async killSubagent(agentId: string): Promise { + const info = this.agentInfo.get(agentId); + if (!info) return false; + + // Already completed, nothing to kill + if (info.status === 'completed') return false; + + try { + // Find Claude process with matching session ID + const pid = await this.findSubagentProcess(info.sessionId); + if (pid) { + process.kill(pid, 'SIGTERM'); + info.status = 'completed'; + this.emit('subagent:completed', info); + return true; + } + } catch { + // Process may have already exited + } + + // Mark as completed even if we couldn't find the process + info.status = 'completed'; + this.emit('subagent:completed', info); + return true; + } + + /** + * Kill all subagents for a specific Claudeman session working directory + */ + async killSubagentsForSession(workingDir: string): Promise { + const subagents = this.getSubagentsForSession(workingDir); + for (const agent of subagents) { + if (agent.status === 'active' || agent.status === 'idle') { + await this.killSubagent(agent.agentId); + } + } + } + + /** + * Find the process ID of a Claude subagent by its session ID + * Searches /proc for claude processes with matching session ID in environment + */ + private async findSubagentProcess(sessionId: string): Promise { + try { + // Find all claude processes + const pgrepOutput = execSync('pgrep -f "claude"', { encoding: 'utf8' }); + const pids = pgrepOutput.trim().split('\n').filter(Boolean); + + for (const pidStr of pids) { + const pid = parseInt(pidStr, 10); + if (isNaN(pid)) continue; + + try { + // Check /proc/{pid}/environ for session ID + const environ = readFileSync(`/proc/${pid}/environ`, 'utf8'); + if (environ.includes(sessionId)) { + return pid; + } + } catch { + // Can't read this process's environ - skip + } + + try { + // Also check /proc/{pid}/cmdline for session ID + const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + if (cmdline.includes(sessionId)) { + return pid; + } + } catch { + // Can't read this process's cmdline - skip + } + } + } catch { + // pgrep returns non-zero if no matches + } + return null; + } + /** * Get transcript for a subagent (optionally limited to last N entries) */ @@ -351,6 +492,52 @@ 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()); + + // 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) as SubagentTranscriptEntry; + if (entry.type === 'user' && entry.message?.content) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + const text = firstContent.text.trim(); + // Extract a useful title: first line, first sentence, or first 80 chars + // Split on newline first + const firstLine = text.split('\n')[0].trim(); + // If still long, try to find a sentence boundary + let title = firstLine; + if (title.length > 80) { + // Look for sentence end (. ! ?) within first 100 chars + const sentenceEnd = title.substring(0, 100).search(/[.!?]/); + if (sentenceEnd > 10) { + title = title.substring(0, sentenceEnd + 1); + } else { + // Truncate at word boundary + const truncated = title.substring(0, 80); + const lastSpace = truncated.lastIndexOf(' '); + title = lastSpace > 40 ? truncated.substring(0, lastSpace) + '...' : truncated + '...'; + } + } + description = title; + break; + } + } + } catch { + // Skip malformed lines + } + } + } catch { + // Failed to read description, continue without it + } + const info: SubagentInfo = { agentId, sessionId, @@ -362,6 +549,7 @@ export class SubagentWatcher extends EventEmitter { toolCallCount: 0, entryCount: 0, fileSize: stat.size, + description, }; this.agentInfo.set(agentId, info); diff --git a/src/web/public/app.js b/src/web/public/app.js index 71d68859..209ed3bd 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3184,6 +3184,7 @@ class ClaudemanApp { this.closeAppSettings(); this.cancelCloseSession(); document.getElementById('monitorPanel').classList.remove('open'); + this.closeSubagentsPanel(); } // ========== Monitor Panel (combined Screen Sessions + Background Tasks) ========== @@ -3803,33 +3804,94 @@ class ClaudemanApp { // ========== 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) { + // Switch between Monitor and Subagents tabs in the monitor panel + switchMonitorTab(tabName) { + const monitorTab = document.querySelector('.monitor-tab[data-tab="monitor"]'); + const subagentsTab = document.querySelector('.monitor-tab[data-tab="subagents"]'); + const monitorContent = this.$('monitorTabContent'); + const subagentsContent = this.$('subagentsTabContent'); + const panel = this.$('monitorPanel'); + + if (tabName === 'monitor') { + monitorTab?.classList.add('active'); + subagentsTab?.classList.remove('active'); + if (monitorContent) { + monitorContent.style.display = 'flex'; + monitorContent.classList.add('active'); + } + if (subagentsContent) { + subagentsContent.style.display = 'none'; + subagentsContent.classList.remove('active'); + } + // Shrink panel back to normal width + panel?.classList.remove('subagents-active'); + this.subagentPanelVisible = false; + } else if (tabName === 'subagents') { + monitorTab?.classList.remove('active'); + subagentsTab?.classList.add('active'); + if (monitorContent) { + monitorContent.style.display = 'none'; + monitorContent.classList.remove('active'); + } + if (subagentsContent) { + subagentsContent.style.display = 'flex'; + subagentsContent.classList.add('active'); + } + // Expand panel for subagents view + panel?.classList.add('subagents-active'); + this.subagentPanelVisible = true; this.renderSubagentPanel(); } } - renderSubagentPanel() { - const panel = this.$('subagentPanel'); - const list = this.$('subagentList'); - const badge = this.$('subagentBadge'); + toggleSubagentsPanel() { + // Open monitor panel and switch to subagents tab + const panel = this.$('monitorPanel'); + if (panel && !panel.classList.contains('open')) { + panel.classList.add('open'); + } + this.switchMonitorTab('subagents'); + this.subagentPanelVisible = true; + } - if (!list) return; + closeSubagentsPanel() { + // Just switch back to monitor tab + this.switchMonitorTab('monitor'); + this.subagentPanelVisible = false; + } - // 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'; + // Legacy alias + toggleSubagentPanel() { + this.toggleSubagentsPanel(); + } + + updateSubagentTabButton() { + const tabBtn = this.$('subagentsTabBtn'); + const badge = this.$('subagentCountBadge'); + + const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length; + const totalCount = this.subagents.size; + + // Show tab if there are any subagents + if (tabBtn) { + tabBtn.style.display = totalCount > 0 ? 'inline-flex' : 'none'; } - // If panel is not visible and there are active subagents, show indicator - if (!this.subagentPanelVisible && activeCount === 0) { + // Update badge with active count + if (badge) { + badge.textContent = activeCount > 0 ? activeCount : ''; + } + } + + renderSubagentPanel() { + const list = this.$('subagentList'); + if (!list) return; + + // Always update toolbar button visibility + this.updateSubagentTabButton(); + + // If panel is not visible, don't render content + if (!this.subagentPanelVisible) { return; } @@ -3854,14 +3916,17 @@ class ClaudemanApp { const lastActivity = activity[activity.length - 1]; const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; const hasWindow = this.subagentWindows.has(agent.agentId); + const canKill = agent.status === 'active' || agent.status === 'idle'; + const displayName = agent.description || agent.agentId.substring(0, 7); html.push(`
🤖 - ${agent.agentId.substring(0, 7)} + ${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} ${agent.status} + ${canKill ? `` : ''} @@ -3927,9 +3992,10 @@ class ClaudemanApp { return ''; }).join(''); + const detailTitle = agent.description || `Agent ${agent.agentId}`; detail.innerHTML = `
- Agent ${agent.agentId} + ${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)} ${agent.status} + +
@@ -255,7 +259,8 @@
-
+ +
Screen Sessions @@ -272,20 +277,15 @@
-
-
- Subagents - +
+ +