diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 3cc0c459..07f6a47e 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -24,7 +24,11 @@ import { sendWallTilesToWebview, } from './assetLoader.js'; import { GLOBAL_KEY_SOUND_ENABLED, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; -import { ensureProjectScan } from './fileWatcher.js'; +import { + ensureProjectScan, + startExternalSessionScanning, + startStaleExternalAgentCheck, +} from './fileWatcher.js'; import type { LayoutWatcher } from './layoutPersistence.js'; import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js'; import type { AgentState } from './types.js'; @@ -47,6 +51,10 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { knownJsonlFiles = new Set(); projectScanTimer = { current: null as ReturnType | null }; + // External session detection (VS Code extension panel, etc.) + externalScanTimer: ReturnType | null = null; + staleCheckTimer: ReturnType | null = null; + // Bundled default layout (loaded from assets/default-layout.json) defaultLayout: Record | null = null; @@ -93,12 +101,30 @@ 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(); + } + // External agents (extension panel) have no terminal to focus } } else if (message.type === 'closeAgent') { const agent = this.agents.get(message.id); if (agent) { - agent.terminalRef.dispose(); + if (agent.terminalRef) { + agent.terminalRef.dispose(); + } else { + // External agent — just remove from tracking + removeAgent( + message.id, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.persistAgents, + ); + webviewView.webview.postMessage({ type: 'agentClosed', id: message.id }); + } } } else if (message.type === 'saveAgentSeats') { // Store seat assignments in a separate key (never touched by persistAgents) @@ -160,6 +186,35 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.persistAgents, ); + // Start external session scanning (detects VS Code extension panel sessions) + if (!this.externalScanTimer) { + this.externalScanTimer = startExternalSessionScanning( + projectDir, + this.knownJsonlFiles, + this.nextAgentId, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.webview, + this.persistAgents, + ); + } + if (!this.staleCheckTimer) { + this.staleCheckTimer = startStaleExternalAgentCheck( + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.webview, + this.persistAgents, + ); + } + // Load furniture assets BEFORE sending layout (async () => { try { @@ -388,6 +443,14 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { clearInterval(this.projectScanTimer.current); this.projectScanTimer.current = null; } + if (this.externalScanTimer) { + clearInterval(this.externalScanTimer); + this.externalScanTimer = null; + } + if (this.staleCheckTimer) { + clearInterval(this.staleCheckTimer); + this.staleCheckTimer = null; + } } } diff --git a/src/agentManager.ts b/src/agentManager.ts index 5d012711..0b903b56 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -68,6 +68,7 @@ export async function launchNewTerminal( const agent: AgentState = { id, terminalRef: terminal, + isExternal: false, projectDir, jsonlFile: expectedFile, fileOffset: 0, @@ -183,7 +184,8 @@ export function persistAgents( for (const agent of agents.values()) { persisted.push({ id: agent.id, - terminalName: agent.terminalRef.name, + terminalName: agent.terminalRef?.name ?? '', + isExternal: agent.isExternal || undefined, jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, folderName: agent.folderName, @@ -217,12 +219,28 @@ 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; + let terminal: vscode.Terminal | undefined; + const isExternal = p.isExternal ?? false; + + if (isExternal) { + // External agents (extension panel sessions) — restore if JSONL file was recently active + try { + if (!fs.existsSync(p.jsonlFile)) continue; + const stat = fs.statSync(p.jsonlFile); + if (Date.now() - stat.mtimeMs > 300_000) continue; // Skip if stale (>5 min) + } catch { + continue; + } + } else { + // Terminal agents — find matching terminal by name + terminal = liveTerminals.find((t) => t.name === p.terminalName); + if (!terminal) continue; + } const agent: AgentState = { id: p.id, terminalRef: terminal, + isExternal, projectDir: p.projectDir, jsonlFile: p.jsonlFile, fileOffset: 0, @@ -240,7 +258,11 @@ export function restoreAgents( agents.set(p.id, agent); knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + if (isExternal) { + console.log(`[Pixel Agents] Restored external agent ${p.id} → ${path.basename(p.jsonlFile)}`); + } else { + console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + } if (p.id > maxId) maxId = p.id; // Extract terminal index from name like "Claude Code #3" @@ -346,12 +368,16 @@ export function sendExistingAgents( Record >(WORKSPACE_KEY_AGENT_SEATS, {}); - // Include folderName per agent + // Include folderName and isExternal per agent const folderNames: Record = {}; + const externalAgents: Record = {}; for (const [id, agent] of agents) { if (agent.folderName) { folderNames[id] = agent.folderName; } + if (agent.isExternal) { + externalAgents[id] = true; + } } console.log( `[Pixel Agents] sendExistingAgents: agents=${JSON.stringify(agentIds)}, meta=${JSON.stringify(agentMeta)}`, @@ -362,6 +388,7 @@ export function sendExistingAgents( agents: agentIds, agentMeta, folderNames, + externalAgents, }); sendCurrentAgentStatuses(agents, webview); diff --git a/src/constants.ts b/src/constants.ts index 5e95c166..b90eab11 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,6 +6,14 @@ export const TOOL_DONE_DELAY_MS = 300; export const PERMISSION_TIMER_DELAY_MS = 7000; export const TEXT_IDLE_DELAY_MS = 5000; +// ── External Session Detection (VS Code extension panel, etc.) ── +export const EXTERNAL_SCAN_INTERVAL_MS = 5000; +/** Only adopt JSONL files modified within this window */ +export const EXTERNAL_ACTIVE_THRESHOLD_MS = 30_000; +/** Remove external agents after this much inactivity */ +export const EXTERNAL_STALE_TIMEOUT_MS = 300_000; // 5 minutes +export const EXTERNAL_STALE_CHECK_INTERVAL_MS = 30_000; + // ── Display Truncation ────────────────────────────────────── export const BASH_COMMAND_DISPLAY_MAX_LENGTH = 30; export const TASK_DESCRIPTION_DISPLAY_MAX_LENGTH = 40; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index f2a60dfd..8059ba38 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -2,7 +2,15 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS } from './constants.js'; +import { removeAgent } from './agentManager.js'; +import { + EXTERNAL_ACTIVE_THRESHOLD_MS, + EXTERNAL_SCAN_INTERVAL_MS, + EXTERNAL_STALE_CHECK_INTERVAL_MS, + EXTERNAL_STALE_TIMEOUT_MS, + FILE_WATCHER_POLL_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'; @@ -166,13 +174,16 @@ function scanForNewJsonlFiles( for (const file of files) { if (!knownJsonlFiles.has(file)) { knownJsonlFiles.add(file); - if (activeAgentIdRef.current !== null) { - // Active agent focused → /clear reassignment + const activeAgent = + activeAgentIdRef.current !== null ? agents.get(activeAgentIdRef.current) : undefined; + if (activeAgent && activeAgent.terminalRef) { + // Active terminal agent focused → /clear reassignment + // (only for terminal agents — external agents don't use /clear) console.log( `[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${activeAgentIdRef.current}`, ); reassignAgentToFile( - activeAgentIdRef.current, + activeAgentIdRef.current!, file, agents, fileWatchers, @@ -185,6 +196,7 @@ function scanForNewJsonlFiles( } else { // No active agent → try to adopt the focused terminal const activeTerminal = vscode.window.activeTerminal; + let adopted = false; if (activeTerminal) { let owned = false; for (const agent of agents.values()) { @@ -208,6 +220,30 @@ function scanForNewJsonlFiles( webview, persistAgents, ); + adopted = true; + } + } + // No terminal to adopt → check if this is an external session + // (e.g., Claude Code VS Code extension panel) + if (!adopted) { + try { + const stat = fs.statSync(file); + if (Date.now() - stat.mtimeMs < EXTERNAL_ACTIVE_THRESHOLD_MS) { + adoptExternalSession( + file, + projectDir, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + } catch { + /* ignore stat errors */ } } } @@ -233,6 +269,7 @@ function adoptTerminalForFile( const agent: AgentState = { id, terminalRef: terminal, + isExternal: false, projectDir, jsonlFile, fileOffset: 0, @@ -269,6 +306,175 @@ function adoptTerminalForFile( readNewLines(id, agents, waitingTimers, permissionTimers, webview); } +// ── External session support (VS Code extension panel, etc.) ── + +function adoptExternalSession( + jsonlFile: string, + projectDir: 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, + terminalRef: undefined, + isExternal: 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, + }; + + agents.set(id, agent); + persistAgents(); + + console.log(`[Pixel Agents] Agent ${id}: detected external session ${path.basename(jsonlFile)}`); + webview?.postMessage({ type: 'agentCreated', id, isExternal: true }); + + startFileWatching( + id, + jsonlFile, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + ); + readNewLines(id, agents, waitingTimers, permissionTimers, webview); +} + +/** + * Periodically scans for external sessions (VS Code extension panel, etc.) + * that produce JSONL files without an associated terminal. + */ +export function startExternalSessionScanning( + projectDir: string, + knownJsonlFiles: Set, + nextAgentIdRef: { current: number }, + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + jsonlPollTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): ReturnType { + return setInterval(() => { + let files: string[]; + try { + files = fs + .readdirSync(projectDir) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => path.join(projectDir, f)); + } catch { + return; + } + + const now = Date.now(); + + for (const file of files) { + if (knownJsonlFiles.has(file)) continue; + + // Check if already tracked by an agent + let tracked = false; + for (const agent of agents.values()) { + if (agent.jsonlFile === file) { + tracked = true; + break; + } + } + if (tracked) continue; + + // Only adopt recently-active files + try { + const stat = fs.statSync(file); + if (now - stat.mtimeMs > EXTERNAL_ACTIVE_THRESHOLD_MS) continue; + } catch { + continue; + } + + knownJsonlFiles.add(file); + adoptExternalSession( + file, + projectDir, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + }, EXTERNAL_SCAN_INTERVAL_MS); +} + +/** + * Periodically removes stale external agents whose JSONL files + * haven't been modified recently. + */ +export function startStaleExternalAgentCheck( + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + jsonlPollTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): ReturnType { + return setInterval(() => { + const now = Date.now(); + const toRemove: number[] = []; + + for (const [id, agent] of agents) { + if (!agent.isExternal) continue; + + try { + const stat = fs.statSync(agent.jsonlFile); + if (now - stat.mtimeMs > EXTERNAL_STALE_TIMEOUT_MS) { + toRemove.push(id); + } + } catch { + // File deleted — remove agent + toRemove.push(id); + } + } + + for (const id of toRemove) { + console.log(`[Pixel Agents] Removing stale external agent ${id}`); + removeAgent( + id, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + jsonlPollTimers, + persistAgents, + ); + webview?.postMessage({ type: 'agentClosed', id }); + } + }, EXTERNAL_STALE_CHECK_INTERVAL_MS); +} + export function reassignAgentToFile( agentId: number, newFilePath: string, diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index ecddd37f..f462967a 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -16,7 +16,7 @@ import { } from './timerManager.js'; import type { AgentState } from './types.js'; -export const PERMISSION_EXEMPT_TOOLS = new Set(['Task', 'AskUserQuestion']); +export const PERMISSION_EXEMPT_TOOLS = new Set(['Task', 'Agent', 'AskUserQuestion']); export function formatToolStatus(toolName: string, input: Record): string { const base = (p: unknown) => (typeof p === 'string' ? path.basename(p) : ''); @@ -39,7 +39,8 @@ export function formatToolStatus(toolName: string, input: Record TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + '\u2026' : desc}` @@ -125,8 +126,9 @@ export function processTranscriptLine( if (block.type === 'tool_result' && block.tool_use_id) { console.log(`[Pixel Agents] Agent ${agentId} tool done: ${block.tool_use_id}`); const completedToolId = block.tool_use_id; - // If the completed tool was a Task, clear its subagent tools - if (agent.activeToolNames.get(completedToolId) === 'Task') { + // If the completed tool was a Task/Agent, clear its subagent tools + const completedToolName = agent.activeToolNames.get(completedToolId); + if (completedToolName === 'Task' || completedToolName === 'Agent') { agent.activeSubagentToolIds.delete(completedToolId); agent.activeSubagentToolNames.delete(completedToolId); webview?.postMessage({ @@ -220,8 +222,9 @@ function processProgressRecord( return; } - // Verify parent is an active Task tool (agent_progress handling) - if (agent.activeToolNames.get(parentToolId) !== 'Task') return; + // Verify parent is an active Task/Agent tool (agent_progress handling) + const parentToolName = agent.activeToolNames.get(parentToolId); + if (parentToolName !== 'Task' && parentToolName !== 'Agent') return; const msg = data.message as Record | undefined; if (!msg) return; diff --git a/src/types.ts b/src/types.ts index feeec137..f52fd7e4 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; + /** Terminal reference — undefined for extension panel sessions */ + terminalRef?: vscode.Terminal; + /** Whether this agent was detected from an external source (VS Code extension panel, etc.) */ + isExternal: boolean; projectDir: string; jsonlFile: string; fileOffset: number; @@ -21,7 +24,10 @@ export interface AgentState { export interface PersistedAgent { id: number; + /** Terminal name — empty string for extension panel sessions */ terminalName: string; + /** Whether this agent was detected from an external source */ + isExternal?: boolean; jsonlFile: string; projectDir: string; /** Workspace folder name (only set for multi-root workspaces) */