From adbe9adab0f83cec5626334d572fb487186ad5d6 Mon Sep 17 00:00:00 2001 From: Akasha53 Date: Mon, 2 Mar 2026 16:25:00 +0100 Subject: [PATCH] feat: add multi-provider agents (claude/codex/gemini) with robust tracking and ui Summary - Implement multi-provider architecture for Claude, Codex, and Gemini using provider definitions. - Add provider detection, recommended default selection, saved provider preference, and backward-compatible openClaude alias. - Generalize agent lifecycle (launch/restore/persist/remove) to include provider/session metadata and mixed-provider agents in one workspace. - Add provider-aware session file binding for Codex and Gemini with resilient fallback matching when CLIs reuse existing sessions. - Extend transcript parsing for provider-specific formats: - Claude: existing tool/sub-agent/permission tracking. - Codex: function_call/function_call_output + task_complete handling. - Gemini: session JSON snapshot parsing + text-turn thinking activity support. - Harden file watching/read path to avoid empty-session-path errors. - Add first-run provider behavior improvements when multiple CLIs are installed. Webview/UI - Replace openClaude flow with openAgent while preserving compatibility. - Add provider state handling in extension messages. - Add provider quick picker next to + Agent and provider controls in Settings. - Include provider metadata in existing/restored agents and show correct behavior for per-agent launches. - Add compatibility handling for legacy providersDetected payload shape. Docs - Update README and CHANGELOG with multi-provider support and migration notes. Result - Mixed provider sessions work in one panel. - Existing Claude-only behavior remains compatible. - Improved resilience for Codex/Gemini binding and activity display in real-world local session formats. --- CHANGELOG.md | 15 + README.md | 22 +- src/PixelAgentsViewProvider.ts | 87 +++++- src/agentManager.ts | 213 +++++++++----- src/constants.ts | 6 +- src/fileWatcher.ts | 61 ++-- src/providers.ts | 286 +++++++++++++++++++ src/transcriptParser.ts | 247 +++++++++++++--- src/types.ts | 15 + webview-ui/src/App.tsx | 19 +- webview-ui/src/components/BottomToolbar.tsx | 235 ++++++++++----- webview-ui/src/components/SettingsModal.tsx | 108 ++++++- webview-ui/src/hooks/useEditorActions.ts | 10 +- webview-ui/src/hooks/useExtensionMessages.ts | 67 ++++- webview-ui/src/office/engine/officeState.ts | 7 +- webview-ui/src/office/types.ts | 9 + 16 files changed, 1174 insertions(+), 233 deletions(-) create mode 100644 src/providers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e659c94..e0442777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Next + +### Features + +- Add multi-provider interactive agent support (Claude, Codex, Gemini) with mixed-provider agents in a single workspace. +- Add provider detection and persisted provider preferences (`defaultProvider`, `askEachTime`). +- Add `openAgent` message flow while keeping `openClaude` as a backward-compatible alias. +- Include provider metadata in `agentCreated` and `existingAgents` payloads. + +### Migration Notes + +- Existing persisted agents without `provider` are restored as `claude`. +- Claude `/clear` rollover behavior is preserved. +- Codex and Gemini use provider-specific session file resolution and best-effort tool/turn parsing. + ## v1.0.2 ### Bug Fixes diff --git a/README.md b/README.md index b6698601..b5a2af46 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A VS Code extension that turns your AI coding agents into animated pixel art characters in a virtual office. -Each Claude Code terminal you open spawns a character that walks around, sits at desks, and visually reflects what the agent is doing — typing when writing code, reading when searching files, waiting when it needs your attention. +Each interactive AI terminal you open (Claude Code, Codex, or Gemini) spawns a character that walks around, sits at desks, and visually reflects what the agent is doing — typing when writing code, reading when searching files, waiting when it needs your attention. This is the source code for the free [Pixel Agents extension for VS Code](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents) — you can install it directly from the marketplace with the full furniture catalog included. @@ -11,7 +11,8 @@ This is the source code for the free [Pixel Agents extension for VS Code](https: ## Features -- **One agent, one character** — every Claude Code terminal gets its own animated character +- **One agent, one character** — every Claude Code, Codex, or Gemini terminal gets its own animated character +- **Mixed providers in one workspace** — run Claude, Codex, and Gemini agents side by side in the same panel - **Live activity tracking** — characters animate based on what the agent is actually doing (writing, reading, running commands) - **Office layout editor** — design your office with floors, walls, and furniture using a built-in editor - **Speech bubbles** — visual indicators when an agent is waiting for input or needs permission @@ -27,7 +28,10 @@ This is the source code for the free [Pixel Agents extension for VS Code](https: ## Requirements - VS Code 1.109.0 or later -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured +- At least one supported CLI installed and configured: + - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) + - Codex CLI + - Gemini CLI ## Getting Started @@ -48,8 +52,9 @@ Then press **F5** in VS Code to launch the Extension Development Host. ### Usage 1. Open the **Pixel Agents** panel (it appears in the bottom panel area alongside your terminal) -2. Click **+ Agent** to spawn a new Claude Code terminal and its character -3. Start coding with Claude — watch the character react in real time +2. Click **+ Agent** to spawn a new agent terminal and its character +3. Pick a provider (Claude/Codex/Gemini) if prompted, or use your configured default provider +4. Start coding with your agent and watch the character react in real time 4. Click a character to select it, then click a seat to reassign it 5. Click **Layout** to open the office editor and customize your space @@ -81,7 +86,7 @@ The extension will still work without the tileset — you'll get the default cha ## How It Works -Pixel Agents watches Claude Code's JSONL transcript files to track what each agent is doing. When an agent uses a tool (like writing a file or running a command), the extension detects it and updates the character's animation accordingly. No modifications to Claude Code are needed — it's purely observational. +Pixel Agents watches provider session transcripts to track what each agent is doing. For Claude and Codex it reads JSONL logs; for Gemini it reads session JSON snapshots. When an agent uses a tool (like writing a file or running a command), the extension detects it and updates the character's animation accordingly. No CLI modifications are required — it's purely observational. The webview runs a lightweight game loop with canvas rendering, BFS pathfinding, and a character state machine (idle → walk → type/read). Everything is pixel-perfect at integer zoom levels. @@ -92,8 +97,9 @@ The webview runs a lightweight game loop with canvas rendering, BFS pathfinding, ## Known Limitations -- **Agent-terminal sync** — the way agents are connected to Claude Code terminal instances is not super robust and sometimes desyncs, especially when terminals are rapidly opened/closed or restored across sessions. -- **Heuristic-based status detection** — Claude Code's JSONL transcript format does not provide clear signals for when an agent is waiting for user input or when it has finished its turn. The current detection is based on heuristics (idle timers, turn-duration events) and often misfires — agents may briefly show the wrong status or miss transitions. +- **Agent-terminal sync** — the way agents are connected to interactive terminal instances is not super robust and sometimes desyncs, especially when terminals are rapidly opened/closed or restored across sessions. +- **Provider log format churn** — status/tool tracking depends on local CLI log formats, which may change without notice and require parser updates. +- **Feature parity across providers** — Claude currently has the richest permission/sub-agent tracking. Codex and Gemini use best-effort parsing where equivalent signals are unavailable. - **Windows-only testing** — the extension has only been tested on Windows 11. It may work on macOS or Linux, but there could be unexpected issues with file watching, paths, or terminal behavior on those platforms. ## Roadmap diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index fe78bd46..2d279981 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import type { AgentState } from './types.js'; +import type { AgentProvider } from './types.js'; import { launchNewTerminal, removeAgent, @@ -14,9 +15,16 @@ import { } from './agentManager.js'; import { ensureProjectScan } from './fileWatcher.js'; import { loadFurnitureAssets, sendAssetsToWebview, loadFloorTiles, sendFloorTilesToWebview, loadWallTiles, sendWallTilesToWebview, loadCharacterSprites, sendCharacterSpritesToWebview, loadDefaultLayout } from './assetLoader.js'; -import { WORKSPACE_KEY_AGENT_SEATS, GLOBAL_KEY_SOUND_ENABLED } from './constants.js'; +import { + WORKSPACE_KEY_AGENT_SEATS, + GLOBAL_KEY_SOUND_ENABLED, + GLOBAL_KEY_DEFAULT_PROVIDER, + GLOBAL_KEY_ASK_PROVIDER_EACH_TIME, + WORKSPACE_KEY_PROVIDER_PREFERENCE_SET, +} from './constants.js'; import { writeLayoutToFile, readLayoutFromFile, watchLayoutFile } from './layoutPersistence.js'; import type { LayoutWatcher } from './layoutPersistence.js'; +import { detectInstalledProviders, getRecommendedProvider } from './providers.js'; export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { nextAgentId = { current: 1 }; @@ -34,6 +42,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { // /clear detection: project-level scan for new JSONL files activeAgentId = { current: null as number | null }; knownJsonlFiles = new Set(); + claimedSessionFiles = new Set(); projectScanTimer = { current: null as ReturnType | null }; // Bundled default layout (loaded from assets/default-layout.json) @@ -56,21 +65,73 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { persistAgents(this.agents, this.context); }; + private getProviderPreference(): { defaultProvider: AgentProvider | null; askEachTime: boolean; preferenceSet: boolean } { + const installed = detectInstalledProviders(); + const recommended = getRecommendedProvider(installed); + const preferenceSet = this.context.workspaceState.get(WORKSPACE_KEY_PROVIDER_PREFERENCE_SET, false); + const saved = this.context.globalState.get(GLOBAL_KEY_DEFAULT_PROVIDER, null); + const savedAskEachTime = this.context.globalState.get(GLOBAL_KEY_ASK_PROVIDER_EACH_TIME, false); + const installedCount = Object.values(installed).filter(Boolean).length; + // First-run behavior: if multiple providers are installed and preference is unset, ask each time. + const askEachTime = preferenceSet ? savedAskEachTime : (savedAskEachTime || installedCount > 1); + + let defaultProvider = saved; + if (!defaultProvider || !installed[defaultProvider]) { + defaultProvider = recommended; + } + return { defaultProvider, askEachTime, preferenceSet }; + } + + private ensureDefaultProviderInitialized(): void { + const installed = detectInstalledProviders(); + const saved = this.context.globalState.get(GLOBAL_KEY_DEFAULT_PROVIDER, null); + if (saved && installed[saved]) return; + const recommended = getRecommendedProvider(installed); + if (recommended) { + this.context.globalState.update(GLOBAL_KEY_DEFAULT_PROVIDER, recommended); + } + } + resolveWebviewView(webviewView: vscode.WebviewView) { this.webviewView = webviewView; webviewView.webview.options = { enableScripts: true }; webviewView.webview.html = getWebviewContent(webviewView.webview, this.extensionUri); webviewView.webview.onDidReceiveMessage(async (message) => { - if (message.type === 'openClaude') { + if (message.type === 'openClaude' || message.type === 'openAgent') { + const installed = detectInstalledProviders(); + const pref = this.getProviderPreference(); + let provider = (message.provider as AgentProvider | undefined); + if (!provider) { + provider = pref.defaultProvider || getRecommendedProvider(installed) || 'claude'; + } + if (!installed[provider]) { + vscode.window.showErrorMessage(`Pixel Agents: ${provider} CLI is not installed.`); + return; + } await launchNewTerminal( this.nextAgentId, this.nextTerminalIndex, this.agents, this.activeAgentId, this.knownJsonlFiles, + this.claimedSessionFiles, this.fileWatchers, this.pollingTimers, this.waitingTimers, this.permissionTimers, this.jsonlPollTimers, this.projectScanTimer, this.webview, this.persistAgents, + provider, message.folderPath as string | undefined, ); + } else if (message.type === 'saveProviderPreference') { + const provider = message.defaultProvider as AgentProvider | null; + const askEachTime = !!message.askEachTime; + if (provider) { + this.context.globalState.update(GLOBAL_KEY_DEFAULT_PROVIDER, provider); + } + this.context.globalState.update(GLOBAL_KEY_ASK_PROVIDER_EACH_TIME, askEachTime); + this.context.workspaceState.update(WORKSPACE_KEY_PROVIDER_PREFERENCE_SET, true); + this.webview?.postMessage({ + type: 'providerPreferenceSaved', + defaultProvider: provider, + askEachTime, + }); } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); if (agent) { @@ -91,10 +152,14 @@ 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') { + this.ensureDefaultProviderInitialized(); + const installed = detectInstalledProviders(); + const recommended = getRecommendedProvider(installed); restoreAgents( this.context, this.nextAgentId, this.nextTerminalIndex, this.agents, this.knownJsonlFiles, + this.claimedSessionFiles, this.fileWatchers, this.pollingTimers, this.waitingTimers, this.permissionTimers, this.jsonlPollTimers, this.projectScanTimer, this.activeAgentId, this.webview, this.persistAgents, @@ -102,6 +167,18 @@ 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 }); + const pref = this.getProviderPreference(); + this.webview?.postMessage({ + type: 'providersDetected', + installed, + recommended, + }); + this.webview?.postMessage({ + type: 'providerPreferenceLoaded', + defaultProvider: pref.defaultProvider, + askEachTime: pref.askEachTime, + preferenceSet: pref.preferenceSet, + }); // Send workspace folders to webview (only when multi-root) const wsFolders = vscode.workspace.workspaceFolders; @@ -113,8 +190,8 @@ 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; + const projectDir = getProjectDirPath('claude', workspaceRoot); console.log('[Extension] workspaceRoot:', workspaceRoot); console.log('[Extension] projectDir:', projectDir); if (projectDir) { @@ -225,7 +302,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('claude', vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); if (projectDir && fs.existsSync(projectDir)) { vscode.env.openExternal(vscode.Uri.file(projectDir)); } @@ -286,6 +363,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } removeAgent( id, this.agents, + this.claimedSessionFiles, this.fileWatchers, this.pollingTimers, this.waitingTimers, this.permissionTimers, this.jsonlPollTimers, this.persistAgents, ); @@ -327,6 +405,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { for (const id of [...this.agents.keys()]) { removeAgent( id, this.agents, + this.claimedSessionFiles, this.fileWatchers, this.pollingTimers, this.waitingTimers, this.permissionTimers, this.jsonlPollTimers, this.persistAgents, ); diff --git a/src/agentManager.ts b/src/agentManager.ts index 4c53af84..1a1be229 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -1,20 +1,59 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import * as vscode from 'vscode'; -import type { AgentState, PersistedAgent } from './types.js'; +import type { AgentProvider, AgentState, PersistedAgent } from './types.js'; import { cancelWaitingTimer, cancelPermissionTimer } from './timerManager.js'; import { startFileWatching, readNewLines, ensureProjectScan } from './fileWatcher.js'; -import { JSONL_POLL_INTERVAL_MS, TERMINAL_NAME_PREFIX, WORKSPACE_KEY_AGENTS, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; +import { JSONL_POLL_INTERVAL_MS, WORKSPACE_KEY_AGENTS, WORKSPACE_KEY_AGENT_SEATS } from './constants.js'; import { migrateAndLoadLayout } from './layoutPersistence.js'; +import { PROVIDER_DEFINITIONS } from './providers.js'; + +function findNewestSessionFile( + rootDir: string, + match: (name: string) => boolean, + claimedSessionFiles: Set, +): string | null { + if (!rootDir || !fs.existsSync(rootDir)) return null; + const stack: string[] = [rootDir]; + let newestUnclaimed: { file: string; mtime: number } | null = null; + let newestAny: { file: string; mtime: number } | null = null; + + while (stack.length > 0) { + const dir = stack.pop(); + if (!dir) break; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.isFile() || !match(entry.name)) continue; + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (!newestAny || stat.mtimeMs > newestAny.mtime) { + newestAny = { file: full, mtime: stat.mtimeMs }; + } + if (!claimedSessionFiles.has(full) && (!newestUnclaimed || stat.mtimeMs > newestUnclaimed.mtime)) { + newestUnclaimed = { file: full, mtime: stat.mtimeMs }; + } + } + } -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; + return newestUnclaimed?.file ?? newestAny?.file ?? null; +} + +export function getProjectDirPath(provider: AgentProvider, cwd?: string): string | null { + return PROVIDER_DEFINITIONS[provider].getProjectDirPath(cwd); } export async function launchNewTerminal( @@ -23,6 +62,7 @@ export async function launchNewTerminal( agents: Map, activeAgentIdRef: { current: number | null }, knownJsonlFiles: Set, + claimedSessionFiles: Set, fileWatchers: Map, pollingTimers: Map>, waitingTimers: Map>, @@ -30,40 +70,46 @@ export async function launchNewTerminal( jsonlPollTimers: Map>, projectScanTimerRef: { current: ReturnType | null }, webview: vscode.Webview | undefined, - persistAgents: () => void, + persistAgentsFn: () => void, + provider: AgentProvider, folderPath?: string, ): Promise { const folders = vscode.workspace.workspaceFolders; const cwd = folderPath || folders?.[0]?.uri.fsPath; + if (!cwd) { + vscode.window.showErrorMessage('Pixel Agents: Open a workspace folder in this window before creating an agent.'); + return; + } const isMultiRoot = !!(folders && folders.length > 1); + const def = PROVIDER_DEFINITIONS[provider]; const idx = nextTerminalIndexRef.current++; const terminal = vscode.window.createTerminal({ - name: `${TERMINAL_NAME_PREFIX} #${idx}`, + name: `${def.terminalPrefix} #${idx}`, cwd, }); terminal.show(); - const sessionId = crypto.randomUUID(); - terminal.sendText(`claude --session-id ${sessionId}`); - - const projectDir = getProjectDirPath(cwd); - if (!projectDir) { - console.log(`[Pixel Agents] No project dir, cannot track agent`); - return; - } + const launchTime = Date.now(); + const sessionId = provider === 'claude' ? crypto.randomUUID() : undefined; + terminal.sendText(def.commandForLaunch(sessionId)); - // Pre-register expected JSONL file so project scan won't treat it as a /clear file - const expectedFile = path.join(projectDir, `${sessionId}.jsonl`); - knownJsonlFiles.add(expectedFile); - - // Create agent immediately (before JSONL file exists) + const projectDir = def.getProjectDirPath(cwd) || ''; const id = nextAgentIdRef.current++; const folderName = isMultiRoot && cwd ? path.basename(cwd) : undefined; + const expected = def.resolveSessionFile(cwd, { sessionId, launchedAt: launchTime }, claimedSessionFiles); + if (expected) { + knownJsonlFiles.add(expected); + } + const agent: AgentState = { id, terminalRef: terminal, + provider, projectDir, - jsonlFile: expectedFile, + jsonlFile: expected || '', + sessionFormat: def.sessionFormat, + sessionId, + launchTime, fileOffset: 0, lineBuffer: '', activeToolIds: new Set(), @@ -79,27 +125,45 @@ export async function launchNewTerminal( agents.set(id, agent); activeAgentIdRef.current = id; - persistAgents(); - console.log(`[Pixel Agents] Agent ${id}: created for terminal ${terminal.name}`); - webview?.postMessage({ type: 'agentCreated', id, folderName }); + persistAgentsFn(); + webview?.postMessage({ type: 'agentCreated', id, folderName, provider }); - ensureProjectScan( - projectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, - nextAgentIdRef, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, - webview, persistAgents, - ); + // Keep /clear detection only for Claude projects. + if (provider === 'claude' && projectDir) { + ensureProjectScan( + projectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, + nextAgentIdRef, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, + webview, persistAgentsFn, + ); + } - // Poll for the specific JSONL file to appear + let pollAttempts = 0; const pollTimer = setInterval(() => { + pollAttempts++; + let found = def.resolveSessionFile(cwd, { sessionId, launchedAt: launchTime }, claimedSessionFiles); + // Some CLIs reuse an existing session file and may not emit fresh metadata immediately. + // After a short delay, fall back to the newest provider session file so the agent can bind. + if (!found && provider !== 'claude' && pollAttempts >= 15) { + const providerRoot = def.getProjectDirPath(cwd) || ''; + found = provider === 'codex' + ? findNewestSessionFile(providerRoot, name => name.startsWith('rollout-') && name.endsWith('.jsonl'), claimedSessionFiles) + : findNewestSessionFile(providerRoot, name => /^session-.*\.json$/.test(name), claimedSessionFiles); + } + if (!found) return; try { - if (fs.existsSync(agent.jsonlFile)) { - console.log(`[Pixel Agents] Agent ${id}: found JSONL file ${path.basename(agent.jsonlFile)}`); + if (fs.existsSync(found)) { + agent.jsonlFile = found; + knownJsonlFiles.add(found); + claimedSessionFiles.add(found); clearInterval(pollTimer); jsonlPollTimers.delete(id); - startFileWatching(id, agent.jsonlFile, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); + startFileWatching(id, found, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); readNewLines(id, agents, waitingTimers, permissionTimers, webview); + persistAgentsFn(); } - } catch { /* file may not exist yet */ } + } catch { + // wait for session file + } }, JSONL_POLL_INTERVAL_MS); jsonlPollTimers.set(id, pollTimer); } @@ -107,22 +171,21 @@ export async function launchNewTerminal( export function removeAgent( agentId: number, agents: Map, + claimedSessionFiles: Set, fileWatchers: Map, pollingTimers: Map>, 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); @@ -130,13 +193,13 @@ export function removeAgent( pollingTimers.delete(agentId); try { fs.unwatchFile(agent.jsonlFile); } catch { /* ignore */ } - // Cancel timers cancelWaitingTimer(agentId, waitingTimers); cancelPermissionTimer(agentId, permissionTimers); - - // Remove from maps + if (agent.jsonlFile) { + claimedSessionFiles.delete(agent.jsonlFile); + } agents.delete(agentId); - persistAgents(); + persistAgentsFn(); } export function persistAgents( @@ -150,6 +213,9 @@ export function persistAgents( terminalName: agent.terminalRef.name, jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, + provider: agent.provider, + sessionFormat: agent.sessionFormat, + sessionId: agent.sessionId, folderName: agent.folderName, }); } @@ -162,6 +228,7 @@ export function restoreAgents( nextTerminalIndexRef: { current: number }, agents: Map, knownJsonlFiles: Set, + claimedSessionFiles: Set, fileWatchers: Map, pollingTimers: Map>, waitingTimers: Map>, @@ -178,17 +245,21 @@ export function restoreAgents( const liveTerminals = vscode.window.terminals; let maxId = 0; let maxIdx = 0; - let restoredProjectDir: string | null = null; + let restoredClaudeProjectDir: string | null = null; for (const p of persisted) { const terminal = liveTerminals.find(t => t.name === p.terminalName); if (!terminal) continue; - + const provider = p.provider || 'claude'; + const def = PROVIDER_DEFINITIONS[provider]; const agent: AgentState = { id: p.id, terminalRef: terminal, + provider, projectDir: p.projectDir, jsonlFile: p.jsonlFile, + sessionFormat: p.sessionFormat || def.sessionFormat, + sessionId: p.sessionId, fileOffset: 0, lineBuffer: '', activeToolIds: new Set(), @@ -203,45 +274,47 @@ export function restoreAgents( }; agents.set(p.id, agent); - knownJsonlFiles.add(p.jsonlFile); - console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`); + if (p.jsonlFile) { + knownJsonlFiles.add(p.jsonlFile); + claimedSessionFiles.add(p.jsonlFile); + } if (p.id > maxId) maxId = p.id; - // Extract terminal index from name like "Claude Code #3" const match = p.terminalName.match(/#(\d+)$/); if (match) { const idx = parseInt(match[1], 10); if (idx > maxIdx) maxIdx = idx; } + if (provider === 'claude' && p.projectDir) { + restoredClaudeProjectDir = p.projectDir; + } - restoredProjectDir = p.projectDir; - - // Start file watching if JSONL exists, skipping to end of file try { - if (fs.existsSync(p.jsonlFile)) { + if (p.jsonlFile && fs.existsSync(p.jsonlFile)) { const stat = fs.statSync(p.jsonlFile); agent.fileOffset = stat.size; startFileWatching(p.id, p.jsonlFile, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); - } else { - // Poll for the file to appear + } else if (p.jsonlFile) { const pollTimer = setInterval(() => { try { if (fs.existsSync(agent.jsonlFile)) { - console.log(`[Pixel Agents] Restored agent ${p.id}: found JSONL file`); clearInterval(pollTimer); jsonlPollTimers.delete(p.id); const stat = fs.statSync(agent.jsonlFile); agent.fileOffset = stat.size; startFileWatching(p.id, agent.jsonlFile, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); } - } catch { /* file may not exist yet */ } + } catch { + // keep polling + } }, JSONL_POLL_INTERVAL_MS); jsonlPollTimers.set(p.id, pollTimer); } - } catch { /* ignore errors during restore */ } + } catch { + // ignore restore errors + } } - // Advance counters past restored IDs if (maxId >= nextAgentIdRef.current) { nextAgentIdRef.current = maxId + 1; } @@ -249,13 +322,11 @@ export function restoreAgents( nextTerminalIndexRef.current = maxIdx + 1; } - // Re-persist cleaned-up list (removes entries whose terminals are gone) doPersist(); - // Start project scan for /clear detection - if (restoredProjectDir) { + if (restoredClaudeProjectDir) { ensureProjectScan( - restoredProjectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, + restoredClaudeProjectDir, knownJsonlFiles, projectScanTimerRef, activeAgentIdRef, nextAgentIdRef, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview, doPersist, ); @@ -269,28 +340,27 @@ export function sendExistingAgents( ): void { if (!webview) return; const agentIds: number[] = []; - for (const id of agents.keys()) { + const providers: Record = {}; + for (const [id, agent] of agents) { agentIds.push(id); + providers[id] = agent.provider; } agentIds.sort((a, b) => a - b); - // Include persisted palette/seatId from separate key const agentMeta = context.workspaceState.get>(WORKSPACE_KEY_AGENT_SEATS, {}); - - // Include folderName per agent const folderNames: Record = {}; for (const [id, agent] of agents) { if (agent.folderName) { folderNames[id] = agent.folderName; } } - console.log(`[Pixel Agents] sendExistingAgents: agents=${JSON.stringify(agentIds)}, meta=${JSON.stringify(agentMeta)}`); webview.postMessage({ type: 'existingAgents', agents: agentIds, agentMeta, folderNames, + providers, }); sendCurrentAgentStatuses(agents, webview); @@ -302,7 +372,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', @@ -311,7 +380,6 @@ export function sendCurrentAgentStatuses( status, }); } - // Re-send waiting status if (agent.isWaiting) { webview.postMessage({ type: 'agentStatus', @@ -334,3 +402,4 @@ export function sendLayout( layout, }); } + diff --git a/src/constants.ts b/src/constants.ts index 5e95c166..81004e99 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -31,6 +31,8 @@ export const LAYOUT_FILE_POLL_INTERVAL_MS = 2000; // ── Settings Persistence ──────────────────────────────────── export const GLOBAL_KEY_SOUND_ENABLED = 'pixel-agents.soundEnabled'; +export const GLOBAL_KEY_DEFAULT_PROVIDER = 'pixel-agents.defaultProvider'; +export const GLOBAL_KEY_ASK_PROVIDER_EACH_TIME = 'pixel-agents.askProviderEachTime'; // ── VS Code Identifiers ───────────────────────────────────── export const VIEW_ID = 'pixel-agents.panelView'; @@ -39,4 +41,6 @@ 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 WORKSPACE_KEY_PROVIDER_PREFERENCE_SET = 'pixel-agents.providerPreferenceSet'; + +export const PROVIDERS = ['claude', 'codex', 'gemini'] as const; diff --git a/src/fileWatcher.ts b/src/fileWatcher.ts index 332ceb16..3306e350 100644 --- a/src/fileWatcher.ts +++ b/src/fileWatcher.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type { AgentState } from './types.js'; import { cancelWaitingTimer, cancelPermissionTimer, clearAgentActivity } from './timerManager.js'; -import { processTranscriptLine } from './transcriptParser.js'; +import { processTranscriptLine, processGeminiSessionSnapshot } from './transcriptParser.js'; import { FILE_WATCHER_POLL_INTERVAL_MS, PROJECT_SCAN_INTERVAL_MS } from './constants.js'; export function startFileWatching( @@ -16,7 +16,6 @@ export function startFileWatching( permissionTimers: Map>, webview: vscode.Webview | undefined, ): void { - // Primary: fs.watch (unreliable on macOS — may miss events) try { const watcher = fs.watch(filePath, () => { readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); @@ -26,7 +25,6 @@ export function startFileWatching( console.log(`[Pixel Agents] fs.watch failed for agent ${agentId}: ${e}`); } - // Secondary: fs.watchFile (stat-based polling, reliable on macOS) try { fs.watchFile(filePath, { interval: FILE_WATCHER_POLL_INTERVAL_MS }, () => { readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); @@ -35,7 +33,6 @@ export function startFileWatching( console.log(`[Pixel Agents] fs.watchFile failed for agent ${agentId}: ${e}`); } - // Tertiary: manual poll as last resort const interval = setInterval(() => { if (!agents.has(agentId)) { clearInterval(interval); @@ -56,7 +53,18 @@ export function readNewLines( ): void { const agent = agents.get(agentId); if (!agent) return; + if (!agent.jsonlFile) return; try { + if (agent.sessionFormat === 'gemini-json') { + const stat = fs.statSync(agent.jsonlFile); + if (stat.size <= agent.fileOffset && agent.processedGeminiMessages !== undefined) return; + const raw = fs.readFileSync(agent.jsonlFile, 'utf-8'); + agent.fileOffset = stat.size; + cancelPermissionTimer(agentId, permissionTimers); + processGeminiSessionSnapshot(agentId, raw, agents, waitingTimers, permissionTimers, webview); + return; + } + const stat = fs.statSync(agent.jsonlFile); if (stat.size <= agent.fileOffset) return; @@ -72,7 +80,6 @@ export function readNewLines( const hasLines = lines.some(l => l.trim()); if (hasLines) { - // New data arriving — cancel timers (data flowing means agent is still active) cancelWaitingTimer(agentId, waitingTimers); cancelPermissionTimer(agentId, permissionTimers); if (agent.permissionSent) { @@ -105,7 +112,6 @@ export function ensureProjectScan( persistAgents: () => void, ): void { if (projectScanTimerRef.current) return; - // Seed with all existing JSONL files so we only react to truly new ones try { const files = fs.readdirSync(projectDir) .filter(f => f.endsWith('.jsonl')) @@ -148,33 +154,29 @@ function scanForNewJsonlFiles( if (!knownJsonlFiles.has(file)) { knownJsonlFiles.add(file); if (activeAgentIdRef.current !== null) { - // Active agent focused → /clear reassignment - console.log(`[Pixel Agents] New JSONL detected: ${path.basename(file)}, reassigning to agent ${activeAgentIdRef.current}`); reassignAgentToFile( activeAgentIdRef.current, file, 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, file, projectDir, - nextAgentIdRef, agents, activeAgentIdRef, - fileWatchers, pollingTimers, waitingTimers, permissionTimers, - webview, persistAgents, - ); + if (!activeTerminal) continue; + 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, + ); + } } } } @@ -198,8 +200,10 @@ function adoptTerminalForFile( const agent: AgentState = { id, terminalRef: terminal, + provider: 'claude', projectDir, jsonlFile, + sessionFormat: 'jsonl', fileOffset: 0, lineBuffer: '', activeToolIds: new Set(), @@ -216,9 +220,7 @@ function adoptTerminalForFile( activeAgentIdRef.current = id; persistAgents(); - console.log(`[Pixel Agents] Agent ${id}: adopted terminal "${terminal.name}" for ${path.basename(jsonlFile)}`); - webview?.postMessage({ type: 'agentCreated', id }); - + webview?.postMessage({ type: 'agentCreated', id, provider: 'claude' }); startFileWatching(id, jsonlFile, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); readNewLines(id, agents, waitingTimers, permissionTimers, webview); } @@ -237,7 +239,6 @@ export function reassignAgentToFile( const agent = agents.get(agentId); if (!agent) return; - // Stop old file watching fileWatchers.get(agentId)?.close(); fileWatchers.delete(agentId); const pt = pollingTimers.get(agentId); @@ -245,18 +246,16 @@ export function reassignAgentToFile( pollingTimers.delete(agentId); try { fs.unwatchFile(agent.jsonlFile); } catch { /* ignore */ } - // Clear activity cancelWaitingTimer(agentId, waitingTimers); cancelPermissionTimer(agentId, permissionTimers); clearAgentActivity(agent, agentId, permissionTimers, webview); - // Swap to new file agent.jsonlFile = newFilePath; agent.fileOffset = 0; agent.lineBuffer = ''; persistAgents(); - // Start watching new file startFileWatching(agentId, newFilePath, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, webview); readNewLines(agentId, agents, waitingTimers, permissionTimers, webview); } + diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 00000000..231681dc --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,286 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import type { AgentProvider, SessionFormat } from './types.js'; + +export interface ProviderLaunchMeta { + sessionId?: string; + launchedAt: number; +} + +export interface ProviderDefinition { + id: AgentProvider; + label: string; + terminalPrefix: string; + sessionFormat: SessionFormat; + commandForLaunch: (sessionId?: string) => string; + getProjectDirPath: (cwd?: string) => string | null; + resolveSessionFile: ( + cwd: string, + launchMeta: ProviderLaunchMeta, + claimedFiles: Set, + ) => string | null; +} + +function isWindows(): boolean { + return process.platform === 'win32'; +} + +function commandExists(command: string): boolean { + const checker = isWindows() ? 'where' : 'which'; + const result = spawnSync(checker, [command], { stdio: 'ignore', shell: true }); + return result.status === 0; +} + +function normalizePathForCompare(p: string): string { + return path.resolve(p).toLowerCase(); +} + +function findNewestFiles(root: string, predicate: (f: string) => boolean, maxFiles = 400): string[] { + const files: Array<{ file: string; mtime: number }> = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) break; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.isFile() || !predicate(full)) continue; + try { + const stat = fs.statSync(full); + files.push({ file: full, mtime: stat.mtimeMs }); + } catch { + // ignore + } + } + } + files.sort((a, b) => b.mtime - a.mtime); + return files.slice(0, maxFiles).map(f => f.file); +} + +function resolveClaudeProjectDir(cwd?: string): string | null { + if (!cwd) return null; + const dirName = cwd.replace(/[^a-zA-Z0-9-]/g, '-'); + return path.join(os.homedir(), '.claude', 'projects', dirName); +} + +function resolveCodexProjectDir(): string { + return path.join(os.homedir(), '.codex', 'sessions'); +} + +function resolveGeminiProjectDir(): string { + return path.join(os.homedir(), '.gemini', 'tmp'); +} + +function resolveClaudeSessionFile(cwd: string, launchMeta: ProviderLaunchMeta): string | null { + const projectDir = resolveClaudeProjectDir(cwd); + if (!projectDir || !launchMeta.sessionId) return null; + return path.join(projectDir, `${launchMeta.sessionId}.jsonl`); +} + +function resolveCodexSessionFile(cwd: string, launchMeta: ProviderLaunchMeta, claimedFiles: Set): string | null { + const root = resolveCodexProjectDir(); + if (!fs.existsSync(root)) return null; + const candidates = findNewestFiles(root, f => path.basename(f).startsWith('rollout-') && f.endsWith('.jsonl')); + const cwdNorm = normalizePathForCompare(cwd); + let bestUnclaimed: { file: string; delta: number } | null = null; + let bestClaimed: { file: string; delta: number } | null = null; + let fallbackUnclaimed: { file: string; delta: number } | null = null; + let fallbackClaimed: { file: string; delta: number } | null = null; + + for (const file of candidates) { + const isClaimed = claimedFiles.has(file); + const stat = fs.statSync(file); + if (stat.mtimeMs < launchMeta.launchedAt - 120000) continue; + const delta = Math.abs(stat.mtimeMs - launchMeta.launchedAt); + if (isClaimed) { + if (!fallbackClaimed || delta < fallbackClaimed.delta) { + fallbackClaimed = { file, delta }; + } + } else { + if (!fallbackUnclaimed || delta < fallbackUnclaimed.delta) { + fallbackUnclaimed = { file, delta }; + } + } + + let lineCount = 0; + let fileCwd: string | null = null; + try { + const content = fs.readFileSync(file, 'utf-8'); + const lines = content.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + lineCount++; + if (lineCount > 30) break; + const parsed = JSON.parse(line) as Record; + if (parsed.type === 'session_meta') { + const payload = parsed.payload as Record | undefined; + const pcwd = payload?.cwd; + if (typeof pcwd === 'string') { + fileCwd = normalizePathForCompare(pcwd); + } + break; + } + } + } catch { + continue; + } + + if (!fileCwd || fileCwd !== cwdNorm) continue; + if (isClaimed) { + if (!bestClaimed || delta < bestClaimed.delta) bestClaimed = { file, delta }; + } else { + if (!bestUnclaimed || delta < bestUnclaimed.delta) bestUnclaimed = { file, delta }; + } + } + // Prefer exact cwd unclaimed, then exact cwd claimed, then nearest unclaimed, then nearest claimed. + return bestUnclaimed?.file ?? bestClaimed?.file ?? fallbackUnclaimed?.file ?? fallbackClaimed?.file ?? null; +} + +function resolveGeminiSessionFile(cwd: string, launchMeta: ProviderLaunchMeta, claimedFiles: Set): string | null { + const root = resolveGeminiProjectDir(); + if (!fs.existsSync(root)) return null; + const cwdNorm = normalizePathForCompare(cwd); + const projectRoots = findNewestFiles(root, f => path.basename(f) === '.project_root', 500); + const candidateDirs = new Set(); + + for (const pr of projectRoots) { + try { + const content = fs.readFileSync(pr, 'utf-8').trim(); + const normalized = normalizePathForCompare(content); + if (normalized === cwdNorm) { + candidateDirs.add(path.dirname(pr)); + } + } catch { + // ignore + } + } + // Common local format: ~/.gemini/tmp//chats/session-*.json + candidateDirs.add(path.join(root, path.basename(cwd))); + + let bestUnclaimed: { file: string; delta: number } | null = null; + let bestClaimed: { file: string; delta: number } | null = null; + let bestCandidateAnyUnclaimed: { file: string; mtime: number } | null = null; + let bestCandidateAnyClaimed: { file: string; mtime: number } | null = null; + for (const dir of candidateDirs) { + const chatsDir = path.join(dir, 'chats'); + if (!fs.existsSync(chatsDir)) continue; + let entries: string[]; + try { + entries = fs.readdirSync(chatsDir).filter(f => /^session-.*\.json$/.test(f)); + } catch { + continue; + } + for (const name of entries) { + const full = path.join(chatsDir, name); + const isClaimed = claimedFiles.has(full); + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (isClaimed) { + if (!bestCandidateAnyClaimed || stat.mtimeMs > bestCandidateAnyClaimed.mtime) { + bestCandidateAnyClaimed = { file: full, mtime: stat.mtimeMs }; + } + } else { + if (!bestCandidateAnyUnclaimed || stat.mtimeMs > bestCandidateAnyUnclaimed.mtime) { + bestCandidateAnyUnclaimed = { file: full, mtime: stat.mtimeMs }; + } + } + if (stat.mtimeMs < launchMeta.launchedAt - 120000) continue; + const delta = Math.abs(stat.mtimeMs - launchMeta.launchedAt); + if (isClaimed) { + if (!bestClaimed || delta < bestClaimed.delta) bestClaimed = { file: full, delta }; + } else { + if (!bestUnclaimed || delta < bestUnclaimed.delta) bestUnclaimed = { file: full, delta }; + } + } + } + if (bestUnclaimed?.file || bestClaimed?.file) return bestUnclaimed?.file ?? bestClaimed?.file ?? null; + // If no recent file was found, prefer newest file inside cwd-matched candidate dirs. + if (bestCandidateAnyUnclaimed?.file || bestCandidateAnyClaimed?.file) { + return bestCandidateAnyUnclaimed?.file ?? bestCandidateAnyClaimed?.file ?? null; + } + + // Fallback: scan all session files under ~/.gemini/tmp and pick nearest recent. + const allCandidates = findNewestFiles(root, f => /^session-.*\.json$/.test(path.basename(f)), 800); + let fallbackUnclaimed: { file: string; delta: number } | null = null; + let fallbackClaimed: { file: string; delta: number } | null = null; + for (const file of allCandidates) { + const isClaimed = claimedFiles.has(file); + let stat: fs.Stats; + try { + stat = fs.statSync(file); + } catch { + continue; + } + if (stat.mtimeMs < launchMeta.launchedAt - 120000) continue; + const delta = Math.abs(stat.mtimeMs - launchMeta.launchedAt); + if (isClaimed) { + if (!fallbackClaimed || delta < fallbackClaimed.delta) fallbackClaimed = { file, delta }; + } else { + if (!fallbackUnclaimed || delta < fallbackUnclaimed.delta) fallbackUnclaimed = { file, delta }; + } + } + return fallbackUnclaimed?.file ?? fallbackClaimed?.file ?? null; +} + +export const PROVIDER_DEFINITIONS: Record = { + claude: { + id: 'claude', + label: 'Claude', + terminalPrefix: 'Claude Code', + sessionFormat: 'jsonl', + commandForLaunch: (sessionId?: string) => `claude --session-id ${sessionId || crypto.randomUUID()}`, + getProjectDirPath: (cwd?: string) => resolveClaudeProjectDir(cwd), + resolveSessionFile: (cwd, launchMeta) => resolveClaudeSessionFile(cwd, launchMeta), + }, + codex: { + id: 'codex', + label: 'Codex', + terminalPrefix: 'Codex', + sessionFormat: 'jsonl', + commandForLaunch: () => 'codex', + getProjectDirPath: () => resolveCodexProjectDir(), + resolveSessionFile: (cwd, launchMeta, claimedFiles) => resolveCodexSessionFile(cwd, launchMeta, claimedFiles), + }, + gemini: { + id: 'gemini', + label: 'Gemini', + terminalPrefix: 'Gemini', + sessionFormat: 'gemini-json', + commandForLaunch: () => 'gemini', + getProjectDirPath: () => resolveGeminiProjectDir(), + resolveSessionFile: (cwd, launchMeta, claimedFiles) => resolveGeminiSessionFile(cwd, launchMeta, claimedFiles), + }, +}; + +export function detectInstalledProviders(): Record { + return { + claude: commandExists('claude'), + codex: commandExists('codex'), + gemini: commandExists('gemini'), + }; +} + +export function getRecommendedProvider( + installed: Record, +): AgentProvider | null { + for (const id of ['claude', 'codex', 'gemini'] as AgentProvider[]) { + if (installed[id]) return id; + } + return null; +} + diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index 059b42b5..cad5dbf5 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -20,24 +20,34 @@ export const PERMISSION_EXEMPT_TOOLS = new Set(['Task', 'AskUserQuestion']); 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': { + case 'Read': + case 'read_file': + return `Reading ${base(input.file_path)}`; + case 'Edit': + case 'replace': + return `Editing ${base(input.file_path)}`; + case 'Write': + case 'write_file': + return `Writing ${base(input.file_path)}`; + case 'Bash': + case 'shell_command': + case 'run_shell_command': { 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 'WebFetch': + case 'google_web_search': + 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`; + case 'NotebookEdit': return 'Editing notebook'; default: return `Using ${toolName}`; } } @@ -52,6 +62,201 @@ export function processTranscriptLine( ): void { const agent = agents.get(agentId); if (!agent) return; + if (agent.provider === 'codex') { + processCodexRecord(agentId, line, agent, agents, waitingTimers, permissionTimers, webview); + return; + } + processClaudeRecord(agentId, line, agent, agents, waitingTimers, permissionTimers, webview); +} + +export function processGeminiSessionSnapshot( + agentId: number, + rawJson: string, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + const agent = agents.get(agentId); + if (!agent) return; + try { + const parsed = JSON.parse(rawJson) as { messages?: Array> }; + const messages = Array.isArray(parsed.messages) ? parsed.messages : []; + const startIdx = agent.processedGeminiMessages ?? 0; + if (messages.length <= startIdx) return; + let currentTurnToolId: string | null = null; + for (const toolId of agent.activeToolIds) { + if (toolId.startsWith('gemini-turn:')) { + currentTurnToolId = toolId; + break; + } + } + + for (let i = startIdx; i < messages.length; i++) { + const msg = messages[i]; + const msgType = typeof msg.type === 'string' ? msg.type : ''; + const msgId = typeof msg.id === 'string' ? msg.id : `${i}`; + const ts = typeof msg.timestamp === 'string' ? msg.timestamp : undefined; + if (ts) { + agent.lastGeminiMessageTs = ts; + } + + if (msgType === 'user' && currentTurnToolId === null) { + const virtualToolId = `gemini-turn:${msgId}`; + const status = 'Thinking'; + currentTurnToolId = virtualToolId; + agent.activeToolIds.add(virtualToolId); + agent.activeToolStatuses.set(virtualToolId, status); + agent.activeToolNames.set(virtualToolId, 'GeminiTurn'); + webview?.postMessage({ type: 'agentToolStart', id: agentId, toolId: virtualToolId, status }); + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + cancelWaitingTimer(agentId, waitingTimers); + } + + if (msgType === 'gemini') { + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + cancelWaitingTimer(agentId, waitingTimers); + if (currentTurnToolId) { + const finished = currentTurnToolId; + agent.activeToolIds.delete(finished); + agent.activeToolStatuses.delete(finished); + agent.activeToolNames.delete(finished); + currentTurnToolId = null; + setTimeout(() => { + webview?.postMessage({ type: 'agentToolDone', id: agentId, toolId: finished }); + }, TOOL_DONE_DELAY_MS); + } + } + + const toolCalls = Array.isArray(msg.toolCalls) ? msg.toolCalls as Array> : []; + for (const call of toolCalls) { + const callId = typeof call.id === 'string' ? call.id : ''; + if (!callId) continue; + if (!agent.seenToolCalls) agent.seenToolCalls = new Set(); + if (!agent.seenToolDone) agent.seenToolDone = new Set(); + if (!agent.seenToolCalls.has(callId)) { + const toolName = typeof call.name === 'string' ? call.name : 'Tool'; + const args = (call.args as Record) || {}; + const status = formatToolStatus(toolName, args); + agent.seenToolCalls.add(callId); + agent.activeToolIds.add(callId); + agent.activeToolStatuses.set(callId, status); + agent.activeToolNames.set(callId, toolName); + webview?.postMessage({ type: 'agentToolStart', id: agentId, toolId: callId, status }); + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + cancelWaitingTimer(agentId, waitingTimers); + } + + const doneStatus = typeof call.status === 'string' ? call.status : ''; + if ((doneStatus === 'success' || doneStatus === 'error') && !agent.seenToolDone.has(callId)) { + agent.seenToolDone.add(callId); + agent.activeToolIds.delete(callId); + agent.activeToolStatuses.delete(callId); + agent.activeToolNames.delete(callId); + const toolId = callId; + setTimeout(() => { + webview?.postMessage({ type: 'agentToolDone', id: agentId, toolId }); + }, TOOL_DONE_DELAY_MS); + } + } + + if (msgType === 'gemini') { + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } + } + + agent.processedGeminiMessages = messages.length; + } catch { + // ignore malformed session snapshot + } +} + +function processCodexRecord( + agentId: number, + line: string, + agent: AgentState, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { + try { + const record = JSON.parse(line) as Record; + const topType = record.type; + if (topType === 'response_item') { + const payload = record.payload as Record | undefined; + if (!payload) return; + const payloadType = payload.type; + if (payloadType === 'function_call') { + const callId = typeof payload.call_id === 'string' ? payload.call_id : ''; + const toolName = typeof payload.name === 'string' ? payload.name : 'Tool'; + if (!callId) return; + let args: Record = {}; + const rawArgs = payload.arguments; + if (typeof rawArgs === 'string') { + try { + args = JSON.parse(rawArgs) as Record; + } catch { + args = { command: rawArgs }; + } + } + const status = formatToolStatus(toolName, args); + agent.activeToolIds.add(callId); + agent.activeToolStatuses.set(callId, status); + agent.activeToolNames.set(callId, toolName); + webview?.postMessage({ type: 'agentToolStart', id: agentId, toolId: callId, status }); + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + cancelWaitingTimer(agentId, waitingTimers); + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } else if (payloadType === 'function_call_output') { + const callId = typeof payload.call_id === 'string' ? payload.call_id : ''; + if (!callId) return; + agent.activeToolIds.delete(callId); + agent.activeToolStatuses.delete(callId); + agent.activeToolNames.delete(callId); + setTimeout(() => { + webview?.postMessage({ type: 'agentToolDone', id: agentId, toolId: callId }); + }, TOOL_DONE_DELAY_MS); + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } else if (payloadType === 'message') { + const role = payload.role; + if (role === 'assistant') { + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'active' }); + cancelWaitingTimer(agentId, waitingTimers); + startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, webview); + } + } + } else if (topType === 'event_msg') { + const payload = record.payload as Record | undefined; + const eventType = payload?.type; + if (eventType === 'task_complete') { + cancelWaitingTimer(agentId, waitingTimers); + cancelPermissionTimer(agentId, permissionTimers); + if (agent.activeToolIds.size > 0) { + agent.activeToolIds.clear(); + agent.activeToolStatuses.clear(); + agent.activeToolNames.clear(); + webview?.postMessage({ type: 'agentToolsClear', id: agentId }); + } + agent.isWaiting = true; + agent.permissionSent = false; + webview?.postMessage({ type: 'agentStatus', id: agentId, status: 'waiting' }); + } + } + } catch { + // ignore malformed lines + } +} + +function processClaudeRecord( + agentId: number, + line: string, + agent: AgentState, + agents: Map, + waitingTimers: Map>, + permissionTimers: Map>, + webview: vscode.Webview | undefined, +): void { try { const record = JSON.parse(line); @@ -71,7 +276,6 @@ export function processTranscriptLine( 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); @@ -90,10 +294,6 @@ export function processTranscriptLine( 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') { @@ -106,9 +306,7 @@ export function processTranscriptLine( 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); @@ -131,19 +329,15 @@ export function processTranscriptLine( }, 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; @@ -151,8 +345,6 @@ export function processTranscriptLine( } 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(); @@ -161,7 +353,6 @@ export function processTranscriptLine( agent.activeSubagentToolNames.clear(); webview?.postMessage({ type: 'agentToolsClear', id: agentId }); } - agent.isWaiting = true; agent.permissionSent = false; agent.hadToolsInTurn = false; @@ -193,8 +384,6 @@ function processProgressRecord( 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)) { @@ -203,7 +392,6 @@ function processProgressRecord( 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; @@ -220,9 +408,7 @@ function processProgressRecord( 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(); @@ -230,7 +416,6 @@ function processProgressRecord( } subTools.add(block.id); - // Track sub-tool names (for permission checking) let subNames = agent.activeSubagentToolNames.get(parentToolId); if (!subNames) { subNames = new Map(); @@ -257,9 +442,6 @@ function processProgressRecord( } 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); @@ -277,11 +459,9 @@ function processProgressRecord( parentToolId, toolId, }); - }, 300); + }, TOOL_DONE_DELAY_MS); } } - // 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) { @@ -297,3 +477,4 @@ function processProgressRecord( } } } + diff --git a/src/types.ts b/src/types.ts index 973afa3b..245f4e42 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,24 @@ import type * as vscode from 'vscode'; +import type { PROVIDERS } from './constants.js'; + +export type AgentProvider = (typeof PROVIDERS)[number]; +export type SessionFormat = 'jsonl' | 'gemini-json'; export interface AgentState { id: number; terminalRef: vscode.Terminal; + provider: AgentProvider; projectDir: string; jsonlFile: string; + sessionFormat: SessionFormat; + sessionId?: string; + launchTime?: number; fileOffset: number; lineBuffer: string; + seenToolCalls?: Set; + seenToolDone?: Set; + processedGeminiMessages?: number; + lastGeminiMessageTs?: string; activeToolIds: Set; activeToolStatuses: Map; activeToolNames: Map; @@ -24,6 +36,9 @@ export interface PersistedAgent { terminalName: string; jsonlFile: string; projectDir: string; + provider?: AgentProvider; + sessionFormat?: SessionFormat; + sessionId?: string; /** 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 5d1c6ded..e580a7b9 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -121,7 +121,20 @@ function App() { const isEditDirty = useCallback(() => editor.isEditMode && editor.isDirty, [editor.isEditMode, editor.isDirty]) - const { agents, selectedAgent, agentTools, agentStatuses, subagentTools, subagentCharacters, layoutReady, loadedAssets, workspaceFolders } = useExtensionMessages(getOfficeState, editor.setLastSavedLayout, isEditDirty) + const { + agents, + selectedAgent, + agentTools, + agentStatuses, + subagentTools, + subagentCharacters, + layoutReady, + loadedAssets, + workspaceFolders, + providers, + // recommendedDefault, + providerPreference, + } = useExtensionMessages(getOfficeState, editor.setLastSavedLayout, isEditDirty) const [isDebugMode, setIsDebugMode] = useState(false) @@ -225,11 +238,13 @@ function App() { {editor.isEditMode && editor.isDirty && ( diff --git a/webview-ui/src/components/BottomToolbar.tsx b/webview-ui/src/components/BottomToolbar.tsx index 48d17419..af5f4480 100644 --- a/webview-ui/src/components/BottomToolbar.tsx +++ b/webview-ui/src/components/BottomToolbar.tsx @@ -1,15 +1,17 @@ import { useState, useEffect, useRef } from 'react' import { SettingsModal } from './SettingsModal.js' -import type { WorkspaceFolder } from '../hooks/useExtensionMessages.js' -import { vscode } from '../vscodeApi.js' +import type { WorkspaceFolder, DetectedProvider, ProviderPreference } from '../hooks/useExtensionMessages.js' +import type { Provider } from '../office/types.js' interface BottomToolbarProps { isEditMode: boolean - onOpenClaude: () => void + onOpenAgent: (provider?: Provider, folderPath?: string) => void onToggleEditMode: () => void isDebugMode: boolean onToggleDebugMode: () => void workspaceFolders: WorkspaceFolder[] + providers: DetectedProvider[] + providerPreference: ProviderPreference } const panelStyle: React.CSSProperties = { @@ -46,106 +48,207 @@ const btnActive: React.CSSProperties = { export function BottomToolbar({ isEditMode, - onOpenClaude, + onOpenAgent, onToggleEditMode, isDebugMode, onToggleDebugMode, workspaceFolders, + providers, + providerPreference, }: BottomToolbarProps) { const [hovered, setHovered] = useState(null) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [isFolderPickerOpen, setIsFolderPickerOpen] = useState(false) + const [isProviderPickerOpen, setIsProviderPickerOpen] = useState(false) + const [pendingFolderPath, setPendingFolderPath] = useState(undefined) const [hoveredFolder, setHoveredFolder] = useState(null) + const [hoveredProvider, setHoveredProvider] = useState(null) const folderPickerRef = useRef(null) + const providerPickerRef = useRef(null) - // Close folder picker on outside click + // Close pickers on outside click useEffect(() => { - if (!isFolderPickerOpen) return const handleClick = (e: MouseEvent) => { - if (folderPickerRef.current && !folderPickerRef.current.contains(e.target as Node)) { + if (isFolderPickerOpen && folderPickerRef.current && !folderPickerRef.current.contains(e.target as Node)) { setIsFolderPickerOpen(false) } + if (isProviderPickerOpen && providerPickerRef.current && !providerPickerRef.current.contains(e.target as Node)) { + setIsProviderPickerOpen(false) + } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) - }, [isFolderPickerOpen]) + }, [isFolderPickerOpen, isProviderPickerOpen]) const hasMultipleFolders = workspaceFolders.length > 1 + const installedProviders = providers.filter((p) => p.installed).map((p) => p.id) + + const getPreferredProvider = (): Provider | undefined => { + const preferred = providerPreference.defaultProvider + if (installedProviders.includes(preferred)) return preferred + if (installedProviders.length > 0) return installedProviders[0] + return 'claude' + } + + const launchAgent = (folderPath?: string) => { + if (providerPreference.askEachTime && installedProviders.length > 1) { + setPendingFolderPath(folderPath) + setIsProviderPickerOpen(true) + return + } + onOpenAgent(getPreferredProvider(), folderPath) + } const handleAgentClick = () => { if (hasMultipleFolders) { setIsFolderPickerOpen((v) => !v) } else { - onOpenClaude() + launchAgent() } } const handleFolderSelect = (folder: WorkspaceFolder) => { setIsFolderPickerOpen(false) - vscode.postMessage({ type: 'openClaude', folderPath: folder.path }) + launchAgent(folder.path) + } + + const handleProviderSelect = (provider: Provider) => { + setIsProviderPickerOpen(false) + const folderPath = pendingFolderPath + setPendingFolderPath(undefined) + onOpenAgent(provider, folderPath) } return (
-
- - {isFolderPickerOpen && ( -
+
+ - ))} -
- )} + + Agent + + {isFolderPickerOpen && ( +
+ {workspaceFolders.map((folder, i) => ( + + ))} +
+ )} +
+ +
+ + {isProviderPickerOpen && ( +
+ {providers.map((p) => ( + + ))} +
+ )} +
+
diff --git a/webview-ui/src/components/SettingsModal.tsx b/webview-ui/src/components/SettingsModal.tsx index 174450ec..785669e7 100644 --- a/webview-ui/src/components/SettingsModal.tsx +++ b/webview-ui/src/components/SettingsModal.tsx @@ -1,12 +1,16 @@ import { useState } from 'react' import { vscode } from '../vscodeApi.js' import { isSoundEnabled, setSoundEnabled } from '../notificationSound.js' +import type { DetectedProvider, ProviderPreference } from '../hooks/useExtensionMessages.js' +import type { Provider } from '../office/types.js' interface SettingsModalProps { isOpen: boolean onClose: () => void isDebugMode: boolean onToggleDebugMode: () => void + providers: DetectedProvider[] + providerPreference: ProviderPreference } const menuItemBase: React.CSSProperties = { @@ -24,12 +28,43 @@ const menuItemBase: React.CSSProperties = { textAlign: 'left', } -export function SettingsModal({ isOpen, onClose, isDebugMode, onToggleDebugMode }: SettingsModalProps) { +const sectionHeaderStyle: React.CSSProperties = { + fontSize: '20px', + color: 'var(--pixel-text-dim)', + padding: '8px 10px 4px', + textTransform: 'uppercase', + letterSpacing: '1px', +} + +export function SettingsModal({ + isOpen, + onClose, + isDebugMode, + onToggleDebugMode, + providers, + providerPreference, +}: SettingsModalProps) { const [hovered, setHovered] = useState(null) const [soundLocal, setSoundLocal] = useState(isSoundEnabled) if (!isOpen) return null + const handleProviderChange = (defaultProvider: Provider) => { + vscode.postMessage({ + type: 'saveProviderPreference', + defaultProvider, + askEachTime: providerPreference.askEachTime, + }) + } + + const handleAskEachTimeToggle = () => { + vscode.postMessage({ + type: 'saveProviderPreference', + defaultProvider: providerPreference.defaultProvider, + askEachTime: !providerPreference.askEachTime, + }) + } + return ( <> {/* Dark backdrop — click to close */} @@ -58,7 +93,7 @@ export function SettingsModal({ isOpen, onClose, isDebugMode, onToggleDebugMode borderRadius: 0, padding: '4px', boxShadow: 'var(--pixel-shadow)', - minWidth: 200, + minWidth: 280, }} > {/* Header with title and X button */} @@ -91,6 +126,75 @@ export function SettingsModal({ isOpen, onClose, isDebugMode, onToggleDebugMode X + + {/* Provider Settings */} +
AI Providers
+
+
+ {providers.map((p) => ( + + ))} +
+
+ + + +
General
+ {/* Menu items */}