diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index 34a851b..e3dbf5a 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -12,6 +12,8 @@ use crate::event::EventSink; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentQueryOptions { + #[serde(default = "default_provider")] + pub provider: String, pub session_id: String, pub prompt: String, pub cwd: Option, @@ -24,6 +26,13 @@ pub struct AgentQueryOptions { pub model: Option, pub claude_config_dir: Option, pub additional_directories: Option>, + /// Provider-specific configuration blob (passed through to sidecar as-is) + #[serde(default)] + pub provider_config: serde_json::Value, +} + +fn default_provider() -> String { + "claude".to_string() } /// Directories to search for sidecar scripts. @@ -66,12 +75,13 @@ impl SidecarManager { log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" ")); - // Build a clean environment stripping CLAUDE* vars to prevent - // the SDK from detecting nesting when BTerminal is launched from a Claude Code terminal. - // Whitelist CLAUDE_CODE_EXPERIMENTAL_* so feature flags (e.g. agent teams) pass through. + // Build a clean environment stripping provider-specific vars to prevent + // SDKs from detecting nesting when BTerminal is launched from a provider terminal. + // Per-provider prefixes: CLAUDE* (whitelist CLAUDE_CODE_EXPERIMENTAL_*), + // CODEX* and OLLAMA* for future providers. let clean_env: Vec<(String, String)> = std::env::vars() .filter(|(k, _)| { - !k.starts_with("CLAUDE") || k.starts_with("CLAUDE_CODE_EXPERIMENTAL_") + strip_provider_env_var(k) }) .collect(); @@ -177,8 +187,23 @@ impl SidecarManager { return Err("Sidecar not ready".to_string()); } + // Validate that the requested provider has a runner available + let runner_name = format!("{}-runner.mjs", options.provider); + let runner_exists = self + .config + .search_paths + .iter() + .any(|base| base.join("dist").join(&runner_name).exists()); + if !runner_exists { + return Err(format!( + "No sidecar runner found for provider '{}' (expected {})", + options.provider, runner_name + )); + } + let msg = serde_json::json!({ "type": "query", + "provider": options.provider, "sessionId": options.session_id, "prompt": options.prompt, "cwd": options.cwd, @@ -191,6 +216,7 @@ impl SidecarManager { "model": options.model, "claudeConfigDir": options.claude_config_dir, "additionalDirectories": options.additional_directories, + "providerConfig": options.provider_config, }); self.send_message(&msg) @@ -227,8 +253,16 @@ impl SidecarManager { *self.ready.lock().unwrap() } + /// Resolve a sidecar runner command. Uses the default claude-runner for startup. + /// Future providers will have their own runners (e.g. codex-runner.mjs). fn resolve_sidecar_command(&self) -> Result { - // Single bundled .mjs works with both Deno and Node.js. + self.resolve_sidecar_for_provider("claude") + } + + /// Resolve a sidecar command for a specific provider's runner file. + fn resolve_sidecar_for_provider(&self, provider: &str) -> Result { + let runner_name = format!("{}-runner.mjs", provider); + // Try Deno first (faster startup, better perf), fall back to Node.js. let has_deno = Command::new("deno") .arg("--version") @@ -246,7 +280,7 @@ impl SidecarManager { let mut checked = Vec::new(); for base in &self.config.search_paths { - let mjs_path = base.join("dist").join("agent-runner.mjs"); + let mjs_path = base.join("dist").join(&runner_name); if mjs_path.exists() { if has_deno { return Ok(SidecarCommand { @@ -279,13 +313,27 @@ impl SidecarManager { "" }; Err(format!( - "Sidecar not found. Checked: {}{}", + "Sidecar not found for provider '{}'. Checked: {}{}", + provider, paths.join(", "), runtime_note, )) } } +/// Returns true if the env var should be KEPT (not stripped). +/// Strips CLAUDE*, CODEX*, OLLAMA* prefixes to prevent nesting detection. +/// Whitelists CLAUDE_CODE_EXPERIMENTAL_* for feature flags. +fn strip_provider_env_var(key: &str) -> bool { + if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") { + return true; + } + if key.starts_with("CLAUDE") || key.starts_with("CODEX") || key.starts_with("OLLAMA") { + return false; + } + true +} + impl Drop for SidecarManager { fn drop(&mut self) { let _ = self.shutdown(); diff --git a/v2/package.json b/v2/package.json index 0971f4c..1f1e5c1 100644 --- a/v2/package.json +++ b/v2/package.json @@ -14,7 +14,7 @@ "tauri:build": "cargo tauri build", "test": "vitest run", "test:e2e": "wdio run tests/e2e/wdio.conf.js", - "build:sidecar": "esbuild sidecar/agent-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/agent-runner.mjs" + "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", diff --git a/v2/sidecar/agent-runner.ts b/v2/sidecar/claude-runner.ts similarity index 96% rename from v2/sidecar/agent-runner.ts rename to v2/sidecar/claude-runner.ts index 4d648fd..a2ca35c 100644 --- a/v2/sidecar/agent-runner.ts +++ b/v2/sidecar/claude-runner.ts @@ -1,6 +1,6 @@ -// Agent Runner — Node.js sidecar entry point -// Spawned by Rust backend, communicates via stdio NDJSON -// Uses @anthropic-ai/claude-agent-sdk for proper Claude session management +// Claude Runner — Node.js sidecar entry point for Claude Code provider +// Spawned by Rust SidecarManager, communicates via stdio NDJSON +// Uses @anthropic-ai/claude-agent-sdk for Claude session management import { stdin, stdout, stderr } from 'process'; import { createInterface } from 'readline'; diff --git a/v2/sidecar/package.json b/v2/sidecar/package.json index b51dfc5..afd62b2 100644 --- a/v2/sidecar/package.json +++ b/v2/sidecar/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "build": "esbuild agent-runner.ts --bundle --platform=node --target=node20 --outfile=dist/agent-runner.mjs --format=esm" + "build": "esbuild claude-runner.ts --bundle --platform=node --target=node20 --outfile=dist/claude-runner.mjs --format=esm" }, "devDependencies": { "esbuild": "0.25.4" diff --git a/v2/src-tauri/tauri.conf.json b/v2/src-tauri/tauri.conf.json index 9de1fc6..2deb127 100644 --- a/v2/src-tauri/tauri.conf.json +++ b/v2/src-tauri/tauri.conf.json @@ -44,7 +44,7 @@ "icons/icon.ico" ], "resources": [ - "../sidecar/dist/agent-runner.mjs" + "../sidecar/dist/claude-runner.mjs" ], "category": "DeveloperTool", "shortDescription": "Multi-session Claude agent dashboard", diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 11d6352..694819e 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -5,6 +5,8 @@ import { isDetachedMode, getDetachedConfig } from './lib/utils/detach'; import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher'; import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte'; + import { registerProvider } from './lib/providers/registry.svelte'; + import { CLAUDE_PROVIDER } from './lib/providers/claude'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte'; // Workspace components @@ -65,6 +67,7 @@ getSetting('project_max_aspect').then(v => { if (v) document.documentElement.style.setProperty('--project-max-aspect', v); }); + registerProvider(CLAUDE_PROVIDER); startAgentDispatcher(); startHealthTick(); diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts index 54730c7..6125614 100644 --- a/v2/src/lib/adapters/agent-bridge.ts +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -4,7 +4,10 @@ import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { ProviderId } from '../providers/types'; + export interface AgentQueryOptions { + provider?: ProviderId; session_id: string; prompt: string; cwd?: string; @@ -17,6 +20,7 @@ export interface AgentQueryOptions { model?: string; claude_config_dir?: string; additional_directories?: string[]; + provider_config?: Record; remote_machine_id?: string; } diff --git a/v2/src/lib/adapters/sdk-messages.test.ts b/v2/src/lib/adapters/claude-messages.test.ts similarity index 99% rename from v2/src/lib/adapters/sdk-messages.test.ts rename to v2/src/lib/adapters/claude-messages.test.ts index 6e94d22..752f0ad 100644 --- a/v2/src/lib/adapters/sdk-messages.test.ts +++ b/v2/src/lib/adapters/claude-messages.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { adaptSDKMessage } from './sdk-messages'; -import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './sdk-messages'; +import { adaptSDKMessage } from './claude-messages'; +import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './claude-messages'; // Mock crypto.randomUUID for deterministic IDs when uuid is missing beforeEach(() => { diff --git a/v2/src/lib/adapters/sdk-messages.ts b/v2/src/lib/adapters/claude-messages.ts similarity index 97% rename from v2/src/lib/adapters/sdk-messages.ts rename to v2/src/lib/adapters/claude-messages.ts index 147ff44..99f1139 100644 --- a/v2/src/lib/adapters/sdk-messages.ts +++ b/v2/src/lib/adapters/claude-messages.ts @@ -1,5 +1,5 @@ -// SDK Message Adapter — insulates UI from Claude Agent SDK wire format changes -// This is the ONLY place that knows SDK internals. +// Claude Message Adapter — transforms Claude Agent SDK wire format to internal AgentMessage format +// This is the ONLY place that knows Claude SDK internals. export type AgentMessageType = | 'init' diff --git a/v2/src/lib/adapters/message-adapters.ts b/v2/src/lib/adapters/message-adapters.ts new file mode 100644 index 0000000..5c42093 --- /dev/null +++ b/v2/src/lib/adapters/message-adapters.ts @@ -0,0 +1,29 @@ +// Message Adapter Registry — routes raw provider messages to the correct parser +// Each provider registers its own adapter; the dispatcher calls adaptMessage() + +import type { AgentMessage } from './claude-messages'; +import type { ProviderId } from '../providers/types'; +import { adaptSDKMessage } from './claude-messages'; + +/** Function signature for a provider message adapter */ +export type MessageAdapter = (raw: Record) => AgentMessage[]; + +const adapters = new Map(); + +/** Register a message adapter for a provider */ +export function registerMessageAdapter(providerId: ProviderId, adapter: MessageAdapter): void { + adapters.set(providerId, adapter); +} + +/** Adapt a raw message using the appropriate provider adapter */ +export function adaptMessage(providerId: ProviderId, raw: Record): AgentMessage[] { + const adapter = adapters.get(providerId); + if (!adapter) { + console.warn(`No message adapter for provider: ${providerId}, falling back to claude`); + return adaptSDKMessage(raw); + } + return adapter(raw); +} + +// Register Claude adapter by default +registerMessageAdapter('claude', adaptSDKMessage); diff --git a/v2/src/lib/adapters/provider-bridge.ts b/v2/src/lib/adapters/provider-bridge.ts new file mode 100644 index 0000000..e5fb5db --- /dev/null +++ b/v2/src/lib/adapters/provider-bridge.ts @@ -0,0 +1,26 @@ +// Provider Bridge — generic adapter that delegates to provider-specific bridges +// Currently only Claude is implemented; future providers add their own bridge files + +import type { ProviderId } from '../providers/types'; +import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge'; + +// Re-export types for consumers +export type { ClaudeProfile, ClaudeSkill }; + +/** List profiles for a given provider (only Claude supports this) */ +export async function listProviderProfiles(provider: ProviderId): Promise { + if (provider === 'claude') return claudeListProfiles(); + return []; +} + +/** List skills for a given provider (only Claude supports this) */ +export async function listProviderSkills(provider: ProviderId): Promise { + if (provider === 'claude') return claudeListSkills(); + return []; +} + +/** Read a skill file (only Claude supports this) */ +export async function readProviderSkill(provider: ProviderId, path: string): Promise { + if (provider === 'claude') return claudeReadSkill(path); + return ''; +} diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index dbca069..030564d 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -51,8 +51,10 @@ vi.mock('./adapters/agent-bridge', () => ({ restartAgent: (...args: unknown[]) => mockRestartAgent(...args), })); -vi.mock('./adapters/sdk-messages', () => ({ - adaptSDKMessage: vi.fn((raw: Record) => { +vi.mock('./providers/types', () => ({})); + +vi.mock('./adapters/message-adapters', () => ({ + adaptMessage: vi.fn((_provider: string, raw: Record) => { if (raw.type === 'system' && raw.subtype === 'init') { return [{ id: 'msg-1', diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 76dacb6..44b6b2e 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -2,8 +2,9 @@ // Single listener that routes sidecar messages to the correct agent session import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; -import { adaptSDKMessage } from './adapters/sdk-messages'; -import type { InitContent, CostContent, ToolCallContent } from './adapters/sdk-messages'; +import { adaptMessage } from './adapters/message-adapters'; +import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; +import type { ProviderId } from './providers/types'; import { updateAgentStatus, setAgentSdkSessionId, @@ -34,14 +35,18 @@ let unlistenExit: (() => void) | null = null; // Map sessionId -> projectId for persistence routing const sessionProjectMap = new Map(); +// Map sessionId -> provider for message adapter routing +const sessionProviderMap = new Map(); + // Map sessionId -> start timestamp for metrics const sessionStartTimes = new Map(); // In-flight persistence counter — prevents teardown from racing with async saves let pendingPersistCount = 0; -export function registerSessionProject(sessionId: string, projectId: string): void { +export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void { sessionProjectMap.set(sessionId, projectId); + sessionProviderMap.set(sessionId, provider); } // Sidecar liveness — checked by UI components @@ -149,7 +154,8 @@ const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); const toolUseToChildPane = new Map(); function handleAgentEvent(sessionId: string, event: Record): void { - const messages = adaptSDKMessage(event); + const provider = sessionProviderMap.get(sessionId) ?? 'claude'; + const messages = adaptMessage(provider, event); // Route messages with parentId to the appropriate child pane const mainMessages: typeof messages = []; @@ -401,5 +407,6 @@ export function stopAgentDispatcher(): void { // Clear routing maps to prevent unbounded memory growth toolUseToChildPane.clear(); sessionProjectMap.clear(); + sessionProviderMap.clear(); sessionStartTimes.clear(); } diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index a7509f6..3934e6a 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -21,7 +21,8 @@ CostContent, ErrorContent, StatusContent, - } from '../../adapters/sdk-messages'; + } from '../../adapters/claude-messages'; + import type { ProviderId, ProviderCapabilities } from '../../providers/types'; // Tool-aware truncation limits const MAX_BASH_LINES = 500; @@ -29,15 +30,28 @@ const MAX_GLOB_LINES = 20; const MAX_DEFAULT_LINES = 30; + // Default capabilities (Claude — all enabled) + const DEFAULT_CAPABILITIES: ProviderCapabilities = { + hasProfiles: true, + hasSkills: true, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: true, + supportsCost: true, + supportsResume: true, + }; + interface Props { sessionId: string; prompt?: string; cwd?: string; profile?: string; + provider?: ProviderId; + capabilities?: ProviderCapabilities; onExit?: () => void; } - let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, onExit }: Props = $props(); + let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props(); let session = $derived(getAgentSession(sessionId)); let inputPrompt = $state(initialPrompt); @@ -106,9 +120,10 @@ onMount(async () => { await getHighlighter(); + // Only load profiles/skills for providers that support them const [profileList, skillList] = await Promise.all([ - listProfiles().catch(() => []), - listSkills().catch(() => []), + capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]), + capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]), ]); profiles = profileList; skills = skillList; @@ -144,6 +159,7 @@ const profile = profileName ? profiles.find(p => p.name === profileName) : undefined; await queryAgent({ + provider: providerId, session_id: sessionId, prompt: text, cwd: initialCwd || undefined, @@ -335,7 +351,7 @@ {#if msg.type === 'init'}
Session started - {(msg.content as import('../../adapters/sdk-messages').InitContent).model} + {(msg.content as import('../../adapters/claude-messages').InitContent).model}
{:else if msg.type === 'text'} {@const textContent = (msg.content as TextContent).text} @@ -490,19 +506,21 @@ New Session - + {#if capabilities.supportsResume} + + {/if} {/if}
- {#if showSkillMenu && filteredSkills.length > 0} + {#if capabilities.hasSkills && showSkillMenu && filteredSkills.length > 0}
{#each filteredSkills as skill, i (skill.name)}