diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 3cc0c459..a6cffd4f 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -23,7 +23,13 @@ import { sendFloorTilesToWebview, sendWallTilesToWebview, } from './assetLoader.js'; -import { GLOBAL_KEY_SOUND_ENABLED, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; +import { + AGENT_PROVIDERS, + type AgentProvider, + GLOBAL_KEY_AGENT_PROVIDER, + GLOBAL_KEY_SOUND_ENABLED, + WORKSPACE_KEY_AGENT_SEATS, +} from './constants.js'; import { ensureProjectScan } from './fileWatcher.js'; import type { LayoutWatcher } from './layoutPersistence.js'; import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js'; @@ -53,7 +59,15 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { // Cross-window layout sync layoutWatcher: LayoutWatcher | null = null; - constructor(private readonly context: vscode.ExtensionContext) {} + // Current agent provider (persisted in globalState) + agentProvider: AgentProvider; + + constructor(private readonly context: vscode.ExtensionContext) { + this.agentProvider = context.globalState.get( + GLOBAL_KEY_AGENT_PROVIDER, + AGENT_PROVIDERS.copilot, + ); + } private get extensionUri(): vscode.Uri { return this.context.extensionUri; @@ -73,8 +87,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = getWebviewContent(webviewView.webview, this.extensionUri); webviewView.webview.onDidReceiveMessage(async (message) => { - if (message.type === 'openClaude') { + if (message.type === 'openAgent') { await launchNewTerminal( + this.agentProvider, this.nextAgentId, this.nextTerminalIndex, this.agents, @@ -90,6 +105,37 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.persistAgents, message.folderPath as string | undefined, ); + } else if (message.type === 'setSoundEnabled') { + this.context.globalState.update(GLOBAL_KEY_SOUND_ENABLED, message.enabled); + } else if (message.type === 'setAgentProvider') { + const newProvider = message.provider as AgentProvider; + if (newProvider === 'copilot' || newProvider === 'claude') { + this.agentProvider = newProvider; + this.context.globalState.update(GLOBAL_KEY_AGENT_PROVIDER, newProvider); + // Restart scan for new provider + if (this.projectScanTimer.current) { + clearInterval(this.projectScanTimer.current); + this.projectScanTimer.current = null; + } + const newProjectDir = getProjectDirPath(newProvider); + if (newProjectDir) { + ensureProjectScan( + newProjectDir, + newProvider, + this.knownJsonlFiles, + this.projectScanTimer, + this.activeAgentId, + this.nextAgentId, + this.agents, + this.fileWatchers, + this.pollingTimers, + this.waitingTimers, + this.permissionTimers, + this.webview, + this.persistAgents, + ); + } + } } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); if (agent) { @@ -107,10 +153,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } else if (message.type === 'saveLayout') { this.layoutWatcher?.markOwnWrite(); writeLayoutToFile(message.layout as Record); - } else if (message.type === 'setSoundEnabled') { - this.context.globalState.update(GLOBAL_KEY_SOUND_ENABLED, message.enabled); } else if (message.type === 'webviewReady') { restoreAgents( + this.agentProvider, this.context, this.nextAgentId, this.nextTerminalIndex, @@ -128,7 +173,11 @@ 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 }); + this.webview?.postMessage({ + type: 'settingsLoaded', + soundEnabled, + agentProvider: this.agentProvider, + }); // Send workspace folders to webview (only when multi-root) const wsFolders = vscode.workspace.workspaceFolders; @@ -140,13 +189,14 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } // Ensure project scan runs even with no restored agents (to adopt external terminals) - const projectDir = getProjectDirPath(); + const projectDir = getProjectDirPath(this.agentProvider); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; console.log('[Extension] workspaceRoot:', workspaceRoot); console.log('[Extension] projectDir:', projectDir); if (projectDir) { ensureProjectScan( projectDir, + this.agentProvider, this.knownJsonlFiles, this.projectScanTimer, this.activeAgentId, @@ -262,7 +312,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } sendExistingAgents(this.agents, this.context, this.webview); } else if (message.type === 'openSessionsFolder') { - const projectDir = getProjectDirPath(); + const projectDir = getProjectDirPath(this.agentProvider); if (projectDir && fs.existsSync(projectDir)) { vscode.env.openExternal(vscode.Uri.file(projectDir)); } diff --git a/src/agentManager.ts b/src/agentManager.ts index 5d012711..10ff948b 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -4,8 +4,10 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { + type AgentProvider, + CLAUDE_TERMINAL_NAME_PREFIX, + COPILOT_TERMINAL_NAME_PREFIX, JSONL_POLL_INTERVAL_MS, - TERMINAL_NAME_PREFIX, WORKSPACE_KEY_AGENT_SEATS, WORKSPACE_KEY_AGENTS, } from './constants.js'; @@ -14,16 +16,19 @@ import { migrateAndLoadLayout } from './layoutPersistence.js'; import { cancelPermissionTimer, cancelWaitingTimer } from './timerManager.js'; import type { AgentState, PersistedAgent } from './types.js'; -export function getProjectDirPath(cwd?: string): string | null { - const workspacePath = cwd || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) return null; - const dirName = workspacePath.replace(/[^a-zA-Z0-9-]/g, '-'); - const projectDir = path.join(os.homedir(), '.claude', 'projects', dirName); - console.log(`[Pixel Agents] Project dir: ${workspacePath} → ${dirName}`); - return projectDir; +export function getProjectDirPath(provider: AgentProvider, cwd?: string): string | null { + if (provider === 'copilot') { + return path.join(os.homedir(), '.copilot', 'session-state'); + } else { + const workspacePath = cwd || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspacePath) return null; + const dirName = workspacePath.replace(/[^a-zA-Z0-9-]/g, '-'); + return path.join(os.homedir(), '.claude', 'projects', dirName); + } } export async function launchNewTerminal( + provider: AgentProvider, nextAgentIdRef: { current: number }, nextTerminalIndexRef: { current: number }, agents: Map, @@ -36,30 +41,40 @@ export async function launchNewTerminal( jsonlPollTimers: Map>, projectScanTimerRef: { current: ReturnType | null }, webview: vscode.Webview | undefined, - persistAgents: () => void, + persistAgentsFn: () => void, folderPath?: string, ): Promise { const folders = vscode.workspace.workspaceFolders; const cwd = folderPath || folders?.[0]?.uri.fsPath; const isMultiRoot = !!(folders && folders.length > 1); const idx = nextTerminalIndexRef.current++; + + const terminalPrefix = + provider === 'copilot' ? COPILOT_TERMINAL_NAME_PREFIX : CLAUDE_TERMINAL_NAME_PREFIX; const terminal = vscode.window.createTerminal({ - name: `${TERMINAL_NAME_PREFIX} #${idx}`, + name: `${terminalPrefix} #${idx}`, cwd, }); terminal.show(); const sessionId = crypto.randomUUID(); - terminal.sendText(`claude --session-id ${sessionId}`); - const projectDir = getProjectDirPath(cwd); + const projectDir = getProjectDirPath(provider, cwd); if (!projectDir) { - console.log(`[Pixel Agents] No project dir, cannot track agent`); + console.log(`[Pixel Agents] No project dir for provider ${provider}, cannot track agent`); return; } - // Pre-register expected JSONL file so project scan won't treat it as a /clear file - const expectedFile = path.join(projectDir, `${sessionId}.jsonl`); + let expectedFile: string; + if (provider === 'copilot') { + terminal.sendText(`copilot --resume ${sessionId}`); + expectedFile = path.join(projectDir, sessionId, 'events.jsonl'); + } else { + terminal.sendText(`claude --resume ${sessionId}`); + expectedFile = path.join(projectDir, `${sessionId}.jsonl`); + } + + // Pre-register expected JSONL file so project scan won't treat it as an adopted file knownJsonlFiles.add(expectedFile); // Create agent immediately (before JSONL file exists) @@ -67,6 +82,7 @@ export async function launchNewTerminal( const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; const agent: AgentState = { id, + provider, terminalRef: terminal, projectDir, jsonlFile: expectedFile, @@ -85,12 +101,13 @@ export async function launchNewTerminal( agents.set(id, agent); activeAgentIdRef.current = id; - persistAgents(); - console.log(`[Pixel Agents] Agent ${id}: created for terminal ${terminal.name}`); + persistAgentsFn(); + console.log(`[Pixel Agents] Agent ${id} (${provider}): created for terminal ${terminal.name}`); webview?.postMessage({ type: 'agentCreated', id, folderName }); ensureProjectScan( projectDir, + provider, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, @@ -101,7 +118,7 @@ export async function launchNewTerminal( waitingTimers, permissionTimers, webview, - persistAgents, + persistAgentsFn, ); // Poll for the specific JSONL file to appear @@ -140,19 +157,17 @@ export function removeAgent( waitingTimers: Map>, permissionTimers: Map>, jsonlPollTimers: Map>, - persistAgents: () => void, + persistAgentsFn: () => void, ): void { const agent = agents.get(agentId); if (!agent) return; - // Stop JSONL poll timer const jpTimer = jsonlPollTimers.get(agentId); if (jpTimer) { clearInterval(jpTimer); } jsonlPollTimers.delete(agentId); - // Stop file watching fileWatchers.get(agentId)?.close(); fileWatchers.delete(agentId); const pt = pollingTimers.get(agentId); @@ -166,13 +181,11 @@ export function removeAgent( /* ignore */ } - // Cancel timers cancelWaitingTimer(agentId, waitingTimers); cancelPermissionTimer(agentId, permissionTimers); - // Remove from maps agents.delete(agentId); - persistAgents(); + persistAgentsFn(); } export function persistAgents( @@ -183,6 +196,7 @@ export function persistAgents( for (const agent of agents.values()) { persisted.push({ id: agent.id, + provider: agent.provider, terminalName: agent.terminalRef.name, jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, @@ -193,6 +207,7 @@ export function persistAgents( } export function restoreAgents( + agentProvider: AgentProvider, context: vscode.ExtensionContext, nextAgentIdRef: { current: number }, nextTerminalIndexRef: { current: number }, @@ -209,19 +224,45 @@ export function restoreAgents( doPersist: () => void, ): void { const persisted = context.workspaceState.get(WORKSPACE_KEY_AGENTS, []); - if (persisted.length === 0) return; + + const startScan = () => { + const projectDir = getProjectDirPath(agentProvider); + if (projectDir) { + ensureProjectScan( + projectDir, + agentProvider, + knownJsonlFiles, + projectScanTimerRef, + activeAgentIdRef, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + doPersist, + ); + } + }; + + if (persisted.length === 0) { + startScan(); + return; + } const liveTerminals = vscode.window.terminals; let maxId = 0; let maxIdx = 0; - let restoredProjectDir: string | null = null; for (const p of persisted) { const terminal = liveTerminals.find((t) => t.name === p.terminalName); if (!terminal) continue; + const provider: AgentProvider = p.provider ?? 'copilot'; const agent: AgentState = { id: p.id, + provider, terminalRef: terminal, projectDir: p.projectDir, jsonlFile: p.jsonlFile, @@ -240,18 +281,18 @@ export function restoreAgents( 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} (${provider}) -> terminal "${p.terminalName}"`, + ); if (p.id > maxId) maxId = p.id; - // Extract terminal index from name like "Claude Code #3" + // Extract terminal index from name like "GitHub Copilot #3" or "Claude Code #3" const match = p.terminalName.match(/#(\d+)$/); if (match) { const idx = parseInt(match[1], 10); if (idx > maxIdx) maxIdx = idx; } - restoredProjectDir = p.projectDir; - // Start file watching if JSONL exists, skipping to end of file try { if (fs.existsSync(p.jsonlFile)) { @@ -268,7 +309,6 @@ export function restoreAgents( webview, ); } else { - // Poll for the file to appear const pollTimer = setInterval(() => { try { if (fs.existsSync(agent.jsonlFile)) { @@ -299,7 +339,6 @@ export function restoreAgents( } } - // Advance counters past restored IDs if (maxId >= nextAgentIdRef.current) { nextAgentIdRef.current = maxId + 1; } @@ -310,23 +349,7 @@ export function restoreAgents( // Re-persist cleaned-up list (removes entries whose terminals are gone) doPersist(); - // Start project scan for /clear detection - if (restoredProjectDir) { - ensureProjectScan( - restoredProjectDir, - knownJsonlFiles, - projectScanTimerRef, - activeAgentIdRef, - nextAgentIdRef, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - doPersist, - ); - } + startScan(); } export function sendExistingAgents( @@ -341,12 +364,10 @@ export function sendExistingAgents( } agentIds.sort((a, b) => a - b); - // Include persisted palette/seatId from separate key const agentMeta = context.workspaceState.get< Record >(WORKSPACE_KEY_AGENT_SEATS, {}); - // Include folderName per agent const folderNames: Record = {}; for (const [id, agent] of agents) { if (agent.folderName) { @@ -373,7 +394,6 @@ export function sendCurrentAgentStatuses( ): void { if (!webview) return; for (const [agentId, agent] of agents) { - // Re-send active tools for (const [toolId, status] of agent.activeToolStatuses) { webview.postMessage({ type: 'agentToolStart', @@ -382,7 +402,6 @@ export function sendCurrentAgentStatuses( status, }); } - // Re-send waiting status if (agent.isWaiting) { webview.postMessage({ type: 'agentStatus', diff --git a/src/claudeTranscriptParser.ts b/src/claudeTranscriptParser.ts new file mode 100644 index 00000000..ab4741a7 --- /dev/null +++ b/src/claudeTranscriptParser.ts @@ -0,0 +1,334 @@ +import * as path from 'path'; +import type * as vscode from 'vscode'; + +import { + BASH_COMMAND_DISPLAY_MAX_LENGTH, + TASK_DESCRIPTION_DISPLAY_MAX_LENGTH, + TEXT_IDLE_DELAY_MS, + TOOL_DONE_DELAY_MS, +} from './constants.js'; +import { + cancelPermissionTimer, + cancelWaitingTimer, + clearAgentActivity, + startPermissionTimer, + startWaitingTimer, +} from './timerManager.js'; +import type { AgentState } from './types.js'; + +export const CLAUDE_PERMISSION_EXEMPT_TOOLS = new Set(['Task', 'AskUserQuestion']); + +function formatClaudeToolStatus(toolName: string, input: Record): string { + const base = (p: unknown) => (typeof p === 'string' ? path.basename(p) : ''); + switch (toolName) { + case 'Read': + return `Reading ${base(input.file_path)}`; + case 'Edit': + return `Editing ${base(input.file_path)}`; + case 'Write': + return `Writing ${base(input.file_path)}`; + case 'Bash': { + const cmd = (input.command as string) || ''; + return `Running: ${cmd.length > BASH_COMMAND_DISPLAY_MAX_LENGTH ? cmd.slice(0, BASH_COMMAND_DISPLAY_MAX_LENGTH) + '\u2026' : cmd}`; + } + case 'Glob': + return 'Searching files'; + case 'Grep': + return 'Searching code'; + case 'WebFetch': + return 'Fetching web content'; + case 'WebSearch': + return 'Searching the web'; + case 'Task': { + const desc = typeof input.description === 'string' ? input.description : ''; + return desc + ? `Subtask: ${desc.length > TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + '\u2026' : desc}` + : 'Running subtask'; + } + case 'AskUserQuestion': + return 'Waiting for your answer'; + case 'EnterPlanMode': + return 'Planning'; + case 'NotebookEdit': + return 'Editing notebook'; + default: + return `Using ${toolName}`; + } +} + +export function processClaudeTranscriptLine( + agentId: number, + line: string, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + const agent = agents.get(agentId); + if (!agent) return; + try { + const record = JSON.parse(line) as Record; + + if ( + record.type === 'assistant' && + Array.isArray((record.message as Record)?.content) + ) { + const blocks = (record.message as Record).content as Array<{ + type: string; + id?: string; + name?: string; + input?: Record; + }>; + const hasToolUse = blocks.some((b) => b.type === 'tool_use'); + + if (hasToolUse) { + cancelWaitingTimer(agentId, waitingTimers); + agent.isWaiting = false; + agent.hadToolsInTurn = true; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + let hasNonExemptTool = false; + for (const block of blocks) { + if (block.type === 'tool_use' && block.id) { + const toolName = block.name || ''; + const status = formatClaudeToolStatus(toolName, block.input || {}); + console.log(`[Pixel Agents] Agent ${agentId} tool start: ${block.id} ${status}`); + agent.activeToolIds.add(block.id); + agent.activeToolStatuses.set(block.id, status); + agent.activeToolNames.set(block.id, toolName); + if (!CLAUDE_PERMISSION_EXEMPT_TOOLS.has(toolName)) { + hasNonExemptTool = true; + } + webview?.postMessage({ + type: 'agentToolStart', + id: agentId, + toolId: block.id, + status, + }); + } + } + if (hasNonExemptTool) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + CLAUDE_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + } else if (blocks.some((b) => b.type === 'text') && !agent.hadToolsInTurn) { + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } + } else if (record.type === 'progress') { + processClaudeProgressRecord(agentId, record, agents, permissionTimers, webview); + } else if (record.type === 'user') { + const content = (record.message as Record)?.content; + if (Array.isArray(content)) { + const blocks = content as Array<{ type: string; tool_use_id?: string }>; + const hasToolResult = blocks.some((b) => b.type === 'tool_result'); + if (hasToolResult) { + for (const block of blocks) { + 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 (agent.activeToolNames.get(completedToolId) === 'Task') { + agent.activeSubagentToolIds.delete(completedToolId); + agent.activeSubagentToolNames.delete(completedToolId); + webview?.postMessage({ + type: 'subagentClear', + id: agentId, + parentToolId: completedToolId, + }); + } + agent.activeToolIds.delete(completedToolId); + agent.activeToolStatuses.delete(completedToolId); + agent.activeToolNames.delete(completedToolId); + const toolId = completedToolId; + setTimeout(() => { + webview?.postMessage({ + type: 'agentToolDone', + id: agentId, + toolId, + }); + }, TOOL_DONE_DELAY_MS); + } + } + if (agent.activeToolIds.size === 0) { + agent.hadToolsInTurn = false; + } + } else { + cancelWaitingTimer(agentId, waitingTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + } + } else if (typeof content === 'string' && content.trim()) { + cancelWaitingTimer(agentId, waitingTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + } + } else if (record.type === 'system' && record.subtype === 'turn_duration') { + cancelWaitingTimer(agentId, waitingTimers); + cancelPermissionTimer(agentId, permissionTimers); + + if (agent.activeToolIds.size > 0) { + agent.activeToolIds.clear(); + agent.activeToolStatuses.clear(); + agent.activeToolNames.clear(); + agent.activeSubagentToolIds.clear(); + agent.activeSubagentToolNames.clear(); + webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + } + + agent.isWaiting = true; + agent.permissionSent = false; + agent.hadToolsInTurn = false; + webview?.postMessage({ + type: 'agentStatus', + id: agentId, + status: 'waiting', + }); + } + } catch { + // Ignore malformed lines + } +} + +function processClaudeProgressRecord( + agentId: number, + record: Record, + agents: Map, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + const agent = agents.get(agentId); + if (!agent) return; + + const parentToolId = record.parentToolUseID as string | undefined; + if (!parentToolId) return; + + const data = record.data as Record | undefined; + if (!data) return; + + const dataType = data.type as string | undefined; + if (dataType === 'bash_progress' || dataType === 'mcp_progress') { + if (agent.activeToolIds.has(parentToolId)) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + CLAUDE_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + return; + } + + if (agent.activeToolNames.get(parentToolId) !== 'Task') return; + + const msg = data.message as Record | undefined; + if (!msg) return; + + const msgType = msg.type as string; + const innerMsg = msg.message as Record | undefined; + const content = innerMsg?.content; + if (!Array.isArray(content)) return; + + if (msgType === 'assistant') { + let hasNonExemptSubTool = false; + for (const block of content) { + if ( + (block as Record).type === 'tool_use' && + (block as Record).id + ) { + const b = block as Record; + const toolName = (b.name as string) || ''; + const status = formatClaudeToolStatus(toolName, (b.input as Record) || {}); + console.log( + `[Pixel Agents] Agent ${agentId} subagent tool start: ${b.id} ${status} (parent: ${parentToolId})`, + ); + + let subTools = agent.activeSubagentToolIds.get(parentToolId); + if (!subTools) { + subTools = new Set(); + agent.activeSubagentToolIds.set(parentToolId, subTools); + } + subTools.add(b.id as string); + + let subNames = agent.activeSubagentToolNames.get(parentToolId); + if (!subNames) { + subNames = new Map(); + agent.activeSubagentToolNames.set(parentToolId, subNames); + } + subNames.set(b.id as string, toolName); + + if (!CLAUDE_PERMISSION_EXEMPT_TOOLS.has(toolName)) { + hasNonExemptSubTool = true; + } + + webview?.postMessage({ + type: 'subagentToolStart', + id: agentId, + parentToolId, + toolId: b.id, + status, + }); + } + } + if (hasNonExemptSubTool) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + CLAUDE_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + } else if (msgType === 'user') { + for (const block of content) { + const b = block as Record; + if (b.type === 'tool_result' && b.tool_use_id) { + console.log( + `[Pixel Agents] Agent ${agentId} subagent tool done: ${b.tool_use_id} (parent: ${parentToolId})`, + ); + + const subTools = agent.activeSubagentToolIds.get(parentToolId); + if (subTools) { + subTools.delete(b.tool_use_id as string); + } + const subNames = agent.activeSubagentToolNames.get(parentToolId); + if (subNames) { + subNames.delete(b.tool_use_id as string); + } + + const toolId = b.tool_use_id as string; + setTimeout(() => { + webview?.postMessage({ + type: 'subagentToolDone', + id: agentId, + parentToolId, + toolId, + }); + }, TOOL_DONE_DELAY_MS); + } + } + + let stillHasNonExempt = false; + for (const [, subNames] of agent.activeSubagentToolNames) { + for (const [, toolName] of subNames) { + if (!CLAUDE_PERMISSION_EXEMPT_TOOLS.has(toolName)) { + stillHasNonExempt = true; + break; + } + } + if (stillHasNonExempt) break; + } + if (stillHasNonExempt) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + CLAUDE_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + } +} diff --git a/src/constants.ts b/src/constants.ts index 5e95c166..cc2e173e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,6 +29,13 @@ export const LAYOUT_FILE_DIR = '.pixel-agents'; export const LAYOUT_FILE_NAME = 'layout.json'; export const LAYOUT_FILE_POLL_INTERVAL_MS = 2000; +// ── Provider Selection ───────────────────────────────────── +export const AGENT_PROVIDERS = { copilot: 'copilot', claude: 'claude' } as const; +export type AgentProvider = (typeof AGENT_PROVIDERS)[keyof typeof AGENT_PROVIDERS]; +export const GLOBAL_KEY_AGENT_PROVIDER = 'pixel-agents.agentProvider'; +export const COPILOT_TERMINAL_NAME_PREFIX = 'GitHub Copilot'; +export const CLAUDE_TERMINAL_NAME_PREFIX = 'Claude Code'; + // ── Settings Persistence ──────────────────────────────────── export const GLOBAL_KEY_SOUND_ENABLED = 'pixel-agents.soundEnabled'; @@ -39,4 +46,5 @@ export const COMMAND_EXPORT_DEFAULT_LAYOUT = 'pixel-agents.exportDefaultLayout'; 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 TERMINAL_NAME_PREFIX = 'GitHub Copilot'; // kept for backward-compat restoration matching +export const COPILOT_TURN_END_DELAY_MS = 500; diff --git a/src/copilotTranscriptParser.ts b/src/copilotTranscriptParser.ts new file mode 100644 index 00000000..265e5edb --- /dev/null +++ b/src/copilotTranscriptParser.ts @@ -0,0 +1,317 @@ +import * as path from 'path'; +import type * as vscode from 'vscode'; + +import { + BASH_COMMAND_DISPLAY_MAX_LENGTH, + COPILOT_TURN_END_DELAY_MS, + TASK_DESCRIPTION_DISPLAY_MAX_LENGTH, + TOOL_DONE_DELAY_MS, +} from './constants.js'; +import { + cancelPermissionTimer, + cancelWaitingTimer, + clearAgentActivity, + startPermissionTimer, + startWaitingTimer, +} from './timerManager.js'; +import type { AgentState } from './types.js'; + +export const COPILOT_PERMISSION_EXEMPT_TOOLS = new Set(['task', 'ask_user', 'report_intent']); + +function formatCopilotToolStatus(toolName: string, input: Record): string { + const base = (p: unknown) => (typeof p === 'string' ? path.basename(p) : ''); + switch (toolName) { + case 'view': + return `Reading ${base(input.path)}`; + case 'edit': + return `Editing ${base(input.path)}`; + case 'create': + return `Writing ${base(input.path)}`; + case 'powershell': { + const cmd = (input.command as string) || ''; + return `Running: ${cmd.length > BASH_COMMAND_DISPLAY_MAX_LENGTH ? cmd.slice(0, BASH_COMMAND_DISPLAY_MAX_LENGTH) + '\u2026' : cmd}`; + } + case 'glob': + return 'Searching files'; + case 'grep': + return 'Searching code'; + case 'web_fetch': + return 'Fetching web content'; + case 'task': { + const desc = typeof input.description === 'string' ? input.description : ''; + return desc + ? `Subtask: ${desc.length > TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + '\u2026' : desc}` + : 'Running subtask'; + } + case 'explore': + return 'Exploring'; + case 'ask_user': + return 'Waiting for your answer'; + case 'exit_plan_mode': + return 'Planning'; + case 'store_memory': + return 'Saving memory'; + case 'sql': + return 'Querying database'; + case 'task_complete': + return 'Completing task'; + case 'ide-get_diagnostics': + return 'Getting diagnostics'; + case 'ide-get_selection': + return 'Getting selection'; + default: + if (toolName.startsWith('github-mcp-server')) return 'Using GitHub API'; + return `Using ${toolName}`; + } +} + +export function processCopilotTranscriptLine( + agentId: number, + line: string, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + const agent = agents.get(agentId); + if (!agent) return; + try { + const record = JSON.parse(line) as Record; + const eventType = record.type as string; + const data = (record.data ?? {}) as Record; + + // Synthesize explore tool events if Copilot reports explore mode but emits no tool event + if ( + eventType === 'assistant.turn_start' && + !agent.isWaiting && + !agent.hadToolsInTurn && + (data['mode'] === 'explore' || data['toolName'] === 'explore') + ) { + webview?.postMessage({ + type: 'agentToolStart', + id: agentId, + toolId: 'explore_synth', + status: 'Exploring', + }); + agent.activeToolIds.add('explore_synth'); + agent.activeToolStatuses.set('explore_synth', 'Exploring'); + agent.activeToolNames.set('explore_synth', 'explore'); + agent.hadToolsInTurn = true; + } + + switch (eventType) { + case 'user.message': { + cancelWaitingTimer(agentId, waitingTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + break; + } + + case 'assistant.turn_start': { + cancelWaitingTimer(agentId, waitingTimers); + agent.isWaiting = false; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + break; + } + + case 'assistant.turn_end': { + // Resolve synthesized explore tool before clearing + if (agent.activeToolIds.has('explore_synth')) { + agent.activeToolIds.delete('explore_synth'); + agent.activeToolStatuses.delete('explore_synth'); + agent.activeToolNames.delete('explore_synth'); + setTimeout(() => { + webview?.postMessage({ + type: 'agentToolDone', + id: agentId, + toolId: 'explore_synth', + }); + }, TOOL_DONE_DELAY_MS); + } + cancelPermissionTimer(agentId, permissionTimers); + startWaitingTimer(agentId, COPILOT_TURN_END_DELAY_MS, agents, waitingTimers, webview); + if (agent.activeToolIds.size > 0) { + agent.activeToolIds.clear(); + agent.activeToolStatuses.clear(); + agent.activeToolNames.clear(); + agent.activeSubagentToolIds.clear(); + agent.activeSubagentToolNames.clear(); + webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + } + agent.hadToolsInTurn = false; + break; + } + + case 'tool.execution_start': { + const toolCallId = data.toolCallId as string | undefined; + const toolName = (data.toolName as string) || ''; + const toolInput = (data.arguments as Record) || {}; + const parentToolCallId = data.parentToolCallId as string | undefined; + + if (!toolCallId) break; + + if (parentToolCallId) { + if (agent.activeToolNames.get(parentToolCallId) !== 'task') break; + + const status = formatCopilotToolStatus(toolName, toolInput); + let subTools = agent.activeSubagentToolIds.get(parentToolCallId); + if (!subTools) { + subTools = new Set(); + agent.activeSubagentToolIds.set(parentToolCallId, subTools); + } + subTools.add(toolCallId); + + let subNames = agent.activeSubagentToolNames.get(parentToolCallId); + if (!subNames) { + subNames = new Map(); + agent.activeSubagentToolNames.set(parentToolCallId, subNames); + } + subNames.set(toolCallId, toolName); + + if (!COPILOT_PERMISSION_EXEMPT_TOOLS.has(toolName)) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + COPILOT_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + + webview?.postMessage({ + type: 'subagentToolStart', + id: agentId, + parentToolId: parentToolCallId, + toolId: toolCallId, + status, + }); + } else { + cancelWaitingTimer(agentId, waitingTimers); + agent.isWaiting = false; + agent.hadToolsInTurn = true; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + + const status = formatCopilotToolStatus(toolName, toolInput); + console.log(`[Pixel Agents] Agent ${agentId} tool start: ${toolCallId} ${status}`); + agent.activeToolIds.add(toolCallId); + agent.activeToolStatuses.set(toolCallId, status); + agent.activeToolNames.set(toolCallId, toolName); + + if (!COPILOT_PERMISSION_EXEMPT_TOOLS.has(toolName)) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + COPILOT_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + + webview?.postMessage({ + type: 'agentToolStart', + id: agentId, + toolId: toolCallId, + status, + }); + } + break; + } + + case 'tool.execution_complete': { + const toolCallId = data.toolCallId as string | undefined; + const parentToolCallId = data.parentToolCallId as string | undefined; + + if (!toolCallId) break; + + if (parentToolCallId) { + const subTools = agent.activeSubagentToolIds.get(parentToolCallId); + if (subTools) subTools.delete(toolCallId); + const subNames = agent.activeSubagentToolNames.get(parentToolCallId); + if (subNames) subNames.delete(toolCallId); + + const tid = toolCallId; + setTimeout(() => { + webview?.postMessage({ + type: 'subagentToolDone', + id: agentId, + parentToolId: parentToolCallId, + toolId: tid, + }); + }, TOOL_DONE_DELAY_MS); + + let stillHasNonExempt = false; + for (const [, names] of agent.activeSubagentToolNames) { + for (const [, name] of names) { + if (!COPILOT_PERMISSION_EXEMPT_TOOLS.has(name)) { + stillHasNonExempt = true; + break; + } + } + if (stillHasNonExempt) break; + } + if (stillHasNonExempt) { + startPermissionTimer( + agentId, + agents, + permissionTimers, + COPILOT_PERMISSION_EXEMPT_TOOLS, + webview, + ); + } + } else { + console.log(`[Pixel Agents] Agent ${agentId} tool done: ${toolCallId}`); + + if (agent.activeToolNames.get(toolCallId) === 'task') { + agent.activeSubagentToolIds.delete(toolCallId); + agent.activeSubagentToolNames.delete(toolCallId); + webview?.postMessage({ + type: 'subagentClear', + id: agentId, + parentToolId: toolCallId, + }); + } + + agent.activeToolIds.delete(toolCallId); + agent.activeToolStatuses.delete(toolCallId); + agent.activeToolNames.delete(toolCallId); + + const tid = toolCallId; + setTimeout(() => { + webview?.postMessage({ + type: 'agentToolDone', + id: agentId, + toolId: tid, + }); + }, TOOL_DONE_DELAY_MS); + } + break; + } + + case 'subagent.completed': { + const toolCallId = data.toolCallId as string | undefined; + if (!toolCallId) break; + agent.activeSubagentToolIds.delete(toolCallId); + agent.activeSubagentToolNames.delete(toolCallId); + webview?.postMessage({ + type: 'subagentClear', + id: agentId, + parentToolId: toolCallId, + }); + break; + } + + case 'abort': { + cancelWaitingTimer(agentId, waitingTimers); + cancelPermissionTimer(agentId, permissionTimers); + clearAgentActivity(agent, agentId, permissionTimers, webview); + agent.hadToolsInTurn = false; + break; + } + + default: + break; + } + } catch { + // Ignore malformed lines + } +} diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index f2a60dfd..6e133399 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -2,7 +2,11 @@ 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 { + type AgentProvider, + 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'; @@ -96,7 +100,8 @@ export function readNewLines( } export function ensureProjectScan( - projectDir: string, + sessionDir: string, + provider: AgentProvider, knownJsonlFiles: Set, projectScanTimerRef: { current: ReturnType | null }, activeAgentIdRef: { current: number | null }, @@ -110,36 +115,169 @@ 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 known files so we only react to truly new ones try { - const files = fs - .readdirSync(projectDir) - .filter((f) => f.endsWith('.jsonl')) - .map((f) => path.join(projectDir, f)); - for (const f of files) { - knownJsonlFiles.add(f); + if (provider === 'copilot') { + const subdirs = fs.readdirSync(sessionDir, { withFileTypes: true }); + for (const entry of subdirs) { + if (entry.isDirectory()) { + const eventsFile = path.join(sessionDir, entry.name, 'events.jsonl'); + if (fs.existsSync(eventsFile)) { + knownJsonlFiles.add(eventsFile); + } + } + } + } else { + const files = fs + .readdirSync(sessionDir) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => path.join(sessionDir, f)); + for (const f of files) { + knownJsonlFiles.add(f); + } } } catch { /* dir may not exist yet */ } projectScanTimerRef.current = setInterval(() => { - scanForNewJsonlFiles( - projectDir, - knownJsonlFiles, - activeAgentIdRef, - nextAgentIdRef, - agents, - fileWatchers, - pollingTimers, - waitingTimers, - permissionTimers, - webview, - persistAgents, - ); + if (provider === 'copilot') { + scanForNewSessionFiles( + sessionDir, + knownJsonlFiles, + activeAgentIdRef, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } else { + scanForNewJsonlFiles( + sessionDir, + knownJsonlFiles, + activeAgentIdRef, + nextAgentIdRef, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } }, PROJECT_SCAN_INTERVAL_MS); } +// ── Copilot: scan nested subdirectories for new events.jsonl files ────────── + +function scanForNewSessionFiles( + sessionStateDir: string, + knownJsonlFiles: Set, + activeAgentIdRef: { current: number | null }, + nextAgentIdRef: { current: number }, + agents: Map, + fileWatchers: Map, + pollingTimers: Map>, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, + persistAgents: () => void, +): void { + let subdirs: fs.Dirent[]; + try { + subdirs = fs.readdirSync(sessionStateDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of subdirs) { + if (!entry.isDirectory()) continue; + const eventsFile = path.join(sessionStateDir, entry.name, 'events.jsonl'); + if (!knownJsonlFiles.has(eventsFile)) { + try { + if (!fs.existsSync(eventsFile)) continue; + } catch { + continue; + } + knownJsonlFiles.add(eventsFile); + + // Check if any agent has an active 'task' tool — if so, this new session + // is a sub-agent spawned by that tool. The virtual sub-agent character created + // from the parent's agentToolStart ("Subtask: ...") handles display; don't + // reassign the main agent or it will kill that tracking. + let hasActiveTask = false; + for (const agent of agents.values()) { + for (const toolName of agent.activeToolNames.values()) { + if (toolName === 'task') { + hasActiveTask = true; + break; + } + } + if (hasActiveTask) break; + } + + if (hasActiveTask) { + console.log( + `[Pixel Agents] New session detected: ${entry.name} — skipping (sub-agent of active task tool)`, + ); + } else if (activeAgentIdRef.current !== null) { + // Active agent focused + no task running → session switch (e.g. /resume) + console.log( + `[Pixel Agents] New session detected: ${entry.name}, reassigning to agent ${activeAgentIdRef.current}`, + ); + reassignAgentToFile( + activeAgentIdRef.current, + eventsFile, + agents, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } 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, + eventsFile, + sessionStateDir, + 'copilot', + nextAgentIdRef, + agents, + activeAgentIdRef, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + webview, + persistAgents, + ); + } + } + } + } + } +} + +// ── Claude: scan flat directory for new .jsonl files ──────────────────────── + function scanForNewJsonlFiles( projectDir: string, knownJsonlFiles: Set, @@ -198,6 +336,7 @@ function scanForNewJsonlFiles( activeTerminal, file, projectDir, + 'claude', nextAgentIdRef, agents, activeAgentIdRef, @@ -215,10 +354,13 @@ function scanForNewJsonlFiles( } } +// ── Shared helpers ─────────────────────────────────────────────────────────── + function adoptTerminalForFile( terminal: vscode.Terminal, jsonlFile: string, projectDir: string, + provider: AgentProvider, nextAgentIdRef: { current: number }, agents: Map, activeAgentIdRef: { current: number | null }, @@ -232,6 +374,7 @@ function adoptTerminalForFile( const id = nextAgentIdRef.current++; const agent: AgentState = { id, + provider, terminalRef: terminal, projectDir, jsonlFile, diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index ecddd37f..9aa43b69 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -1,60 +1,13 @@ -import * as path from 'path'; import type * as vscode from 'vscode'; -import { - BASH_COMMAND_DISPLAY_MAX_LENGTH, - TASK_DESCRIPTION_DISPLAY_MAX_LENGTH, - TEXT_IDLE_DELAY_MS, - TOOL_DONE_DELAY_MS, -} from './constants.js'; -import { - cancelPermissionTimer, - cancelWaitingTimer, - clearAgentActivity, - startPermissionTimer, - startWaitingTimer, -} from './timerManager.js'; +import { processClaudeTranscriptLine } from './claudeTranscriptParser.js'; +import { processCopilotTranscriptLine } from './copilotTranscriptParser.js'; import type { AgentState } from './types.js'; -export const PERMISSION_EXEMPT_TOOLS = new Set(['Task', 'AskUserQuestion']); +export { CLAUDE_PERMISSION_EXEMPT_TOOLS } from './claudeTranscriptParser.js'; +export { COPILOT_PERMISSION_EXEMPT_TOOLS } from './copilotTranscriptParser.js'; -export function formatToolStatus(toolName: string, input: Record): string { - const base = (p: unknown) => (typeof p === 'string' ? path.basename(p) : ''); - switch (toolName) { - case 'Read': - return `Reading ${base(input.file_path)}`; - case 'Edit': - return `Editing ${base(input.file_path)}`; - case 'Write': - return `Writing ${base(input.file_path)}`; - case 'Bash': { - const cmd = (input.command as string) || ''; - return `Running: ${cmd.length > BASH_COMMAND_DISPLAY_MAX_LENGTH ? cmd.slice(0, BASH_COMMAND_DISPLAY_MAX_LENGTH) + '\u2026' : cmd}`; - } - case 'Glob': - return 'Searching files'; - case 'Grep': - return 'Searching code'; - case 'WebFetch': - return 'Fetching web content'; - case 'WebSearch': - return 'Searching the web'; - case 'Task': { - const desc = typeof input.description === 'string' ? input.description : ''; - return desc - ? `Subtask: ${desc.length > TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + '\u2026' : desc}` - : 'Running subtask'; - } - case 'AskUserQuestion': - return 'Waiting for your answer'; - case 'EnterPlanMode': - return 'Planning'; - case 'NotebookEdit': - return `Editing notebook`; - default: - return `Using ${toolName}`; - } -} +// ── Dispatcher ────────────────────────────────────────────────────────────── export function processTranscriptLine( agentId: number, @@ -66,255 +19,9 @@ export function processTranscriptLine( ): void { const agent = agents.get(agentId); if (!agent) return; - try { - const record = JSON.parse(line); - - if (record.type === 'assistant' && Array.isArray(record.message?.content)) { - const blocks = record.message.content as Array<{ - type: string; - id?: string; - name?: string; - input?: Record; - }>; - const hasToolUse = blocks.some((b) => b.type === 'tool_use'); - - if (hasToolUse) { - cancelWaitingTimer(agentId, waitingTimers); - agent.isWaiting = false; - agent.hadToolsInTurn = true; - webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); - let hasNonExemptTool = false; - for (const block of blocks) { - if (block.type === 'tool_use' && block.id) { - const toolName = block.name || ''; - const status = formatToolStatus(toolName, block.input || {}); - console.log(`[Pixel Agents] Agent ${agentId} tool start: ${block.id} ${status}`); - agent.activeToolIds.add(block.id); - agent.activeToolStatuses.set(block.id, status); - agent.activeToolNames.set(block.id, toolName); - if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) { - hasNonExemptTool = true; - } - webview?.postMessage({ - type: 'agentToolStart', - id: agentId, - toolId: block.id, - status, - }); - } - } - if (hasNonExemptTool) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); - } - } else if (blocks.some((b) => b.type === 'text') && !agent.hadToolsInTurn) { - // Text-only response in a turn that hasn't used any tools. - // turn_duration handles tool-using turns reliably but is never - // emitted for text-only turns, so we use a silence-based timer: - // if no new JSONL data arrives within TEXT_IDLE_DELAY_MS, mark as waiting. - startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); - } - } else if (record.type === 'progress') { - processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, webview); - } else if (record.type === 'user') { - const content = record.message?.content; - if (Array.isArray(content)) { - const blocks = content as Array<{ type: string; tool_use_id?: string }>; - const hasToolResult = blocks.some((b) => b.type === 'tool_result'); - if (hasToolResult) { - for (const block of blocks) { - 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') { - agent.activeSubagentToolIds.delete(completedToolId); - agent.activeSubagentToolNames.delete(completedToolId); - webview?.postMessage({ - type: 'subagentClear', - id: agentId, - parentToolId: completedToolId, - }); - } - agent.activeToolIds.delete(completedToolId); - agent.activeToolStatuses.delete(completedToolId); - agent.activeToolNames.delete(completedToolId); - const toolId = completedToolId; - setTimeout(() => { - webview?.postMessage({ - type: 'agentToolDone', - id: agentId, - toolId, - }); - }, TOOL_DONE_DELAY_MS); - } - } - // All tools completed — allow text-idle timer as fallback - // for turn-end detection when turn_duration is not emitted - if (agent.activeToolIds.size === 0) { - agent.hadToolsInTurn = false; - } - } else { - // New user text prompt — new turn starting - cancelWaitingTimer(agentId, waitingTimers); - clearAgentActivity(agent, agentId, permissionTimers, webview); - agent.hadToolsInTurn = false; - } - } else if (typeof content === 'string' && content.trim()) { - // New user text prompt — new turn starting - cancelWaitingTimer(agentId, waitingTimers); - clearAgentActivity(agent, agentId, permissionTimers, webview); - agent.hadToolsInTurn = false; - } - } else if (record.type === 'system' && record.subtype === 'turn_duration') { - cancelWaitingTimer(agentId, waitingTimers); - cancelPermissionTimer(agentId, permissionTimers); - - // Definitive turn-end: clean up any stale tool state - if (agent.activeToolIds.size > 0) { - agent.activeToolIds.clear(); - agent.activeToolStatuses.clear(); - agent.activeToolNames.clear(); - agent.activeSubagentToolIds.clear(); - agent.activeSubagentToolNames.clear(); - webview?.postMessage({ type: 'agentToolsClear', id: agentId }); - } - - agent.isWaiting = true; - agent.permissionSent = false; - agent.hadToolsInTurn = false; - webview?.postMessage({ - type: 'agentStatus', - id: agentId, - status: 'waiting', - }); - } - } catch { - // Ignore malformed lines - } -} - -function processProgressRecord( - agentId: number, - record: Record, - agents: Map, - waitingTimers: Map>, - permissionTimers: Map>, - webview: vscode.Webview | undefined, -): void { - const agent = agents.get(agentId); - if (!agent) return; - - const parentToolId = record.parentToolUseID as string | undefined; - if (!parentToolId) return; - - const data = record.data as Record | undefined; - if (!data) return; - - // bash_progress / mcp_progress: tool is actively executing, not stuck on permission. - // Restart the permission timer to give the running tool another window. - const dataType = data.type as string | undefined; - if (dataType === 'bash_progress' || dataType === 'mcp_progress') { - if (agent.activeToolIds.has(parentToolId)) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); - } - return; - } - - // Verify parent is an active Task tool (agent_progress handling) - if (agent.activeToolNames.get(parentToolId) !== 'Task') return; - - const msg = data.message as Record | undefined; - if (!msg) return; - - const msgType = msg.type as string; - const innerMsg = msg.message as Record | undefined; - const content = innerMsg?.content; - if (!Array.isArray(content)) return; - - if (msgType === 'assistant') { - let hasNonExemptSubTool = false; - for (const block of content) { - if (block.type === 'tool_use' && block.id) { - const toolName = block.name || ''; - const status = formatToolStatus(toolName, block.input || {}); - console.log( - `[Pixel Agents] Agent ${agentId} subagent tool start: ${block.id} ${status} (parent: ${parentToolId})`, - ); - - // Track sub-tool IDs - let subTools = agent.activeSubagentToolIds.get(parentToolId); - if (!subTools) { - subTools = new Set(); - agent.activeSubagentToolIds.set(parentToolId, subTools); - } - subTools.add(block.id); - - // Track sub-tool names (for permission checking) - let subNames = agent.activeSubagentToolNames.get(parentToolId); - if (!subNames) { - subNames = new Map(); - agent.activeSubagentToolNames.set(parentToolId, subNames); - } - subNames.set(block.id, toolName); - - if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) { - hasNonExemptSubTool = true; - } - - webview?.postMessage({ - type: 'subagentToolStart', - id: agentId, - parentToolId, - toolId: block.id, - status, - }); - } - } - if (hasNonExemptSubTool) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); - } - } else if (msgType === 'user') { - for (const block of content) { - if (block.type === 'tool_result' && block.tool_use_id) { - console.log( - `[Pixel Agents] Agent ${agentId} subagent tool done: ${block.tool_use_id} (parent: ${parentToolId})`, - ); - - // Remove from tracking - const subTools = agent.activeSubagentToolIds.get(parentToolId); - if (subTools) { - subTools.delete(block.tool_use_id); - } - const subNames = agent.activeSubagentToolNames.get(parentToolId); - if (subNames) { - subNames.delete(block.tool_use_id); - } - - const toolId = block.tool_use_id; - setTimeout(() => { - webview?.postMessage({ - type: 'subagentToolDone', - id: agentId, - parentToolId, - toolId, - }); - }, 300); - } - } - // If there are still active non-exempt sub-agent tools, restart the permission timer - // (handles the case where one sub-agent completes but another is still stuck) - let stillHasNonExempt = false; - for (const [, subNames] of agent.activeSubagentToolNames) { - for (const [, toolName] of subNames) { - if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) { - stillHasNonExempt = true; - break; - } - } - if (stillHasNonExempt) break; - } - if (stillHasNonExempt) { - startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, webview); - } + if (agent.provider === 'claude') { + processClaudeTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); + } else { + processCopilotTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, webview); } } diff --git a/src/types.ts b/src/types.ts index feeec137..5f409021 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,10 @@ import type * as vscode from 'vscode'; +import type { AgentProvider } from './constants.js'; + export interface AgentState { id: number; + provider: AgentProvider; terminalRef: vscode.Terminal; projectDir: string; jsonlFile: string; @@ -24,6 +27,7 @@ export interface PersistedAgent { terminalName: string; jsonlFile: string; projectDir: string; + provider?: AgentProvider; /** Workspace folder name (only set for multi-root workspaces) */ folderName?: string; } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 1bf65413..f6311a29 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -137,6 +137,8 @@ function App() { layoutReady, loadedAssets, workspaceFolders, + provider, + setProvider, } = useExtensionMessages(getOfficeState, editor.setLastSavedLayout, isEditDirty); const [isDebugMode, setIsDebugMode] = useState(false); @@ -264,6 +266,8 @@ function App() { onToggleEditMode={editor.handleToggleEditMode} isDebugMode={isDebugMode} onToggleDebugMode={handleToggleDebugMode} + provider={provider} + onSetProvider={setProvider} workspaceFolders={workspaceFolders} /> diff --git a/webview-ui/src/components/BottomToolbar.tsx b/webview-ui/src/components/BottomToolbar.tsx index 92744c74..b0a22662 100644 --- a/webview-ui/src/components/BottomToolbar.tsx +++ b/webview-ui/src/components/BottomToolbar.tsx @@ -10,6 +10,8 @@ interface BottomToolbarProps { onToggleEditMode: () => void; isDebugMode: boolean; onToggleDebugMode: () => void; + provider: 'copilot' | 'claude'; + onSetProvider: (p: 'copilot' | 'claude') => void; workspaceFolders: WorkspaceFolder[]; } @@ -50,6 +52,8 @@ export function BottomToolbar({ onToggleEditMode, isDebugMode, onToggleDebugMode, + provider, + onSetProvider, workspaceFolders, }: BottomToolbarProps) { const [hovered, setHovered] = useState(null); @@ -82,7 +86,7 @@ export function BottomToolbar({ const handleFolderSelect = (folder: WorkspaceFolder) => { setIsFolderPickerOpen(false); - vscode.postMessage({ type: 'openClaude', folderPath: folder.path }); + vscode.postMessage({ type: 'openAgent', folderPath: folder.path }); }; return ( @@ -185,6 +189,8 @@ export function BottomToolbar({ onClose={() => setIsSettingsOpen(false)} isDebugMode={isDebugMode} onToggleDebugMode={onToggleDebugMode} + provider={provider} + onSetProvider={onSetProvider} /> diff --git a/webview-ui/src/components/SettingsModal.tsx b/webview-ui/src/components/SettingsModal.tsx index 44dc5e3c..ee036cd5 100644 --- a/webview-ui/src/components/SettingsModal.tsx +++ b/webview-ui/src/components/SettingsModal.tsx @@ -8,6 +8,8 @@ interface SettingsModalProps { onClose: () => void; isDebugMode: boolean; onToggleDebugMode: () => void; + provider: 'copilot' | 'claude'; + onSetProvider: (p: 'copilot' | 'claude') => void; } const menuItemBase: React.CSSProperties = { @@ -30,6 +32,8 @@ export function SettingsModal({ onClose, isDebugMode, onToggleDebugMode, + provider, + onSetProvider, }: SettingsModalProps) { const [hovered, setHovered] = useState(null); const [soundLocal, setSoundLocal] = useState(isSoundEnabled); @@ -97,6 +101,54 @@ export function SettingsModal({ X + + {/* Agent Provider toggle — Claude first */} +
+
+ Agent Provider +
+
+ {(['claude', 'copilot'] as const).map((p) => { + const active = provider === p; + const hovKey = `provider-${p}`; + return ( + + ); + })} +
+
+ {/* Menu items */}