From 78feb11a281f55035f6c9a84aebee5bb778449b5 Mon Sep 17 00:00:00 2001 From: Florin Timbuc Date: Wed, 25 Mar 2026 14:13:18 +0200 Subject: [PATCH 1/5] feat: add agent connection diagnostics, JSONL parser resilience, and path encoding fuzzy matching --- src/fileWatcher.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index cbd9cd65..7205e123 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -32,6 +32,10 @@ export function startFileWatching( pollingTimers.set(agentId, interval); } +/** Track last read time per agent to debounce triple-redundant watcher callbacks */ +const lastReadTime = new Map(); +const READ_DEBOUNCE_MS = 100; + export function readNewLines( agentId: number, agents: Map, @@ -41,6 +45,13 @@ export function readNewLines( ): void { const agent = agents.get(agentId); if (!agent) return; + + // Debounce: skip if we read within the last 100ms (prevents triple-read from redundant watchers) + const now = Date.now(); + const lastRead = lastReadTime.get(agentId) ?? 0; + if (now - lastRead < READ_DEBOUNCE_MS) return; + lastReadTime.set(agentId, now); + try { const stat = fs.statSync(agent.jsonlFile); if (stat.size <= agent.fileOffset) return; From 7abc7c2bb57abf990ea7eb6366d0eb51a600e7ed Mon Sep 17 00:00:00 2001 From: Florin Timbuc Date: Wed, 25 Mar 2026 16:06:09 +0200 Subject: [PATCH 2/5] fix: simplify file watching to single poll, drop unreliable fs.watch/fs.watchFile --- src/fileWatcher.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index 7205e123..cbd9cd65 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -32,10 +32,6 @@ export function startFileWatching( pollingTimers.set(agentId, interval); } -/** Track last read time per agent to debounce triple-redundant watcher callbacks */ -const lastReadTime = new Map(); -const READ_DEBOUNCE_MS = 100; - export function readNewLines( agentId: number, agents: Map, @@ -45,13 +41,6 @@ export function readNewLines( ): void { const agent = agents.get(agentId); if (!agent) return; - - // Debounce: skip if we read within the last 100ms (prevents triple-read from redundant watchers) - const now = Date.now(); - const lastRead = lastReadTime.get(agentId) ?? 0; - if (now - lastRead < READ_DEBOUNCE_MS) return; - lastReadTime.set(agentId, now); - try { const stat = fs.statSync(agent.jsonlFile); if (stat.size <= agent.fileOffset) return; From 010f8ca8293ff89367887f8b7e47ddb5fcb2c00d Mon Sep 17 00:00:00 2001 From: Drew Date: Thu, 12 Mar 2026 16:31:23 -0500 Subject: [PATCH 3/5] feat: add external session support and Agent tool recognition Detect and track Claude Code sessions running in the VS Code extension panel (WebSocket transport, no terminal). These produce JSONL transcripts like terminal sessions but have no associated Terminal object. Changes: - Make terminalRef optional, add isExternal flag to AgentState - Add external session scanning (5s interval) and stale cleanup (5min timeout) - Persist/restore external agents across reloads - Guard terminal-specific code paths (focus, close, /clear reassignment) - Recognize renamed 'Agent' tool alongside 'Task' for sub-agents Known limitation: external sessions rely on JSONL file mtime for stale detection (no close event available), so agents linger up to 5 minutes after the extension panel session ends. Supersedes #76 and #77. --- src/PixelAgentsViewProvider.ts | 69 ++++++++++- src/agentManager.ts | 37 +++++- src/constants.ts | 8 ++ src/fileWatcher.ts | 214 ++++++++++++++++++++++++++++++++- src/types.ts | 8 +- 5 files changed, 323 insertions(+), 13 deletions(-) diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 232562c1..707f982f 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -31,7 +31,11 @@ import { LAYOUT_REVISION_KEY, 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'; @@ -54,6 +58,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; @@ -104,12 +112,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) @@ -175,6 +201,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 { @@ -466,6 +521,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 4fdf9ab1..5e8c1ba7 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -103,6 +103,7 @@ export async function launchNewTerminal( const agent: AgentState = { id, terminalRef: terminal, + isExternal: false, projectDir, jsonlFile: expectedFile, fileOffset: 0, @@ -241,7 +242,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, @@ -275,12 +277,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, @@ -302,7 +320,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" @@ -408,12 +430,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)}`, @@ -424,6 +450,7 @@ export function sendExistingAgents( agents: agentIds, agentMeta, folderNames, + externalAgents, }); sendCurrentAgentStatuses(agents, webview); diff --git a/src/constants.ts b/src/constants.ts index 6ac6bd99..8e986b5f 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 cbd9cd65..8b5b058b 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'; @@ -150,13 +158,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, @@ -169,6 +180,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()) { @@ -192,6 +204,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 */ } } else { console.log( @@ -241,6 +277,7 @@ function adoptTerminalForFile( const agent: AgentState = { id, terminalRef: terminal, + isExternal: false, projectDir, jsonlFile, fileOffset: 0, @@ -281,6 +318,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/types.ts b/src/types.ts index 38bfbbfe..a24085fe 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; @@ -28,7 +31,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) */ From 7b95c802450a7aea20af006635c6737713171571 Mon Sep 17 00:00:00 2001 From: Florin Timbuc Date: Wed, 25 Mar 2026 23:03:27 +0200 Subject: [PATCH 4/5] fix: improve external session detection and /clear handling --- src/PixelAgentsViewProvider.ts | 59 +++++++++++++++-------------- src/agentManager.ts | 33 ++++++++++++++-- src/fileWatcher.ts | 69 +++++++++++++++++++++++++++------- 3 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 707f982f..dfb8b165 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -202,33 +202,34 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { ); // 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, - ); - } + 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.knownJsonlFiles, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.webview, + this.persistAgents, + ); + } // Load furniture assets BEFORE sending layout (async () => { @@ -403,7 +404,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.activeAgentId.current = null; if (!terminal) return; for (const [id, agent] of this.agents) { - if (agent.terminalRef === terminal) { + if (agent.terminalRef && agent.terminalRef === terminal) { this.activeAgentId.current = id; webviewView.webview.postMessage({ type: 'agentSelected', id }); break; @@ -413,7 +414,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { vscode.window.onDidCloseTerminal((closed) => { for (const [id, agent] of this.agents) { - if (agent.terminalRef === closed) { + if (agent.terminalRef && agent.terminalRef === closed) { if (this.activeAgentId.current === id) { this.activeAgentId.current = null; } diff --git a/src/agentManager.ts b/src/agentManager.ts index 5e8c1ba7..9d33777b 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -281,11 +281,9 @@ export function restoreAgents( const isExternal = p.isExternal ?? false; if (isExternal) { - // External agents (extension panel sessions) — restore if JSONL file was recently active + // External agents — restore if JSONL file still exists on disk 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; } @@ -383,6 +381,35 @@ export function restoreAgents( } } + // After a short delay, remove restored terminal agents that never received data. + // These are dead terminals restored by VS Code (e.g., after /clear or restart) + // where Claude is no longer running. + const restoredTerminalIds = [...agents.entries()] + .filter(([, a]) => !a.isExternal && a.terminalRef) + .map(([id]) => id); + if (restoredTerminalIds.length > 0) { + setTimeout(() => { + for (const id of restoredTerminalIds) { + const agent = agents.get(id); + if (agent && !agent.isExternal && agent.linesProcessed === 0) { + console.log(`[Pixel Agents] Removing restored terminal agent ${id}: no data received`); + agent.terminalRef?.dispose(); + removeAgent( + id, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + jsonlPollTimers, + doPersist, + ); + webview?.postMessage({ type: 'agentClosed', id }); + } + } + }, 10_000); // 10 seconds grace period + } + // Advance counters past restored IDs if (maxId >= nextAgentIdRef.current) { nextAgentIdRef.current = maxId + 1; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index 8b5b058b..2eb73d2b 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -10,6 +10,7 @@ import { EXTERNAL_STALE_TIMEOUT_MS, FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS, + TEXT_IDLE_DELAY_MS, } from './constants.js'; import { cancelPermissionTimer, cancelWaitingTimer, clearAgentActivity } from './timerManager.js'; import { processTranscriptLine } from './transcriptParser.js'; @@ -102,13 +103,36 @@ export function ensureProjectScan( persistAgents: () => void, ): void { if (projectScanTimerRef.current) return; - // Seed with all existing JSONL files so we only react to truly new ones + // Seed with existing JSONL files so we only react to truly new ones. + // Skip recently-active files not owned by any agent — these may be + // external sessions (VS Code extension panel) that should be adopted. try { const files = fs .readdirSync(projectDir) .filter((f) => f.endsWith('.jsonl')) .map((f) => path.join(projectDir, f)); for (const f of files) { + // Check if this file is already tracked by a restored agent + let owned = false; + for (const agent of agents.values()) { + if (agent.jsonlFile === f) { + owned = true; + break; + } + } + if (owned) { + knownJsonlFiles.add(f); + continue; + } + // Skip recently-active unowned files — let external scanner adopt them + try { + const stat = fs.statSync(f); + if (Date.now() - stat.mtimeMs < EXTERNAL_ACTIVE_THRESHOLD_MS) { + continue; // Don't seed — external scanner will pick this up + } + } catch { + /* stat failed, seed it as known */ + } knownJsonlFiles.add(f); } } catch { @@ -160,11 +184,20 @@ function scanForNewJsonlFiles( knownJsonlFiles.add(file); 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) + // /clear reassignment: only if ALL of these hold: + // 1. Active agent has a terminal (not an external agent) + // 2. Agent previously had data (linesProcessed > 0) + // 3. Agent's JSONL has gone stale (no data in last 5s) + // 4. No external agents exist (otherwise new JSONL could be from extension panel) + const hasExternalAgents = [...agents.values()].some((a) => a.isExternal); + const isClearCandidate = activeAgent + ? activeAgent.linesProcessed > 0 && + Date.now() - activeAgent.lastDataAt > TEXT_IDLE_DELAY_MS && + !hasExternalAgents + : false; + if (activeAgent && activeAgent.terminalRef && isClearCandidate) { console.log( - `[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${activeAgentIdRef.current}`, + `[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${activeAgentIdRef.current} (/clear)`, ); reassignAgentToFile( activeAgentIdRef.current!, @@ -238,9 +271,10 @@ function scanForNewJsonlFiles( } } - // Clean up orphaned agents whose terminals have been closed + // Clean up orphaned agents whose terminals have been closed (skip external agents) for (const [id, agent] of agents) { - if (agent.terminalRef.exitStatus !== undefined) { + if (agent.isExternal) continue; + if (agent.terminalRef && agent.terminalRef.exitStatus !== undefined) { console.log(`[Pixel Agents] Agent ${id}: terminal closed, cleaning up orphan`); // Stop file watching fileWatchers.get(id)?.close(); @@ -346,9 +380,13 @@ function adoptExternalSession( activeToolNames: new Map(), activeSubagentToolIds: new Map(), activeSubagentToolNames: new Map(), + backgroundAgentToolIds: new Set(), isWaiting: false, permissionSent: false, hadToolsInTurn: false, + lastDataAt: Date.now(), + linesProcessed: 0, + seenUnknownRecordTypes: new Set(), }; agents.set(id, agent); @@ -401,8 +439,6 @@ export function startExternalSessionScanning( 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()) { @@ -444,6 +480,7 @@ export function startExternalSessionScanning( */ export function startStaleExternalAgentCheck( agents: Map, + knownJsonlFiles: Set, fileWatchers: Map, pollingTimers: Map>, waitingTimers: Map>, @@ -459,11 +496,12 @@ export function startStaleExternalAgentCheck( for (const [id, agent] of agents) { if (!agent.isExternal) continue; + // Only despawn if the JSONL file has been deleted from disk. + // Inactive external agents stay alive so they can resume when + // the session continues (e.g., claude --resume). try { - const stat = fs.statSync(agent.jsonlFile); - if (now - stat.mtimeMs > EXTERNAL_STALE_TIMEOUT_MS) { - toRemove.push(id); - } + fs.statSync(agent.jsonlFile); + // File still exists — keep the agent alive regardless of mtime } catch { // File deleted — remove agent toRemove.push(id); @@ -471,6 +509,11 @@ export function startStaleExternalAgentCheck( } for (const id of toRemove) { + const agent = agents.get(id); + if (agent) { + // Remove from knownJsonlFiles so the file can be re-adopted if it becomes active again + knownJsonlFiles.delete(agent.jsonlFile); + } console.log(`[Pixel Agents] Removing stale external agent ${id}`); removeAgent( id, From cad70159a3b85c91d2611245945faf8908d88b5c Mon Sep 17 00:00:00 2001 From: Florin Timbuc Date: Wed, 25 Mar 2026 23:50:14 +0200 Subject: [PATCH 5/5] fix: prevent external agent respawning and duplicate adoption --- src/PixelAgentsViewProvider.ts | 5 ++++- src/constants.ts | 2 ++ src/fileWatcher.ts | 16 +++++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index dfb8b165..3c884d54 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -32,6 +32,7 @@ import { WORKSPACE_KEY_AGENT_SEATS, } from './constants.js'; import { + dismissedJsonlFiles, ensureProjectScan, startExternalSessionScanning, startStaleExternalAgentCheck, @@ -123,7 +124,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { if (agent.terminalRef) { agent.terminalRef.dispose(); } else { - // External agent — just remove from tracking + // External agent — remove from tracking and dismiss the file + // so the external scanner doesn't re-adopt it + dismissedJsonlFiles.set(agent.jsonlFile, Date.now()); removeAgent( message.id, this.agents, diff --git a/src/constants.ts b/src/constants.ts index 8e986b5f..435897ca 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,8 @@ 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; +/** How long a dismissed external agent file is blocked from re-adoption */ +export const DISMISSED_COOLDOWN_MS = 60_000; // ── Display Truncation ────────────────────────────────────── export const BASH_COMMAND_DISPLAY_MAX_LENGTH = 30; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index 2eb73d2b..981a7e62 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -4,10 +4,10 @@ import * as vscode from 'vscode'; import { removeAgent } from './agentManager.js'; import { + DISMISSED_COOLDOWN_MS, 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, TEXT_IDLE_DELAY_MS, @@ -16,6 +16,9 @@ import { cancelPermissionTimer, cancelWaitingTimer, clearAgentActivity } from '. import { processTranscriptLine } from './transcriptParser.js'; import type { AgentState } from './types.js'; +/** Files explicitly dismissed by the user (closed via X). Temporarily blocked from re-adoption. */ +export const dismissedJsonlFiles = new Map(); // path → dismissal timestamp + export function startFileWatching( agentId: number, filePath: string, @@ -439,10 +442,17 @@ export function startExternalSessionScanning( const now = Date.now(); for (const file of files) { - // Check if already tracked by an agent + // Skip files recently dismissed by the user (closed via X). + // Dismissal expires after DISMISSED_COOLDOWN_MS so resumed sessions can be re-adopted. + const dismissedAt = dismissedJsonlFiles.get(file); + if (dismissedAt && now - dismissedAt < DISMISSED_COOLDOWN_MS) continue; + if (dismissedAt) dismissedJsonlFiles.delete(file); // Expired, clean up + + // Check if already tracked by an agent (normalize paths for comparison) + const normalizedFile = path.resolve(file); let tracked = false; for (const agent of agents.values()) { - if (agent.jsonlFile === file) { + if (path.resolve(agent.jsonlFile) === normalizedFile) { tracked = true; break; }