diff --git a/CLAUDE.md b/CLAUDE.md index 3ded1cf7..d676fcba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1568 (must match `package.json` for npm publish) +**Version**: 0.1570 (must match `package.json` for npm publish) ## Project Overview diff --git a/README.md b/README.md index 313e5122..b5badbea 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ --- +

+ Claudeman — parallel subagent visualization +

+

Claudeman Dashboard

diff --git a/docs/images/subagent-demo-new.gif b/docs/images/subagent-demo-new.gif new file mode 100644 index 00000000..8c7c52b5 Binary files /dev/null and b/docs/images/subagent-demo-new.gif differ diff --git a/docs/images/subagent-demo.gif b/docs/images/subagent-demo.gif index 177f2abe..6775b452 100644 Binary files a/docs/images/subagent-demo.gif and b/docs/images/subagent-demo.gif differ diff --git a/docs/images/subagent-spawn.png b/docs/images/subagent-spawn.png index 7a22f457..6ba10789 100644 Binary files a/docs/images/subagent-spawn.png and b/docs/images/subagent-spawn.png differ diff --git a/package.json b/package.json index f20a63e1..418e2990 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1568", + "version": "0.1570", "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/scripts/capture-subagent-gif.mjs b/scripts/capture-subagent-gif.mjs new file mode 100644 index 00000000..613d2e3e --- /dev/null +++ b/scripts/capture-subagent-gif.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +/** + * Capture a GIF of subagent spawning on the LIVE Claudeman server. + * + * Connects to the running server on port 3000, creates a test session, + * sends a prompt that spawns 3 parallel agents, and captures a tight + * 8-second GIF starting right before agents spawn. + * + * Usage: node scripts/capture-subagent-gif.mjs + * Output: docs/images/subagent-demo.gif + */ + +import { chromium } from 'playwright'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync, unlinkSync, mkdirSync, statSync, cpSync } from 'fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(__dirname, '..'); +const OUTPUT_DIR = join(PROJECT_ROOT, 'docs', 'images'); +const BASE_URL = 'http://localhost:3000'; +const CASE_NAME = 'acme-backend'; +const VIEWPORT = { width: 1440, height: 810 }; + +// GIF settings +const GIF_DURATION = 25; // record long, trim later +const GIF_FPS = 6; +const GIF_WIDTH = 960; +const GIF_COLORS = 128; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Prompt that triggers exactly 3 parallel Explore agents with real research work +const PROMPT = `Analyze this codebase using exactly 3 Explore agents in parallel. IMPORTANT: Launch exactly 3 agents, no more, no fewer. Each agent should do its own research without spawning sub-agents: + +1. Agent 1: Find all REST API endpoints and their HTTP methods in server.ts +2. Agent 2: Analyze the respawn controller state machine in respawn-controller.ts +3. Agent 3: Find all SSE event types broadcast by the server + +Run all three in parallel. After they complete, give me a brief summary.`; + +try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {} + +async function api(path, opts = {}) { + const res = await fetch(`${BASE_URL}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + return res.json(); +} + +async function capture() { + console.log('Subagent GIF Capture'); + console.log('===================='); + + // Verify server is running + try { + await fetch(`${BASE_URL}/api/status`); + } catch { + console.error('Server not running on port 3000'); + process.exit(1); + } + console.log('✓ Server reachable'); + + let sessionId = null; + let browser = null; + + try { + // Create case and seed with real source files + console.log(`Creating case: ${CASE_NAME}`); + const caseDir = join(process.env.HOME, 'claudeman-cases', CASE_NAME); + const caseSrc = join(caseDir, 'src'); + const caseTest = join(caseDir, 'test'); + mkdirSync(caseSrc, { recursive: true }); + mkdirSync(caseTest, { recursive: true }); + + // Copy real source files so agents have content to analyze + const filesToCopy = [ + ['src/types.ts', 'src/types.ts'], + ['src/session.ts', 'src/session.ts'], + ['src/respawn-controller.ts', 'src/respawn-controller.ts'], + ['src/web/server.ts', 'src/server.ts'], + ['src/subagent-watcher.ts', 'src/subagent-watcher.ts'], + ['src/ralph-tracker.ts', 'src/ralph-tracker.ts'], + ['CLAUDE.md', 'CLAUDE.md'], + ['tsconfig.json', 'tsconfig.json'], + ['package.json', 'package.json'], + ]; + for (const [from, to] of filesToCopy) { + const src = join(PROJECT_ROOT, from); + if (existsSync(src)) cpSync(src, join(caseDir, to)); + } + console.log(`✓ Seeded ${filesToCopy.length} files`); + + await api('/api/cases/link', { + method: 'POST', + body: JSON.stringify({ name: CASE_NAME, path: caseDir }), + }); + + // Launch browser + console.log('Launching browser...'); + browser = await chromium.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--force-color-profile=srgb', + '--disable-gpu-compositing', + ], + }); + + // --- Phase 1: Setup (no recording) --- + const setupContext = await browser.newContext({ + viewport: VIEWPORT, + colorScheme: 'dark', + }); + const setupPage = await setupContext.newPage(); + setupPage.setDefaultTimeout(60000); + + await setupPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await sleep(1000); + + // Configure settings: enable subagents, disable Files pane + await setupPage.evaluate(() => { + const existing = JSON.parse(localStorage.getItem('claudeman-app-settings') || '{}'); + Object.assign(existing, { + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: false, + showProjectInsights: false, + showFileBrowser: false, + }); + localStorage.setItem('claudeman-app-settings', JSON.stringify(existing)); + }); + await setupPage.reload({ waitUntil: 'domcontentloaded' }); + await sleep(1000); + + // Quick-start session + console.log('Starting session...'); + const startResult = await api('/api/quick-start', { + method: 'POST', + body: JSON.stringify({ caseName: CASE_NAME, mode: 'claude' }), + }); + if (!startResult.success) throw new Error(`Quick-start failed: ${startResult.error}`); + sessionId = startResult.sessionId; + console.log(`✓ Session: ${sessionId}`); + + // Wait for terminal and select our tab + await setupPage.waitForSelector('.xterm', { timeout: 30000 }); + const ourTab = setupPage.locator(`.session-tab[data-id="${sessionId}"]`); + if (await ourTab.count() > 0) await ourTab.click(); + console.log('✓ Terminal ready'); + + // Wait for Claude CLI to initialize + console.log('Waiting for Claude CLI...'); + await sleep(15000); + + // Send the prompt + console.log('Sending prompt...'); + await api(`/api/sessions/${sessionId}/input`, { + method: 'POST', + body: JSON.stringify({ input: PROMPT, useMux: true }), + }); + await sleep(200); + await api(`/api/sessions/${sessionId}/input`, { + method: 'POST', + body: JSON.stringify({ input: '\r', useMux: true }), + }); + console.log('✓ Prompt sent'); + + // Wait for Claude to start thinking (first subagent imminent) + console.log('Waiting for Claude to start processing...'); + const pollStart = Date.now(); + let firstAgentSeen = false; + + while (Date.now() - pollStart < 180000) { + const res = await api(`/api/sessions/${sessionId}/subagents`); + const agents = res.data || []; + if (agents.length >= 1) { + console.log(` First agent detected! (${agents.length} total)`); + firstAgentSeen = true; + break; + } + await sleep(1000); + } + + if (!firstAgentSeen) { + console.log('⚠ No agents detected after 3 min, recording anyway...'); + } + + // Wait for all 3 agents to spawn and start working + console.log('Waiting for all agents to spawn and start working...'); + const spawnWait = Date.now(); + while (Date.now() - spawnWait < 60000) { + const res = await api(`/api/sessions/${sessionId}/subagents`); + const agents = res.data || []; + if (agents.length >= 3) { + console.log(` All ${agents.length} agents spawned`); + break; + } + console.log(` ${agents.length}/3 agents...`); + await sleep(2000); + } + + // Let agents work for a few seconds so there's content in the windows + console.log('Letting agents work for 5s...'); + await sleep(5000); + + // Close setup context (we'll grab localStorage via the API/new context) + await setupContext.close(); + console.log('✓ Setup phase complete'); + + // --- Phase 2: Record (start video right before the action) --- + console.log('Starting recording context...'); + const recordContext = await browser.newContext({ + viewport: VIEWPORT, + colorScheme: 'dark', + recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT }, + }); + const recordPage = await recordContext.newPage(); + recordPage.setDefaultTimeout(30000); + + await recordPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await sleep(500); + + // Re-apply settings (new context = fresh localStorage) + await recordPage.evaluate(() => { + const existing = JSON.parse(localStorage.getItem('claudeman-app-settings') || '{}'); + Object.assign(existing, { + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: false, + showProjectInsights: false, + showFileBrowser: false, + }); + localStorage.setItem('claudeman-app-settings', JSON.stringify(existing)); + }); + await recordPage.reload({ waitUntil: 'domcontentloaded' }); + await sleep(1000); + + // Select our session tab + const recTab = recordPage.locator(`.session-tab[data-id="${sessionId}"]`); + if (await recTab.count() > 0) await recTab.click(); + await sleep(500); + + // Force open any subagent windows that exist + const agentRes = await api(`/api/sessions/${sessionId}/subagents`); + for (const agent of (agentRes.data || [])) { + await recordPage.evaluate((id) => { + if (window.app?.openSubagentWindow) window.app.openSubagentWindow(id); + }, agent.agentId); + await sleep(300); + } + + const recordStart = Date.now(); + console.log('🔴 Recording...'); + + // Poll for more agents and open their windows as they appear + let maxSeen = (agentRes.data || []).length; + while (Date.now() - recordStart < 30000) { + const res = await api(`/api/sessions/${sessionId}/subagents`); + const agents = res.data || []; + if (agents.length > maxSeen) { + console.log(` ${agents.length} agent(s) now`); + for (const agent of agents) { + await recordPage.evaluate((id) => { + const existing = document.querySelector(`.subagent-window[data-agent-id="${id}"]`); + if (!existing && window.app?.openSubagentWindow) window.app.openSubagentWindow(id); + }, agent.agentId); + } + maxSeen = agents.length; + } + await sleep(1000); + } + console.log(` Recorded ${((Date.now() - recordStart) / 1000).toFixed(0)}s total`); + + // Take a static screenshot too + await recordPage.screenshot({ + path: join(OUTPUT_DIR, 'subagent-spawn.png'), + fullPage: false, + }); + console.log('✓ Saved: subagent-spawn.png'); + + // Finalize video + console.log('Finalizing video...'); + const videoPath = await recordPage.video()?.path(); + await recordContext.close(); + + // --- Phase 3: Convert to optimized GIF --- + if (videoPath && existsSync(videoPath)) { + const gifPath = join(OUTPUT_DIR, 'subagent-demo.gif'); + const palettePath = join(OUTPUT_DIR, '_palette.png'); + + // Probe video duration to calculate start offset + const probeOut = execSync( + `ffprobe -v error -show_entries format=duration -of csv=p=0 "${videoPath}"`, + { encoding: 'utf8' } + ).trim(); + const videoDuration = parseFloat(probeOut); + // Skip the first ~2s (page load/reload) and take GIF_DURATION seconds + const ssOffset = Math.max(0, Math.min(2, videoDuration - GIF_DURATION)); + console.log(`Video: ${videoDuration.toFixed(1)}s, extracting ${GIF_DURATION}s from ${ssOffset.toFixed(1)}s`); + + console.log('Converting to GIF...'); + // Boost contrast/saturation for vivid terminal colors, then encode + // stats_mode=single = per-frame palette (best color accuracy for few accent colors on dark bg) + const filters = `fps=${GIF_FPS},scale=${GIF_WIDTH}:-1:flags=lanczos,eq=contrast=1.1:saturation=1.3:brightness=0.02`; + execSync( + `ffmpeg -y -ss ${ssOffset} -t ${GIF_DURATION} -i "${videoPath}" ` + + `-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:stats_mode=single:reserve_transparent=0[p];[s1][p]paletteuse=dither=floyd_steinberg:diff_mode=rectangle:new=1" ` + + `-loop 0 "${gifPath}"`, + { stdio: 'pipe' } + ); + + try { unlinkSync(palettePath); } catch {} + try { unlinkSync(videoPath); } catch {} + + const sizeMB = (existsSync(gifPath) ? statSync(gifPath).size / (1024 * 1024) : 0).toFixed(1); + console.log(`✓ GIF saved: ${gifPath} (${sizeMB} MB)`); + } else { + console.log('⚠ No video recording found'); + } + + console.log('\nDone!'); + + } catch (err) { + console.error('Error:', err.message); + process.exitCode = 1; + } finally { + // Cleanup: ONLY delete the session WE created + if (sessionId) { + console.log(`Cleaning up session: ${sessionId}`); + await api(`/api/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + } + if (browser) await browser.close().catch(() => {}); + } +} + +process.on('SIGINT', () => { + console.log('\nInterrupted'); + process.exit(1); +}); + +capture(); diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 7dfb5387..c5441cbf 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -276,7 +276,8 @@ export class SubagentWatcher extends EventEmitter { /** * Run pgrep once and read /proc info for all Claude PIDs in parallel. - * Returns a Map of pid -> { environ, cmdline } for all running claude processes. + * Returns a Map of pid -> { environ, cmdline } for subagent processes only. + * Excludes main Claudeman-managed Claude processes (CLAUDEMAN_MUX=1). */ private async getClaudePids(): Promise> { const result = new Map(); @@ -295,6 +296,8 @@ export class SubagentWatcher extends EventEmitter { let cmdline = ''; try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ } try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ } + // Skip main Claudeman-managed Claude processes — only track subagents + if (environ.includes('CLAUDEMAN_MUX=1')) return; if (environ || cmdline) { result.set(pid, { environ, cmdline }); } @@ -623,20 +626,26 @@ export class SubagentWatcher extends EventEmitter { } /** - * Kill all subagents for a specific Claudeman session working directory + * Kill all subagents for a specific Claudeman session. + * IMPORTANT: Must scope to sessionId to avoid cross-session kills. + * All sessions in the same workingDir share a projectHash, so filtering + * by workingDir alone would kill subagents belonging to OTHER sessions. */ - async killSubagentsForSession(workingDir: string): Promise { + async killSubagentsForSession(workingDir: string, sessionId?: string): Promise { const subagents = this.getSubagentsForSession(workingDir); for (const agent of subagents) { if (agent.status === 'active' || agent.status === 'idle') { + // Only kill subagents belonging to this specific session + if (sessionId && agent.sessionId !== sessionId) continue; 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 + * Find the process ID of a Claude subagent by its session ID. + * Searches /proc for claude processes with matching session ID in environment. + * Skips main Claudeman-managed Claude processes (identified by CLAUDEMAN_MUX=1). */ private async findSubagentProcess(sessionId: string): Promise { try { @@ -653,18 +662,22 @@ export class SubagentWatcher extends EventEmitter { const pid = parseInt(pidStr, 10); if (Number.isNaN(pid)) continue; + let environ = ''; try { - // Check /proc/{pid}/environ for session ID (async read) - const environ = await readFile(`/proc/${pid}/environ`, 'utf8'); - if (environ.includes(sessionId)) { - return pid; - } + environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { // Can't read this process's environ - skip } + // Defense-in-depth: never kill a main Claudeman-managed Claude process. + // Main processes have CLAUDEMAN_MUX=1 in their environment; subagents don't. + if (environ.includes('CLAUDEMAN_MUX=1')) continue; + + if (environ.includes(sessionId)) { + return pid; + } + try { - // Also check /proc/{pid}/cmdline for session ID (async read) const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); if (cmdline.includes(sessionId)) { return pid; diff --git a/src/web/public/app.js b/src/web/public/app.js index 4bfcf949..125aa413 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2441,7 +2441,7 @@ class ClaudemanApp { _updateLocalEchoState() { const settings = this.loadAppSettingsFromStorage(); const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - const echoEnabled = settings.localEchoEnabled ?? true; + const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); const shouldEnable = !!(echoEnabled && session); if (this._localEchoEnabled && !shouldEnable) { // Flush background-buffered keystrokes before disabling @@ -9935,7 +9935,7 @@ class ClaudemanApp { document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; - document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? true; + document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 117af99a..9b7f3782 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1022,7 +1022,8 @@ html.mobile-init .file-browser-panel { padding-right: calc(0.5rem + var(--safe-area-right)); } - /* Respawn/Ralph panels - more compact on mobile, includes safe area padding */ + /* ---- Respawn Banner: Mobile Optimizations ---- */ + .respawn-banner { padding: 0.4rem 0.5rem; padding-left: calc(0.5rem + var(--safe-area-left)); @@ -1030,6 +1031,169 @@ html.mobile-init .file-browser-panel { font-size: 0.7rem; } + /* Stack banner vertically on mobile — status on top, action log below */ + .respawn-compact-layout { + flex-direction: column; + gap: 0.25rem; + } + + /* Let status column shrink to fit */ + .respawn-status-col { + flex-shrink: 1; + min-width: 0; + } + + /* Wrap status row items — tokens/timer may go to second line */ + .respawn-status-row1 { + flex-wrap: wrap; + gap: 0.35rem; + } + + /* Stop button — 44px touch target */ + .respawn-banner .btn-icon-only { + min-width: 36px; + min-height: 36px; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + border-radius: 6px; + } + + /* Action log — horizontal strip below status, border on top instead of left */ + .respawn-action-log { + border-left: none; + border-top: 1px solid rgba(34, 197, 94, 0.2); + padding-left: 0; + padding-top: 0.25rem; + max-height: 2em; + font-size: 0.6rem; + } + + /* Countdown timers — tighter on mobile */ + .respawn-countdown-timers { + gap: 0.2rem; + } + + .respawn-countdown-timer { + font-size: 0.55rem; + padding: 0.05rem 0.25rem; + } + + /* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */ + + /* Respawn header — stack status and buttons vertically */ + .respawn-header { + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + } + + /* Enable/Stop buttons — full-width touch targets */ + .respawn-header .respawn-actions { + display: flex; + gap: 0.5rem; + } + + .respawn-header .respawn-actions .btn-toolbar { + flex: 1; + min-height: 44px !important; + font-size: 0.85rem !important; + border-radius: 8px; + } + + /* Duration preset buttons — 44px touch targets in a grid */ + .duration-presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; + } + + .duration-preset-btn { + min-height: 40px; + padding: 0.5rem 0.4rem; + font-size: 0.8rem; + border-radius: 8px; + text-align: center; + } + + /* Custom duration — spans full width below the grid */ + .duration-custom { + grid-column: 1 / -1; + flex-wrap: wrap; + gap: 0.4rem; + } + + .duration-custom .duration-preset-btn { + flex: 0 0 auto; + min-width: 80px; + } + + .duration-custom-input.visible { + flex: 1; + } + + .duration-custom-input input { + width: 100%; + min-height: 40px; + font-size: 16px; /* Prevents iOS zoom */ + } + + /* Preset selector row — stack vertically */ + .preset-selector { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .preset-selector select { + width: 100%; + min-height: 40px; + font-size: 16px; /* Prevents iOS zoom */ + border-radius: 8px; + } + + .preset-selector .btn { + flex: 1; + min-height: 40px; + font-size: 0.8rem; + border-radius: 8px; + } + + /* Textareas — prevent iOS zoom with 16px font */ + #sessionOptionsModal textarea { + font-size: 16px; + min-height: 56px; + border-radius: 8px; + padding: 0.5rem 0.75rem; + } + + /* Checkboxes — larger touch area */ + #sessionOptionsModal .checkbox-inline { + min-height: 40px; + font-size: 0.85rem; + padding: 0.25rem 0; + gap: 0.5rem; + } + + #sessionOptionsModal .checkbox-inline input[type="checkbox"] { + width: 20px; + height: 20px; + } + + /* Respawn options row — stack if needed */ + #sessionOptionsModal .respawn-options-row { + gap: 0.5rem; + } + + /* Form section headers — more breathing room */ + #sessionOptionsModal .form-section-header { + margin-top: 1rem; + padding-top: 0.75rem; + font-size: 0.7rem; + } + .ralph-panel { font-size: 0.75rem; } diff --git a/src/web/server.ts b/src/web/server.ts index b470525b..6aa14235 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -4011,10 +4011,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; session.ralphTracker.stopWatchingFixPlan(); } - // Kill all subagents spawned by this session + // Kill all subagents spawned by this session (scoped to sessionId to avoid cross-session kills) if (session && killMux) { try { - await subagentWatcher.killSubagentsForSession(session.workingDir); + await subagentWatcher.killSubagentsForSession(session.workingDir, sessionId); } catch (err) { console.error(`[Server] Failed to kill subagents for session ${sessionId}:`, err); }