diff --git a/.vscode/settings.json b/.vscode/settings.json index 82e9b027..f56b2630 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,4 +19,4 @@ "editor.formatOnSave": false }, "liveServer.settings.port": 5501 -} \ No newline at end of file +} diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index fe1cdd81..b8012d24 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -1,3 +1,4 @@ +import { execSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -6,9 +7,11 @@ import * as vscode from 'vscode'; import { getProjectDirPath, launchNewTerminal, + launchWorktreeAgent, persistAgents, removeAgent, restoreAgents, + sendCurrentAgentStatuses, sendExistingAgents, sendLayout, } from './agentManager.js'; @@ -28,7 +31,7 @@ import { LAYOUT_REVISION_KEY, WORKSPACE_KEY_AGENT_SEATS, } from './constants.js'; -import { ensureProjectScan } from './fileWatcher.js'; +import { adoptExistingJsonlFiles, ensureProjectScan, readNewLines } from './fileWatcher.js'; import type { LayoutWatcher } from './layoutPersistence.js'; import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js'; import type { AgentState } from './types.js'; @@ -94,15 +97,54 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.persistAgents, message.folderPath as string | undefined, ); + } else if (message.type === 'requestWorktreeAgent') { + const defaultBranch = `agent-${new Date().toISOString().slice(0, 16).replace('T', '-').replace(':', '')}`; + const branchName = await vscode.window.showInputBox({ + prompt: 'Branch name for new worktree agent', + value: defaultBranch, + validateInput: (v) => (v.trim() ? null : 'Branch name cannot be empty'), + }); + if (!branchName) return; + await launchWorktreeAgent( + branchName.trim(), + this.nextAgentId, + this.nextTerminalIndex, + this.agents, + this.activeAgentId, + this.knownJsonlFiles, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.jsonlPollTimers, + this.projectScanTimer, + this.webview, + this.persistAgents, + ); } else if (message.type === 'focusAgent') { - const agent = this.agents.get(message.id); - if (agent) { + const agent = this.agents.get(message.id as number); + if (agent?.terminalRef) { agent.terminalRef.show(); } } else if (message.type === 'closeAgent') { - const agent = this.agents.get(message.id); + const agent = this.agents.get(message.id as number); if (agent) { - agent.terminalRef.dispose(); + if (agent.terminalRef) { + agent.terminalRef.dispose(); // triggers onDidCloseTerminal + } else { + // Headless agent: remove directly since there's no terminal to close + removeAgent( + message.id as number, + 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) @@ -114,7 +156,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } else if (message.type === 'setSoundEnabled') { this.context.globalState.update(GLOBAL_KEY_SOUND_ENABLED, message.enabled); } else if (message.type === 'webviewReady') { - restoreAgents( + await restoreAgents( this.context, this.nextAgentId, this.nextTerminalIndex, @@ -132,7 +174,20 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { ); // Send persisted settings to webview const soundEnabled = this.context.globalState.get(GLOBAL_KEY_SOUND_ENABLED, true); - this.webview?.postMessage({ type: 'settingsLoaded', soundEnabled }); + + // Detect whether workspace is a git repo (for worktree button) + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + let isGitRepo = false; + if (workspaceRoot) { + try { + execSync('git rev-parse --show-toplevel', { cwd: workspaceRoot, stdio: 'ignore' }); + isGitRepo = true; + } catch { + /* not a git repo */ + } + } + + this.webview?.postMessage({ type: 'settingsLoaded', soundEnabled, isGitRepo }); // Send workspace folders to webview (only when multi-root) const wsFolders = vscode.workspace.workspaceFolders; @@ -145,10 +200,24 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { // Ensure project scan runs even with no restored agents (to adopt external terminals) const projectDir = getProjectDirPath(); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; console.log('[Extension] workspaceRoot:', workspaceRoot); console.log('[Extension] projectDir:', projectDir); if (projectDir) { + // Adopt pre-existing sessions (started before VS Code opened) + adoptExistingJsonlFiles( + projectDir, + this.knownJsonlFiles, + this.nextAgentId, + this.agents, + this.activeAgentId, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.webview, + this.persistAgents, + ); + ensureProjectScan( projectDir, this.knownJsonlFiles, @@ -230,6 +299,8 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { if (this.webview) { console.log('[Extension] Sending saved layout'); sendLayout(this.context, this.webview, this.defaultLayout); + // Send agent statuses AFTER layoutLoaded so characters exist when messages arrive + sendCurrentAgentStatuses(this.agents, this.webview); this.startLayoutWatcher(); } })(); @@ -260,6 +331,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } if (this.webview) { sendLayout(this.context, this.webview, this.defaultLayout); + sendCurrentAgentStatuses(this.agents, this.webview); this.startLayoutWatcher(); } })(); @@ -311,9 +383,11 @@ 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 }); + // Read any JSONL lines that arrived while this terminal wasn't focused + readNewLines(id, this.agents, this.waitingTimers, this.permissionTimers, this.webview); break; } } @@ -321,10 +395,11 @@ 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; } + const worktreePath = agent.worktreePath; removeAgent( id, this.agents, @@ -336,6 +411,27 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.persistAgents, ); webviewView.webview.postMessage({ type: 'agentClosed', id }); + + // Offer to clean up git worktree + if (worktreePath) { + const branchName = path.basename(worktreePath); + void vscode.window + .showInformationMessage(`Remove worktree '${branchName}'?`, 'Yes', 'No') + .then((choice) => { + if (choice === 'Yes') { + try { + execSync(`git worktree remove "${worktreePath}" --force`, { + cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + }); + vscode.window.showInformationMessage( + `Pixel Agents: Removed worktree '${branchName}'.`, + ); + } catch (e) { + vscode.window.showErrorMessage(`Pixel Agents: Failed to remove worktree: ${e}`); + } + } + }); + } } } }); diff --git a/src/agentManager.ts b/src/agentManager.ts index f7c17a9d..f06016d6 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -1,3 +1,4 @@ +import { execSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -38,6 +39,8 @@ export async function launchNewTerminal( webview: vscode.Webview | undefined, persistAgents: () => void, folderPath?: string, + overrideFolderName?: string, + worktreePath?: string, ): Promise { const folders = vscode.workspace.workspaceFolders; const cwd = folderPath || folders?.[0]?.uri.fsPath; @@ -64,7 +67,7 @@ export async function launchNewTerminal( // Create agent immediately (before JSONL file exists) const id = nextAgentIdRef.current++; - const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; + const folderName = overrideFolderName ?? (isMultiRoot && cwd ? path.basename(cwd) : undefined); const agent: AgentState = { id, terminalRef: terminal, @@ -81,6 +84,7 @@ export async function launchNewTerminal( permissionSent: false, hadToolsInTurn: false, folderName, + worktreePath, }; agents.set(id, agent); @@ -132,6 +136,81 @@ export async function launchNewTerminal( jsonlPollTimers.set(id, pollTimer); } +export async function launchWorktreeAgent( + branchName: string, + nextAgentIdRef: { current: number }, + nextTerminalIndexRef: { current: number }, + agents: Map, + activeAgentIdRef: { current: number | null }, + knownJsonlFiles: Set, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + jsonlPollTimers: Map>, + projectScanTimerRef: { current: ReturnType | null }, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): Promise { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + vscode.window.showErrorMessage('Pixel Agents: No workspace folder found.'); + return; + } + + // Validate git repository + let repoRoot: string; + try { + repoRoot = execSync('git rev-parse --show-toplevel', { + cwd: workspaceRoot, + encoding: 'utf-8', + }).trim(); + } catch { + vscode.window.showErrorMessage('Pixel Agents: Not a git repository.'); + return; + } + + // Build worktree path inside the repo under .worktrees/ + const worktreePath = path.join(repoRoot, '.worktrees', branchName); + + // Create worktree + try { + execSync(`git worktree add "${worktreePath}" -b "${branchName}"`, { + cwd: repoRoot, + encoding: 'utf-8', + }); + } catch (e) { + const msg = String(e); + if (msg.includes('already exists')) { + vscode.window.showErrorMessage(`Pixel Agents: Branch '${branchName}' already exists.`); + } else { + vscode.window.showErrorMessage(`Pixel Agents: Failed to create worktree: ${msg}`); + } + return; + } + + console.log(`[Pixel Agents] Created worktree at ${worktreePath} for branch '${branchName}'`); + + await launchNewTerminal( + nextAgentIdRef, + nextTerminalIndexRef, + agents, + activeAgentIdRef, + knownJsonlFiles, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + jsonlPollTimers, + projectScanTimerRef, + webview, + persistAgents, + worktreePath, // folderPath = worktree dir + branchName, // overrideFolderName = branch name as label + worktreePath, // worktreePath stored on agent + ); +} + export function removeAgent( agentId: number, agents: Map, @@ -183,16 +262,19 @@ 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, + isExternal: agent.isExternal, + worktreePath: agent.worktreePath, + claudePid: agent.claudePid, }); } context.workspaceState.update(WORKSPACE_KEY_AGENTS, persisted); } -export function restoreAgents( +export async function restoreAgents( context: vscode.ExtensionContext, nextAgentIdRef: { current: number }, nextTerminalIndexRef: { current: number }, @@ -207,7 +289,7 @@ export function restoreAgents( activeAgentIdRef: { current: number | null }, webview: vscode.Webview | undefined, doPersist: () => void, -): void { +): Promise { const persisted = context.workspaceState.get(WORKSPACE_KEY_AGENTS, []); if (persisted.length === 0) return; @@ -216,13 +298,66 @@ export function restoreAgents( let maxIdx = 0; let restoredProjectDir: string | null = null; + // Resolve all terminal shell PIDs up front (async) to avoid multiple awaits in the loop + const terminalShellPids = new Map(); + for (const t of liveTerminals) { + terminalShellPids.set(t, await t.processId); + } + + // Track which terminals have already been claimed to avoid assigning the same + // terminal to two agents (critical when multiple terminals share the same name). + const claimedTerminals = new Set(); + for (const p of persisted) { - const terminal = liveTerminals.find((t) => t.name === p.terminalName); - if (!terminal) continue; + // Skip agents already in the map — prevents duplicate file watchers on re-entry + // (webviewReady fires on every panel focus, re-calling restoreAgents each time) + if (agents.has(p.id)) { + // Just make sure the JSONL is tracked and update terminalRef if we can improve the match + knownJsonlFiles.add(p.jsonlFile); + const existing = agents.get(p.id)!; + if (existing.terminalRef) claimedTerminals.add(existing.terminalRef); + continue; + } + + let terminalRef: vscode.Terminal | null = null; + + if (p.isExternal) { + // External agent: restore headlessly if JSONL still exists + if (!fs.existsSync(p.jsonlFile)) continue; + } else { + // Try PID-based matching first (reliable even when multiple terminals share the same name) + if (p.claudePid) { + try { + const shellPid = parseInt( + execSync(`ps -p ${p.claudePid} -o ppid=`, { encoding: 'utf-8', timeout: 500 }).trim(), + ); + if (shellPid && !isNaN(shellPid)) { + for (const t of liveTerminals) { + if (claimedTerminals.has(t)) continue; + if (terminalShellPids.get(t) === shellPid) { + terminalRef = t; + break; + } + } + } + } catch { + /* process may be gone — fall through to name matching */ + } + } + + // Fall back to name matching (e.g. claudePid not set, or process gone) + if (!terminalRef) { + terminalRef = + liveTerminals.find((t) => !claimedTerminals.has(t) && t.name === p.terminalName) ?? null; + } + + if (!terminalRef) continue; + claimedTerminals.add(terminalRef); + } const agent: AgentState = { id: p.id, - terminalRef: terminal, + terminalRef, projectDir: p.projectDir, jsonlFile: p.jsonlFile, fileOffset: 0, @@ -236,11 +371,18 @@ export function restoreAgents( permissionSent: false, hadToolsInTurn: false, folderName: p.folderName, + isExternal: p.isExternal, + worktreePath: p.worktreePath, + claudePid: p.claudePid, }; agents.set(p.id, agent); knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + if (p.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" @@ -363,8 +505,8 @@ export function sendExistingAgents( agentMeta, folderNames, }); - - sendCurrentAgentStatuses(agents, webview); + // Note: sendCurrentAgentStatuses is called separately AFTER layoutLoaded + // so that agentStatus/agentToolStart messages arrive after characters are created. } export function sendCurrentAgentStatuses( diff --git a/src/constants.ts b/src/constants.ts index 1373e168..513e87ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,7 +3,7 @@ export const JSONL_POLL_INTERVAL_MS = 1000; export const FILE_WATCHER_POLL_INTERVAL_MS = 1000; export const PROJECT_SCAN_INTERVAL_MS = 1000; export const TOOL_DONE_DELAY_MS = 300; -export const PERMISSION_TIMER_DELAY_MS = 7000; +export const PERMISSION_TIMER_DELAY_MS = 3000; export const TEXT_IDLE_DELAY_MS = 5000; // ── Display Truncation ────────────────────────────────────── @@ -40,3 +40,6 @@ export const WORKSPACE_KEY_AGENTS = 'pixel-agents.agents'; export const WORKSPACE_KEY_AGENT_SEATS = 'pixel-agents.agentSeats'; export const WORKSPACE_KEY_LAYOUT = 'pixel-agents.layout'; export const TERMINAL_NAME_PREFIX = 'Claude Code'; +export const EXTERNAL_AGENT_FOLDER_NAME = 'Ext'; +export const ADOPT_RECENT_FILE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes +export const ADOPT_MIN_FILE_SIZE_BYTES = 3072; // 3KB — substantial activity diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index f2a60dfd..ad5ca14d 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -1,12 +1,301 @@ +import { execSync } from 'child_process'; 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 { + ADOPT_MIN_FILE_SIZE_BYTES, + ADOPT_RECENT_FILE_THRESHOLD_MS, + EXTERNAL_AGENT_FOLDER_NAME, + 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'; +const KNOWN_TERMINALS = [ + 'Ghostty', + 'iTerm2', + 'Terminal', + 'Hyper', + 'WezTerm', + 'Alacritty', + 'kitty', + 'tmux', + 'Cursor', // Cursor IDE treated as external app — shows as headless agent +]; +const VS_CODE_PROCESSES = ['code', 'code-insiders', 'electron']; + +// Sentinel: the JSONL belongs to a VS Code terminal (not extension host). +// Caller should look for an unowned VS Code terminal and adopt it. +const VSCODE_TERMINAL_SESSION = '__vscode_terminal__'; + +/** Walk the process tree upward from pid. + * Returns 'vscode_terminal' if a regular Code Helper (non-plugin) ancestor is found, + * 'ignore' if an extension-host (Plugin) or other VS Code process is found, + * the terminal name if a known external terminal is found, + * or null if nothing matched within the depth limit. */ +function walkProcessTree(pid: number): 'vscode_terminal' | 'ignore' | string | null { + let checkPid = pid; + for (let i = 0; i < 8; i++) { + try { + const ppidStr = execSync(`ps -p ${checkPid} -o ppid=`, { + encoding: 'utf-8', + timeout: 1000, + }).trim(); + const ppid = parseInt(ppidStr); + if (!ppid || isNaN(ppid) || ppid === checkPid || ppid <= 1) break; + // Use args= (full command line incl. path) so "Code Helper (Plugin)" isn't truncated. + // ps -o comm= caps at ~16 chars and loses "(Plugin)", causing false vscode_terminal matches. + const args = execSync(`ps -p ${ppid} -o args=`, { + encoding: 'utf-8', + timeout: 1000, + }).trim(); + const argsLower = args.toLowerCase(); + + // Extension host process (Code Helper (Plugin), Cursor Helper (Plugin)) → ignore + if (argsLower.includes('plugin')) return 'ignore'; + + // Regular VS Code / Cursor terminal process → adopt as terminal agent + for (const vs of VS_CODE_PROCESSES) { + if (argsLower.includes(vs)) return 'vscode_terminal'; + } + + // Known external terminal emulator → headless agent + for (const term of KNOWN_TERMINALS) { + if (argsLower.includes(term.toLowerCase())) return term; + } + + checkPid = ppid; + } catch { + break; + } + } + return null; +} + +type DetectionResult = { + label: string | null | undefined; + claudePid?: number; // set when label === VSCODE_TERMINAL_SESSION, used for terminal matching +}; + +/** Classify a claude PID to a label via its process-tree ancestry. */ +function treeClassify(pid: number): string { + const result = walkProcessTree(pid); + if (result === 'vscode_terminal') return VSCODE_TERMINAL_SESSION; + if (result === 'ignore') return EXTERNAL_AGENT_FOLDER_NAME; // extension-host parent → Ext agent + if (result !== null) return result; // Ghostty, iTerm2, etc. + return EXTERNAL_AGENT_FOLDER_NAME; // unrecognised ancestor +} + +/** Try to identify the terminal emulator running a claude session by walking the process tree. + * label: + * null — already tracked (+button --session-id agent); skip entirely + * VSCODE_TERMINAL_SESSION — VS Code terminal session; claudePid is set for terminal matching + * string (other) — external/extension name (create headless agent with that label) + * undefined — ps error; caller should create headless as a safe fallback */ +/** Parse `ps etime` string ([[DD-]hh:]mm:ss) to total seconds. */ +function parseElapsedSeconds(etime: string): number { + const parts = etime.trim().split(':'); + if (parts.length === 3) { + const hPart = parts[0]; + const h = hPart.includes('-') + ? hPart.split('-').reduce((s, p, i) => s + parseInt(p) * (i === 0 ? 86400 : 3600), 0) + : parseInt(hPart) * 3600; + return h + parseInt(parts[1]) * 60 + parseInt(parts[2]); + } + if (parts.length === 2) return parseInt(parts[0]) * 60 + parseInt(parts[1]); + return isNaN(parseInt(etime.trim())) ? Infinity : parseInt(etime.trim()); +} + +function detectExternalTerminalName( + jsonlFile: string, + usedClaudePids: Set = new Set(), +): DetectionResult { + const fileBase = path.basename(jsonlFile, '.jsonl').slice(0, 8); // short ID for logs + try { + const sessionId = path.basename(jsonlFile, '.jsonl'); + // ps auxww: wide output so long arg lists (e.g. --output-format) aren't truncated + const psOutput = execSync('ps auxww', { encoding: 'utf-8', timeout: 2000 }); + const lines = psOutput.split('\n'); + + // Fast path: claude process that explicitly carries this session UUID in its args. + const uuidLine = lines.find((l) => l.includes('claude') && l.includes(sessionId)); + if (uuidLine) { + if (uuidLine.includes('--session-id')) { + console.log(`[Pixel Agents] detect ${fileBase}: uuid match → tracked (+button), skip`); + return { label: null }; + } + if (uuidLine.includes('--output-format')) { + console.log(`[Pixel Agents] detect ${fileBase}: uuid match → --output-format → Ext`); + return { label: EXTERNAL_AGENT_FOLDER_NAME }; + } + const pid = parseInt(uuidLine.trim().split(/\s+/)[1]); + if (!pid || isNaN(pid)) return { label: EXTERNAL_AGENT_FOLDER_NAME }; + const label = treeClassify(pid); + console.log(`[Pixel Agents] detect ${fileBase}: uuid match pid=${pid} → ${label}`); + return { label, claudePid: label === VSCODE_TERMINAL_SESSION ? pid : undefined }; + } + + // UUID not in args — bare `claude`, extension, or external terminal. + const candidateLines = lines.filter( + (l) => l.includes(' claude') && !l.includes('--session-id') && !l.includes('grep'), + ); + console.log( + `[Pixel Agents] detect ${fileBase}: no uuid match, candidates=${candidateLines.length} [${candidateLines.map((l) => l.trim().split(/\s+/)[1]).join(',')}]`, + ); + if (candidateLines.length === 0) return { label: undefined }; + + // Primary: lsof -a -p exits 0 iff that specific process has the file open. + // (-a = AND both filters; without -a lsof uses OR and always matches something) + for (const l of candidateLines) { + const pid = parseInt(l.trim().split(/\s+/)[1]); + if (!pid || isNaN(pid)) continue; + try { + execSync(`lsof -a -p ${pid} "${jsonlFile}"`, { encoding: 'utf-8', timeout: 2000 }); + const hasOutputFormat = l.includes('--output-format'); + console.log( + `[Pixel Agents] detect ${fileBase}: lsof hit pid=${pid} outputFmt=${hasOutputFormat}`, + ); + if (hasOutputFormat) return { label: EXTERNAL_AGENT_FOLDER_NAME }; + const label = treeClassify(pid); + console.log(`[Pixel Agents] detect ${fileBase}: tree pid=${pid} → ${label}`); + return { label, claudePid: label === VSCODE_TERMINAL_SESSION ? pid : undefined }; + } catch { + /* file not open by this process */ + } + } + console.log(`[Pixel Agents] detect ${fileBase}: lsof found nothing — using fallback`); + + // Fallback: classify by process tree heuristics. + const extensionLines = candidateLines.filter((l) => l.includes('--output-format')); + const bareLines = candidateLines.filter((l) => !l.includes('--output-format')); + console.log( + `[Pixel Agents] detect ${fileBase}: fallback ext=${extensionLines.length} bare=${bareLines.length}`, + ); + + // Both extension and bare claudes running → can't attribute without lsof → headless. + if (extensionLines.length > 0 && bareLines.length > 0) { + console.log(`[Pixel Agents] detect ${fileBase}: both types → Ext (ambiguous)`); + return { label: EXTERNAL_AGENT_FOLDER_NAME }; + } + + // Only bare claudes — sort by elapsed time (most recent first) to pick the newest. + // Skip PIDs already matched to existing agents so they don't steal future JSONLs. + // Also skip processes far older than the JSONL file itself — they can't be the owner. + let fileAgeSec = 0; + try { + fileAgeSec = (Date.now() - fs.statSync(jsonlFile).mtimeMs) / 1000; + } catch { + /* use 0 if stat fails */ + } + const sortedBare = bareLines + .map((l) => { + const pid = parseInt(l.trim().split(/\s+/)[1]); + if (!pid || isNaN(pid)) return null; + if (usedClaudePids.has(pid)) return null; // already matched to another agent + try { + const etime = execSync(`ps -p ${pid} -o etime=`, { + encoding: 'utf-8', + timeout: 500, + }).trim(); + const elapsed = parseElapsedSeconds(etime); + // If the process is much older than the file (> 5 min tolerance), it can't be + // the owner of a newly-created session. This filters out long-lived extension + // processes (e.g. claude binary running inside Code Helper for 2+ days). + if (elapsed > fileAgeSec + 300) { + console.log( + `[Pixel Agents] detect ${fileBase}: bare pid=${pid} elapsed=${elapsed}s too old vs file age ${fileAgeSec}s, skip`, + ); + return null; + } + return { l, pid, elapsed }; + } catch { + return { l, pid, elapsed: Infinity }; + } + }) + .filter((x): x is { l: string; pid: number; elapsed: number } => x !== null) + .sort((a, b) => a.elapsed - b.elapsed); // ascending = most recent first + + for (const { l, pid, elapsed } of sortedBare) { + const result = walkProcessTree(pid); + console.log( + `[Pixel Agents] detect ${fileBase}: bare pid=${pid} elapsed=${elapsed}s tree=${result}`, + ); + if (result === 'vscode_terminal') return { label: VSCODE_TERMINAL_SESSION, claudePid: pid }; + if (result === 'ignore') continue; + if (result !== null) return { label: result }; + } + + if (extensionLines.length > 0) { + console.log(`[Pixel Agents] detect ${fileBase}: only extension → Ext`); + return { label: EXTERNAL_AGENT_FOLDER_NAME }; + } + if (bareLines.length > 0) { + console.log(`[Pixel Agents] detect ${fileBase}: bare no match → Ext fallback`); + return { label: EXTERNAL_AGENT_FOLDER_NAME }; + } + + return { label: undefined }; + } catch { + return { label: undefined }; + } +} + +/** Walk the claude process's ancestor chain, then find the VS Code terminal whose shell + * PID is in that chain. Returns null if no match (caller falls back to first unowned). */ +async function findOwningTerminal( + claudePid: number, + agents: Map, +): Promise { + const ancestors = new Set(); + let p = claudePid; + for (let i = 0; i < 12; i++) { + try { + const ppid = parseInt( + execSync(`ps -p ${p} -o ppid=`, { encoding: 'utf-8', timeout: 500 }).trim(), + ); + if (!ppid || isNaN(ppid) || ppid <= 1 || ppid === p) break; + ancestors.add(ppid); + p = ppid; + } catch { + break; + } + } + + // Build set of shell PIDs already owned by existing agents by looking up + // the parent PID (shell) of each agent's tracked claude process. + // This works even when terminalRef objects are stale after restoreAgents(). + const ownedShellPids = new Set(); + for (const agent of agents.values()) { + if (!agent.claudePid) continue; + try { + const ppid = parseInt( + execSync(`ps -p ${agent.claudePid} -o ppid=`, { encoding: 'utf-8', timeout: 500 }).trim(), + ); + if (ppid && !isNaN(ppid)) ownedShellPids.add(ppid); + } catch { + /* process may be gone */ + } + } + + for (const terminal of vscode.window.terminals) { + const shellPid = await terminal.processId; + const isOwnedByRef = [...agents.values()].some((a) => a.terminalRef === terminal); + const isOwnedByPid = shellPid !== undefined && ownedShellPids.has(shellPid); + console.log( + `[Pixel Agents] findOwning: terminal="${terminal.name}" shellPid=${shellPid} ownedByRef=${isOwnedByRef} ownedByPid=${isOwnedByPid} inAncestors=${shellPid !== undefined && ancestors.has(shellPid)}`, + ); + if (isOwnedByRef || isOwnedByPid) continue; + if (shellPid !== undefined && ancestors.has(shellPid)) return terminal; + } + console.log( + `[Pixel Agents] findOwning: no terminal matched ancestors=[${[...ancestors].join(',')}]`, + ); + return null; +} + export function startFileWatching( agentId: number, filePath: string, @@ -110,21 +399,32 @@ 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, skipping large+recent ones (adoptExistingJsonlFiles handles those) try { const files = fs .readdirSync(projectDir) .filter((f) => f.endsWith('.jsonl')) .map((f) => path.join(projectDir, f)); + const now = Date.now(); for (const f of files) { - knownJsonlFiles.add(f); + if (knownJsonlFiles.has(f)) continue; // already tracked (e.g. by adoptExistingJsonlFiles) + try { + const stat = fs.statSync(f); + const isRecent = now - stat.mtimeMs < ADOPT_RECENT_FILE_THRESHOLD_MS; + const isLarge = stat.size >= ADOPT_MIN_FILE_SIZE_BYTES; + if (!(isLarge && isRecent)) { + knownJsonlFiles.add(f); + } + } catch { + knownJsonlFiles.add(f); // if can't stat, seed it anyway + } } } catch { /* dir may not exist yet */ } projectScanTimerRef.current = setInterval(() => { - scanForNewJsonlFiles( + void scanForNewJsonlFiles( projectDir, knownJsonlFiles, activeAgentIdRef, @@ -140,7 +440,7 @@ export function ensureProjectScan( }, PROJECT_SCAN_INTERVAL_MS); } -function scanForNewJsonlFiles( +async function scanForNewJsonlFiles( projectDir: string, knownJsonlFiles: Set, activeAgentIdRef: { current: number | null }, @@ -152,7 +452,7 @@ function scanForNewJsonlFiles( permissionTimers: Map>, webview: vscode.Webview | undefined, persistAgents: () => void, -): void { +): Promise { let files: string[]; try { files = fs @@ -163,60 +463,122 @@ function scanForNewJsonlFiles( return; } + // Build set of claude PIDs already matched to existing agents so we don't re-match them. + const usedClaudePids = new Set( + [...agents.values()].map((a) => a.claudePid).filter((p): p is number => p !== undefined), + ); + for (const file of files) { if (!knownJsonlFiles.has(file)) { knownJsonlFiles.add(file); - if (activeAgentIdRef.current !== null) { - // Active agent focused → /clear reassignment + + const { label: externalName, claudePid } = detectExternalTerminalName(file, usedClaudePids); + // Add to used set immediately so subsequent files in the same scan don't steal this PID. + if (claudePid !== undefined) usedClaudePids.add(claudePid); + + if (externalName === null) { + // Already tracked (+button --session-id agent) — ignore silently. + console.log(`[Pixel Agents] Ignoring tracked session: ${path.basename(file)}`); + } else if (externalName === VSCODE_TERMINAL_SESSION) { + // VS Code terminal running `claude` manually. + // Use claudePid to find the exact terminal by process ancestry, then fall + // back to first unowned terminal (e.g. /clear case with no new terminal). + let adoptedTerminal: vscode.Terminal | null = null; + if (claudePid !== undefined) { + adoptedTerminal = await findOwningTerminal(claudePid, agents); + } + if (adoptedTerminal === null) { + for (const t of vscode.window.terminals) { + let owned = false; + for (const agent of agents.values()) { + if (agent.terminalRef === t) { + owned = true; + break; + } + } + if (!owned) { + adoptedTerminal = t; + break; + } + } + } + + if (adoptedTerminal !== null) { + console.log( + `[Pixel Agents] New VS Code session: ${path.basename(file)}, adopting terminal "${adoptedTerminal.name}"`, + ); + adoptTerminalForFile( + adoptedTerminal, + file, + projectDir, + nextAgentIdRef, + agents, + activeAgentIdRef, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + 0, + undefined, + false, + claudePid, + ); + } else if (activeAgentIdRef.current !== null) { + // /clear — reassign active agent to the new file + console.log( + `[Pixel Agents] /clear detected: ${path.basename(file)}, reassigning agent ${activeAgentIdRef.current}`, + ); + reassignAgentToFile( + activeAgentIdRef.current, + file, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + } else { + // External terminal (Ghostty, iTerm2, etc.), extension session, or ps error. + let currentOffset = 0; + try { + currentOffset = fs.statSync(file).size; + } catch { + /* use 0 if stat fails */ + } + const label = externalName ?? EXTERNAL_AGENT_FOLDER_NAME; console.log( - `[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${activeAgentIdRef.current}`, + `[Pixel Agents] New external session: ${path.basename(file)} (${label}), offset=${currentOffset}`, ); - reassignAgentToFile( - activeAgentIdRef.current, + adoptTerminalForFile( + null, file, + projectDir, + nextAgentIdRef, agents, + activeAgentIdRef, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview, persistAgents, + currentOffset, + label, + false, + claudePid, ); - } else { - // No active agent → try to adopt the focused terminal - const activeTerminal = vscode.window.activeTerminal; - if (activeTerminal) { - let owned = false; - for (const agent of agents.values()) { - if (agent.terminalRef === activeTerminal) { - owned = true; - break; - } - } - if (!owned) { - adoptTerminalForFile( - activeTerminal, - file, - projectDir, - nextAgentIdRef, - agents, - activeAgentIdRef, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - persistAgents, - ); - } - } } } } } -function adoptTerminalForFile( - terminal: vscode.Terminal, +export function adoptTerminalForFile( + terminal: vscode.Terminal | null, jsonlFile: string, projectDir: string, nextAgentIdRef: { current: number }, @@ -228,14 +590,21 @@ function adoptTerminalForFile( permissionTimers: Map>, webview: vscode.Webview | undefined, persistAgents: () => void, + initialOffset = 0, + overrideFolderName?: string, + /** Skip agentCreated message — caller will notify webview via sendExistingAgents instead */ + silent = false, + claudePid?: number, ): void { const id = nextAgentIdRef.current++; + const isExternal = terminal === null; + const folderName = overrideFolderName ?? (isExternal ? EXTERNAL_AGENT_FOLDER_NAME : undefined); const agent: AgentState = { id, terminalRef: terminal, projectDir, jsonlFile, - fileOffset: 0, + fileOffset: initialOffset, lineBuffer: '', activeToolIds: new Set(), activeToolStatuses: new Map(), @@ -245,16 +614,29 @@ function adoptTerminalForFile( isWaiting: false, permissionSent: false, hadToolsInTurn: false, + isExternal: isExternal || undefined, + folderName, + claudePid, }; agents.set(id, agent); - activeAgentIdRef.current = id; + if (terminal !== null) { + activeAgentIdRef.current = id; + } persistAgents(); - console.log( - `[Pixel Agents] Agent ${id}: adopted terminal "${terminal.name}" for ${path.basename(jsonlFile)}`, - ); - webview?.postMessage({ type: 'agentCreated', id }); + if (isExternal) { + console.log( + `[Pixel Agents] Agent ${id}: adopted external session ${path.basename(jsonlFile)} (${folderName})`, + ); + } else { + console.log( + `[Pixel Agents] Agent ${id}: adopted terminal "${terminal!.name}" for ${path.basename(jsonlFile)}`, + ); + } + if (!silent) { + webview?.postMessage({ type: 'agentCreated', id, folderName }); + } startFileWatching( id, @@ -269,6 +651,82 @@ function adoptTerminalForFile( readNewLines(id, agents, waitingTimers, permissionTimers, webview); } +export function adoptExistingJsonlFiles( + projectDir: string, + knownJsonlFiles: Set, + nextAgentIdRef: { current: number }, + agents: Map, + activeAgentIdRef: { current: number | null }, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): void { + 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 f of files) { + if (knownJsonlFiles.has(f)) continue; // already tracked (restored agent) + try { + const stat = fs.statSync(f); + const isRecent = now - stat.mtimeMs < ADOPT_RECENT_FILE_THRESHOLD_MS; + const isLarge = stat.size >= ADOPT_MIN_FILE_SIZE_BYTES; + if (isLarge && isRecent) { + const { label: terminalName } = detectExternalTerminalName(f); + if (terminalName === null) { + // Already tracked (+button --session-id agent restored via restoreAgents) — skip. + knownJsonlFiles.add(f); + continue; + } + // At startup there are no terminals to adopt yet, so treat VSCODE_TERMINAL_SESSION + // the same as an external session (create headless Ext agent). + const resolvedName = + terminalName === undefined || terminalName === VSCODE_TERMINAL_SESSION + ? EXTERNAL_AGENT_FOLDER_NAME + : terminalName; + console.log( + `[Pixel Agents] Adopting pre-existing session: ${path.basename(f)} (${resolvedName})`, + ); + adoptTerminalForFile( + null, + f, + projectDir, + nextAgentIdRef, + agents, + activeAgentIdRef, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + stat.size, // skip history — only watch for new activity + resolvedName, + true, // silent — sendExistingAgents will notify the webview + ); + knownJsonlFiles.add(f); + } + } catch { + /* ignore stat errors */ + } + } + + // Seed all remaining files so the scan timer only reacts to truly new ones + for (const f of files) { + knownJsonlFiles.add(f); + } +} + export function reassignAgentToFile( agentId: number, newFilePath: string, diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index f462967a..6524f0a7 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -101,6 +101,7 @@ export function processTranscriptLine( id: agentId, toolId: block.id, status, + toolName, }); } } diff --git a/src/types.ts b/src/types.ts index feeec137..97c2490f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type * as vscode from 'vscode'; export interface AgentState { id: number; - terminalRef: vscode.Terminal; + terminalRef: vscode.Terminal | null; projectDir: string; jsonlFile: string; fileOffset: number; @@ -15,8 +15,14 @@ export interface AgentState { isWaiting: boolean; permissionSent: boolean; hadToolsInTurn: boolean; - /** Workspace folder name (only set for multi-root workspaces) */ + /** Workspace folder name (only set for multi-root workspaces, worktrees, or external agents) */ folderName?: string; + /** True for agents started outside VS Code (e.g. Ghostty) — no terminalRef */ + isExternal?: boolean; + /** Path of git worktree directory, if agent was launched in a worktree */ + worktreePath?: string; + /** PID of the claude process that owns this agent's JSONL (used to avoid re-matching) */ + claudePid?: number; } export interface PersistedAgent { @@ -24,6 +30,12 @@ export interface PersistedAgent { terminalName: string; jsonlFile: string; projectDir: string; - /** Workspace folder name (only set for multi-root workspaces) */ + /** Workspace folder name (only set for multi-root workspaces, worktrees, or external agents) */ folderName?: string; + /** True for agents started outside VS Code (e.g. Ghostty) — no terminalRef */ + isExternal?: boolean; + /** Path of git worktree directory, if agent was launched in a worktree */ + worktreePath?: string; + /** PID of the claude process that owns this agent's JSONL (used to avoid re-matching) */ + claudePid?: number; } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index ed75ef18..f4608f5d 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -138,6 +138,7 @@ function App() { layoutWasReset, loadedAssets, workspaceFolders, + isGitRepo, } = useExtensionMessages(getOfficeState, editor.setLastSavedLayout, isEditDirty); // Show migration notice once layout reset is detected @@ -271,6 +272,7 @@ function App() { isDebugMode={isDebugMode} onToggleDebugMode={handleToggleDebugMode} workspaceFolders={workspaceFolders} + isGitRepo={isGitRepo} /> {editor.isEditMode && editor.isDirty && ( diff --git a/webview-ui/src/components/BottomToolbar.tsx b/webview-ui/src/components/BottomToolbar.tsx index 92744c74..6f503e27 100644 --- a/webview-ui/src/components/BottomToolbar.tsx +++ b/webview-ui/src/components/BottomToolbar.tsx @@ -11,6 +11,7 @@ interface BottomToolbarProps { isDebugMode: boolean; onToggleDebugMode: () => void; workspaceFolders: WorkspaceFolder[]; + isGitRepo?: boolean; } const panelStyle: React.CSSProperties = { @@ -51,6 +52,7 @@ export function BottomToolbar({ isDebugMode, onToggleDebugMode, workspaceFolders, + isGitRepo, }: BottomToolbarProps) { const [hovered, setHovered] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -146,6 +148,21 @@ export function BottomToolbar({ )} + {isGitRepo && ( + + )}