diff --git a/docs/plans/2026-03-09-dynamic-agent-lifecycle-design.md b/docs/plans/2026-03-09-dynamic-agent-lifecycle-design.md new file mode 100644 index 00000000..2f06b2bb --- /dev/null +++ b/docs/plans/2026-03-09-dynamic-agent-lifecycle-design.md @@ -0,0 +1,69 @@ +# Dynamic Agent Lifecycle — Design + +## Problem + +Pixel Agents only detects Claude Code sessions running in VS Code integrated terminals +within the current workspace. This misses: + +1. **Subagent sessions** — Agent tool spawns write to `/subagents/*.jsonl` +2. **External sessions** — `claude -p` subprocess calls (e.g., Security Analyst bots) +3. **Cross-workspace sessions** — Claude Code in other project directories + +Result: The office feels empty even when many agents are actively working. + +## Solution: Global Session Discovery + Headless Agents + +### Core Idea + +Scan ALL directories under `~/.claude/projects/` for active JSONL files. +Sessions without a VS Code terminal become "headless agents" — visible characters +that work, type, and react, but cannot be focused to a terminal. + +### Architecture Changes + +#### 1. AgentState (types.ts) + +```typescript +terminalRef?: vscode.Terminal; // optional (was required) +isHeadless?: boolean; // true for non-terminal agents +sourceDir?: string; // which project dir this came from +``` + +#### 2. Global Scanner (fileWatcher.ts) + +- `ensureGlobalScan()` — scans `~/.claude/projects/*/` for active JONLs +- Recursive: also scans `/subagents/*.jsonl` +- Smart filter: only JONLs actively growing (modified <10min, >3KB) +- Single shared interval timer for all directories + +#### 3. Headless Agent Manager (agentManager.ts) + +- `addHeadlessAgent(jsonlFile, projectDir)` — creates agent without terminal +- Focus action: opens JSONL file in editor (read-only) +- Auto-despawn: 5 minutes without JSONL growth → remove agent + +#### 4. Lifecycle + +| Event | Action | +|-------|--------| +| New active JSONL found | Spawn agent (matrix effect) | +| JSONL stops growing (5min) | Despawn agent (matrix effect) | +| Terminal closed | Despawn immediately | +| Subagent JSONL appears | Spawn as sub-character | + +### Files Changed + +| File | Change | +|------|--------| +| `src/types.ts` | `terminalRef` optional, `isHeadless` flag | +| `src/constants.ts` | Timeout constants | +| `src/fileWatcher.ts` | Global scan, subagent scan | +| `src/agentManager.ts` | `addHeadlessAgent()`, headless focus | +| `src/PixelAgentsViewProvider.ts` | Global scan init, auto-despawn | + +### What Stays the Same + +- Webview rendering (already agent-type agnostic) +- Character state machine (IDLE/WALK/TYPE) +- Tool event parsing (transcriptParser.ts) +- Subagent character spawning (already works via `Subtask:` prefix) diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 3cc0c459..884bdf47 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -23,8 +23,14 @@ import { sendFloorTilesToWebview, sendWallTilesToWebview, } from './assetLoader.js'; -import { GLOBAL_KEY_SOUND_ENABLED, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; -import { ensureProjectScan } from './fileWatcher.js'; +import { + GLOBAL_KEY_SOUND_ENABLED, + GLOBAL_SCAN_INTERVAL_MS, + HEADLESS_ACTIVITY_CHECK_INTERVAL_MS, + HEADLESS_INACTIVITY_TIMEOUT_MS, + WORKSPACE_KEY_AGENT_SEATS, +} from './constants.js'; +import { checkHeadlessActivity, ensureProjectScan, globalScanForAgents } from './fileWatcher.js'; import type { LayoutWatcher } from './layoutPersistence.js'; import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js'; import type { AgentState } from './types.js'; @@ -47,6 +53,10 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { knownJsonlFiles = new Set(); projectScanTimer = { current: null as ReturnType | null }; + // Global agent discovery timers + globalScanTimer: ReturnType | null = null; + headlessCheckTimer: ReturnType | null = null; + // Bundled default layout (loaded from assets/default-layout.json) defaultLayout: Record | null = null; @@ -93,12 +103,33 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); if (agent) { - agent.terminalRef.show(); + if (agent.terminalRef) { + agent.terminalRef.show(); + } else if (agent.isHeadless) { + // Headless agent — open JSONL file in editor as read-only preview + const uri = vscode.Uri.file(agent.jsonlFile); + vscode.window.showTextDocument(uri, { preview: true }); + } } } else if (message.type === 'closeAgent') { const agent = this.agents.get(message.id); if (agent) { - agent.terminalRef.dispose(); + if (agent.terminalRef) { + agent.terminalRef.dispose(); + } else { + // Headless agent — remove directly (no terminal to dispose) + removeAgent( + message.id, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.persistAgents, + ); + this.webview?.postMessage({ type: 'agentClosed', id: message.id }); + } } } else if (message.type === 'saveAgentSeats') { // Store seat assignments in a separate key (never touched by persistAgents) @@ -261,6 +292,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { })(); } sendExistingAgents(this.agents, this.context, this.webview); + + // Start global agent discovery — scan ALL ~/.claude/projects/ for active sessions + this.startGlobalScan(); } else if (message.type === 'openSessionsFolder') { const projectDir = getProjectDirPath(); if (projectDir && fs.existsSync(projectDir)) { @@ -369,9 +403,70 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { }); } + /** + * Start global agent discovery — scans ALL ~/.claude/projects/ directories + * for active JSONL files and creates headless agents for untracked sessions. + * Also periodically checks headless agents for inactivity and despawns them. + */ + private startGlobalScan(): void { + if (this.globalScanTimer) return; + console.log('[Pixel Agents] Starting global agent discovery'); + + const doScan = (): void => { + try { + globalScanForAgents( + this.knownJsonlFiles, + this.nextAgentId, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.webview, + this.persistAgents, + ); + } catch (err) { + console.error('[Pixel Agents] Global scan error:', err); + } + }; + + // Initial scan (delayed to let normal agent restore complete first) + setTimeout(doScan, 3000); + + // Periodic scan for new agents + this.globalScanTimer = setInterval(doScan, GLOBAL_SCAN_INTERVAL_MS); + + // Periodic headless activity check (auto-despawn inactive headless agents) + this.headlessCheckTimer = setInterval(() => { + try { + checkHeadlessActivity( + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.webview, + this.persistAgents, + HEADLESS_INACTIVITY_TIMEOUT_MS, + ); + } catch (err) { + console.error('[Pixel Agents] Headless activity check error:', err); + } + }, HEADLESS_ACTIVITY_CHECK_INTERVAL_MS); + } + dispose() { this.layoutWatcher?.dispose(); this.layoutWatcher = null; + if (this.globalScanTimer) { + clearInterval(this.globalScanTimer); + this.globalScanTimer = null; + } + if (this.headlessCheckTimer) { + clearInterval(this.headlessCheckTimer); + this.headlessCheckTimer = null; + } for (const id of [...this.agents.keys()]) { removeAgent( id, diff --git a/src/agentManager.ts b/src/agentManager.ts index 5d012711..65c4c817 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -183,10 +183,11 @@ export function persistAgents( for (const agent of agents.values()) { persisted.push({ id: agent.id, - terminalName: agent.terminalRef.name, + terminalName: agent.terminalRef?.name, jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, folderName: agent.folderName, + isHeadless: agent.isHeadless, }); } context.workspaceState.update(WORKSPACE_KEY_AGENTS, persisted); @@ -217,12 +218,17 @@ export function restoreAgents( let restoredProjectDir: string | null = null; for (const p of persisted) { - const terminal = liveTerminals.find((t) => t.name === p.terminalName); - if (!terminal) continue; + // For headless agents, skip terminal lookup — they don't have one + let terminal: vscode.Terminal | undefined; + if (!p.isHeadless) { + terminal = p.terminalName ? liveTerminals.find((t) => t.name === p.terminalName) : undefined; + if (!terminal) continue; // Terminal-based agent whose terminal is gone + } const agent: AgentState = { id: p.id, terminalRef: terminal, + isHeadless: p.isHeadless, projectDir: p.projectDir, jsonlFile: p.jsonlFile, fileOffset: 0, @@ -236,15 +242,18 @@ export function restoreAgents( permissionSent: false, hadToolsInTurn: false, folderName: p.folderName, + lastActivityMs: Date.now(), }; agents.set(p.id, agent); knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + console.log( + `[Pixel Agents] Restored agent ${p.id} → ${p.isHeadless ? 'headless' : `terminal "${p.terminalName}"`}`, + ); if (p.id > maxId) maxId = p.id; // Extract terminal index from name like "Claude Code #3" - const match = p.terminalName.match(/#(\d+)$/); + const match = p.terminalName?.match(/#(\d+)$/); if (match) { const idx = parseInt(match[1], 10); if (idx > maxIdx) maxIdx = idx; diff --git a/src/constants.ts b/src/constants.ts index 5e95c166..97485d6b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -32,6 +32,13 @@ export const LAYOUT_FILE_POLL_INTERVAL_MS = 2000; // ── Settings Persistence ──────────────────────────────────── export const GLOBAL_KEY_SOUND_ENABLED = 'pixel-agents.soundEnabled'; +// ── Global Agent Discovery ────────────────────────────────── +export const GLOBAL_SCAN_INTERVAL_MS = 2000; +export const HEADLESS_INACTIVITY_TIMEOUT_MS = 300_000; // 5 minutes → despawn +export const HEADLESS_ACTIVITY_CHECK_INTERVAL_MS = 30_000; // check every 30s +export const ACTIVE_JSONL_MIN_SIZE = 3000; // 3KB minimum to be considered active +export const ACTIVE_JSONL_MAX_AGE_MS = 600_000; // 10 minutes + // ── VS Code Identifiers ───────────────────────────────────── export const VIEW_ID = 'pixel-agents.panelView'; export const COMMAND_SHOW_PANEL = 'pixel-agents.showPanel'; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index f2a60dfd..30141122 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -1,8 +1,15 @@ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; -import { FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS } from './constants.js'; +import { + ACTIVE_JSONL_MAX_AGE_MS, + ACTIVE_JSONL_MIN_SIZE, + FILE_WATCHER_POLL_INTERVAL_MS, + GLOBAL_SCAN_INTERVAL_MS, + PROJECT_SCAN_INTERVAL_MS, +} from './constants.js'; import { cancelPermissionTimer, cancelWaitingTimer, clearAgentActivity } from './timerManager.js'; import { processTranscriptLine } from './transcriptParser.js'; import type { AgentState } from './types.js'; @@ -71,6 +78,9 @@ export function readNewLines( fs.closeSync(fd); agent.fileOffset = stat.size; + // Track activity for headless auto-despawn + agent.lastActivityMs = Date.now(); + const text = agent.lineBuffer + buf.toString('utf-8'); const lines = text.split('\n'); agent.lineBuffer = lines.pop() || ''; @@ -95,6 +105,8 @@ export function readNewLines( } } +// ── Legacy project-level scanning (kept for /clear reassignment) ──── + export function ensureProjectScan( projectDir: string, knownJsonlFiles: Set, @@ -183,19 +195,18 @@ function scanForNewJsonlFiles( persistAgents, ); } else { - // No active agent → try to adopt the focused terminal - const activeTerminal = vscode.window.activeTerminal; - if (activeTerminal) { + // No active agent → try to adopt any unowned terminal + for (const terminal of vscode.window.terminals) { let owned = false; for (const agent of agents.values()) { - if (agent.terminalRef === activeTerminal) { + if (agent.terminalRef === terminal) { owned = true; break; } } if (!owned) { adoptTerminalForFile( - activeTerminal, + terminal, file, projectDir, nextAgentIdRef, @@ -208,6 +219,7 @@ function scanForNewJsonlFiles( webview, persistAgents, ); + break; } } } @@ -321,3 +333,272 @@ export function reassignAgentToFile( ); readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); } + +// ── Global Agent Discovery ────────────────────────────────────────── + +/** + * Check if a JSONL file represents an active Claude session. + * Active = file is >3KB AND was modified within the last 10 minutes. + */ +function isActiveJsonl(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + return ( + stat.size >= ACTIVE_JSONL_MIN_SIZE && Date.now() - stat.mtimeMs < ACTIVE_JSONL_MAX_AGE_MS + ); + } catch { + return false; + } +} + +/** + * Check if a JSONL file is already being tracked by any agent. + */ +function isTrackedByAgent(filePath: string, agents: Map): boolean { + for (const agent of agents.values()) { + if (agent.jsonlFile === filePath) return true; + } + return false; +} + +/** + * Derive a human-readable folder name from a Claude project directory name. + * e.g. "-home-cmdshadow-shadowops-bot" → "shadowops-bot" + */ +function folderNameFromProjectDir(dirName: string): string { + // Strip leading dashes and common home prefix + const parts = dirName.replace(/^-+/, '').split('-'); + // Skip "home" and username parts, return the rest + // Typical: home-cmdshadow-GuildScout → GuildScout + if (parts.length >= 3 && parts[0] === 'home') { + return parts.slice(2).join('-') || parts[parts.length - 1]; + } + return dirName; +} + +/** + * Scan all directories under ~/.claude/projects/ for active JSONL files + * and create headless agents for any that aren't already tracked. + * Also scans session subdirectories for subagent JSONL files. + */ +export function globalScanForAgents( + knownJsonlFiles: Set, + nextAgentIdRef: { current: number }, + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): void { + const projectsRoot = path.join(os.homedir(), '.claude', 'projects'); + let projectDirs: string[]; + try { + projectDirs = fs + .readdirSync(projectsRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return; + } + + for (const dirName of projectDirs) { + const dirPath = path.join(projectsRoot, dirName); + const folderName = folderNameFromProjectDir(dirName); + + // 1. Scan top-level JSONL files + try { + const files = fs + .readdirSync(dirPath, { withFileTypes: true }) + .filter((f) => f.isFile() && f.name.endsWith('.jsonl')) + .map((f) => path.join(dirPath, f.name)); + + for (const file of files) { + if (knownJsonlFiles.has(file)) continue; + if (!isActiveJsonl(file)) continue; + if (isTrackedByAgent(file, agents)) continue; + + // New active JSONL — create headless agent + knownJsonlFiles.add(file); + createHeadlessAgent( + file, + dirPath, + folderName, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + } catch { + /* dir may not be readable */ + } + + // 2. Scan session subdirectories for subagent JSONL files + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const subagentDir = path.join(dirPath, entry.name, 'subagents'); + try { + if (!fs.existsSync(subagentDir)) continue; + const subFiles = fs + .readdirSync(subagentDir) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => path.join(subagentDir, f)); + + for (const subFile of subFiles) { + if (knownJsonlFiles.has(subFile)) continue; + if (!isActiveJsonl(subFile)) continue; + if (isTrackedByAgent(subFile, agents)) continue; + + knownJsonlFiles.add(subFile); + createHeadlessAgent( + subFile, + dirPath, + `${folderName} (subagent)`, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + } catch { + /* subagent dir may not exist */ + } + } + } catch { + /* ignore */ + } + } +} + +/** + * Create a headless agent (no terminal) for a discovered JSONL file. + */ +function createHeadlessAgent( + jsonlFile: string, + projectDir: string, + folderName: string, + nextAgentIdRef: { current: number }, + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): void { + const id = nextAgentIdRef.current++; + const agent: AgentState = { + id, + isHeadless: true, + projectDir, + jsonlFile, + fileOffset: 0, + lineBuffer: '', + activeToolIds: new Set(), + activeToolStatuses: new Map(), + activeToolNames: new Map(), + activeSubagentToolIds: new Map(), + activeSubagentToolNames: new Map(), + isWaiting: false, + permissionSent: false, + hadToolsInTurn: false, + folderName, + lastActivityMs: Date.now(), + }; + + agents.set(id, agent); + persistAgents(); + + console.log( + `[Pixel Agents] Agent ${id}: headless agent for ${path.basename(jsonlFile)} (${folderName})`, + ); + webview?.postMessage({ type: 'agentCreated', id, folderName }); + + // Start watching — read from beginning to catch up on tool state + startFileWatching( + id, + jsonlFile, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + ); + readNewLines(id, agents, waitingTimers, permissionTimers, webview); +} + +/** + * Check all headless agents for inactivity and despawn those whose + * JSONL files haven't grown for HEADLESS_INACTIVITY_TIMEOUT_MS. + */ +export function checkHeadlessActivity( + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + jsonlPollTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, + timeoutMs: number, +): void { + const now = Date.now(); + const toRemove: number[] = []; + + for (const [id, agent] of agents) { + if (!agent.isHeadless) continue; + + // Check if JSONL is still being written to + try { + const stat = fs.statSync(agent.jsonlFile); + if (now - stat.mtimeMs < timeoutMs) { + agent.lastActivityMs = now; + continue; + } + } catch { + // File gone — mark for removal + } + + // Check last known activity + const lastActivity = agent.lastActivityMs || 0; + if (now - lastActivity > timeoutMs) { + toRemove.push(id); + } + } + + for (const id of toRemove) { + console.log(`[Pixel Agents] Agent ${id}: headless agent inactive, despawning`); + // Remove agent (reuse existing removeAgent logic) + const agent = agents.get(id); + if (!agent) continue; + + fileWatchers.get(id)?.close(); + fileWatchers.delete(id); + const pt = pollingTimers.get(id); + if (pt) clearInterval(pt); + pollingTimers.delete(id); + try { + fs.unwatchFile(agent.jsonlFile); + } catch { + /* ignore */ + } + cancelWaitingTimer(id, waitingTimers); + cancelPermissionTimer(id, permissionTimers); + agents.delete(id); + persistAgents(); + webview?.postMessage({ type: 'agentClosed', id }); + } +} diff --git a/src/types.ts b/src/types.ts index feeec137..44c2f841 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,10 @@ import type * as vscode from 'vscode'; export interface AgentState { id: number; - terminalRef: vscode.Terminal; + /** VS Code terminal reference — undefined for headless agents */ + terminalRef?: vscode.Terminal; + /** True for agents discovered via JSONL scanning without a terminal */ + isHeadless?: boolean; projectDir: string; jsonlFile: string; fileOffset: number; @@ -17,13 +20,16 @@ export interface AgentState { hadToolsInTurn: boolean; /** Workspace folder name (only set for multi-root workspaces) */ folderName?: string; + /** Last time the JSONL file was seen growing (for headless auto-despawn) */ + lastActivityMs?: number; } export interface PersistedAgent { id: number; - terminalName: string; + terminalName?: string; jsonlFile: string; projectDir: string; /** Workspace folder name (only set for multi-root workspaces) */ folderName?: string; + isHeadless?: boolean; }