diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index 3108c3b..934e117 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -1,7 +1,10 @@ // Sidecar lifecycle management (Deno-first, Node.js fallback) -// Spawns bundled agent-runner.mjs via deno or node, communicates via stdio NDJSON +// Spawns per-provider runner scripts (e.g. claude-runner.mjs, aider-runner.mjs) +// via deno or node, communicates via stdio NDJSON. +// Each provider gets its own process, started lazily on first query. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; #[cfg(unix)] use std::os::unix::process::CommandExt; @@ -58,10 +61,18 @@ struct SidecarCommand { args: Vec, } +/// Per-provider sidecar process state. +struct ProviderProcess { + child: Child, + stdin_writer: Box, + ready: bool, +} + pub struct SidecarManager { - child: Arc>>, - stdin_writer: Arc>>>, - ready: Arc>, + /// Provider name → running sidecar process + providers: Arc>>, + /// Session ID → provider name (for routing stop messages) + session_providers: Arc>>, sink: Arc, config: Mutex, } @@ -69,9 +80,8 @@ pub struct SidecarManager { impl SidecarManager { pub fn new(sink: Arc, config: SidecarConfig) -> Self { Self { - child: Arc::new(Mutex::new(None)), - stdin_writer: Arc::new(Mutex::new(None)), - ready: Arc::new(Mutex::new(false)), + providers: Arc::new(Mutex::new(HashMap::new())), + session_providers: Arc::new(Mutex::new(HashMap::new())), sink, config: Mutex::new(config), } @@ -82,21 +92,25 @@ impl SidecarManager { self.config.lock().unwrap().sandbox = sandbox; } + /// Start the default (claude) provider sidecar. Called on app startup. pub fn start(&self) -> Result<(), String> { - let mut child_lock = self.child.lock().unwrap(); - if child_lock.is_some() { - return Err("Sidecar already running".to_string()); + self.start_provider("claude") + } + + /// Start a specific provider's sidecar process. + fn start_provider(&self, provider: &str) -> Result<(), String> { + let mut providers = self.providers.lock().unwrap(); + if providers.contains_key(provider) { + return Err(format!("Sidecar for '{}' already running", provider)); } let config = self.config.lock().unwrap(); - let cmd = self.resolve_sidecar_command_with_config(&config)?; + let cmd = Self::resolve_sidecar_for_provider_with_config(&config, provider)?; - log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" ")); + log::info!("Starting {} sidecar: {} {}", provider, cmd.program, cmd.args.join(" ")); // 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, _)| { strip_provider_env_var(k) @@ -114,7 +128,6 @@ impl SidecarManager { .stderr(Stdio::piped()); // Apply Landlock sandbox in child process before exec (Linux only). - // Restrictions are inherited by all child processes (provider CLIs). #[cfg(unix)] if config.sandbox.enabled { let sandbox = config.sandbox.clone(); @@ -129,12 +142,12 @@ impl SidecarManager { } } - // Drop config lock before spawn (pre_exec closure owns the sandbox clone) + // Drop config lock before spawn drop(config); let mut child = command .spawn() - .map_err(|e| format!("Failed to start sidecar: {e}"))?; + .map_err(|e| format!("Failed to start {} sidecar: {e}", provider))?; let child_stdin = child .stdin @@ -149,11 +162,10 @@ impl SidecarManager { .take() .ok_or("Failed to capture sidecar stderr")?; - *self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin)); - // Stdout reader thread — forwards NDJSON to event sink let sink = self.sink.clone(); - let ready = self.ready.clone(); + let providers_ref = self.providers.clone(); + let provider_name = provider.to_string(); thread::spawn(move || { let reader = BufReader::new(child_stdout); for line in reader.lines() { @@ -165,83 +177,119 @@ impl SidecarManager { match serde_json::from_str::(&line) { Ok(msg) => { if msg.get("type").and_then(|t| t.as_str()) == Some("ready") { - *ready.lock().unwrap() = true; - log::info!("Sidecar ready"); + if let Ok(mut provs) = providers_ref.lock() { + if let Some(p) = provs.get_mut(&provider_name) { + p.ready = true; + } + } + log::info!("{} sidecar ready", provider_name); } sink.emit("sidecar-message", msg); } Err(e) => { - log::warn!("Invalid JSON from sidecar: {e}: {line}"); + log::warn!("Invalid JSON from {} sidecar: {e}: {line}", provider_name); } } } Err(e) => { - log::error!("Sidecar stdout read error: {e}"); + log::error!("{} sidecar stdout read error: {e}", provider_name); break; } } } - log::info!("Sidecar stdout reader exited"); - sink.emit("sidecar-exited", serde_json::Value::Null); + log::info!("{} sidecar stdout reader exited", provider_name); + sink.emit("sidecar-exited", serde_json::json!({ "provider": provider_name })); }); // Stderr reader thread — logs only + let provider_name2 = provider.to_string(); thread::spawn(move || { let reader = BufReader::new(child_stderr); for line in reader.lines() { match line { - Ok(line) => log::info!("[sidecar stderr] {line}"), + Ok(line) => log::info!("[{} sidecar stderr] {line}", provider_name2), Err(e) => { - log::error!("Sidecar stderr read error: {e}"); + log::error!("{} sidecar stderr read error: {e}", provider_name2); break; } } } }); - *child_lock = Some(child); + providers.insert(provider.to_string(), ProviderProcess { + child, + stdin_writer: Box::new(child_stdin), + ready: false, + }); + Ok(()) } - pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> { - let mut writer_lock = self.stdin_writer.lock().unwrap(); - let writer = writer_lock.as_mut().ok_or("Sidecar not running")?; + /// Ensure a provider's sidecar is running and ready, starting it lazily if needed. + fn ensure_provider(&self, provider: &str) -> Result<(), String> { + { + let providers = self.providers.lock().unwrap(); + if let Some(p) = providers.get(provider) { + if p.ready { + return Ok(()); + } + // Started but not ready yet — wait briefly + } else { + drop(providers); + self.start_provider(provider)?; + } + } - let line = - serde_json::to_string(msg).map_err(|e| format!("JSON serialize error: {e}"))?; + // Wait for ready (up to 10 seconds) + for _ in 0..100 { + std::thread::sleep(std::time::Duration::from_millis(100)); + let providers = self.providers.lock().unwrap(); + if let Some(p) = providers.get(provider) { + if p.ready { + return Ok(()); + } + } else { + return Err(format!("{} sidecar process exited before ready", provider)); + } + } + Err(format!("{} sidecar did not become ready within timeout", provider)) + } - writer + fn send_to_provider(&self, provider: &str, msg: &serde_json::Value) -> Result<(), String> { + let mut providers = self.providers.lock().unwrap(); + let proc = providers.get_mut(provider) + .ok_or_else(|| format!("{} sidecar not running", provider))?; + + let line = serde_json::to_string(msg) + .map_err(|e| format!("JSON serialize error: {e}"))?; + + proc.stdin_writer .write_all(line.as_bytes()) .map_err(|e| format!("Sidecar write error: {e}"))?; - writer + proc.stdin_writer .write_all(b"\n") .map_err(|e| format!("Sidecar write error: {e}"))?; - writer + proc.stdin_writer .flush() .map_err(|e| format!("Sidecar flush error: {e}"))?; Ok(()) } - pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> { - if !*self.ready.lock().unwrap() { - return Err("Sidecar not ready".to_string()); - } + /// Legacy send_message — routes to the default (claude) provider. + pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> { + self.send_to_provider("claude", msg) + } - // Validate that the requested provider has a runner available - let runner_name = format!("{}-runner.mjs", options.provider); - let config = self.config.lock().unwrap(); - let runner_exists = config - .search_paths - .iter() - .any(|base| base.join("dist").join(&runner_name).exists()); - drop(config); - if !runner_exists { - return Err(format!( - "No sidecar runner found for provider '{}' (expected {})", - options.provider, runner_name - )); - } + pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> { + let provider = &options.provider; + + // Ensure the provider's sidecar is running and ready + self.ensure_provider(provider)?; + + // Track session → provider mapping for stop routing + self.session_providers.lock().unwrap() + .insert(options.session_id.clone(), provider.clone()); let msg = serde_json::json!({ "type": "query", @@ -263,7 +311,7 @@ impl SidecarManager { "extraEnv": options.extra_env, }); - self.send_message(&msg) + self.send_to_provider(provider, &msg) } pub fn stop_session(&self, session_id: &str) -> Result<(), String> { @@ -271,36 +319,39 @@ impl SidecarManager { "type": "stop", "sessionId": session_id, }); - self.send_message(&msg) + + // Route to the correct provider based on session tracking + let provider = self.session_providers.lock().unwrap() + .get(session_id) + .cloned() + .unwrap_or_else(|| "claude".to_string()); + + self.send_to_provider(&provider, &msg) } pub fn restart(&self) -> Result<(), String> { - log::info!("Restarting sidecar"); + log::info!("Restarting all sidecars"); let _ = self.shutdown(); self.start() } pub fn shutdown(&self) -> Result<(), String> { - let mut child_lock = self.child.lock().unwrap(); - if let Some(ref mut child) = *child_lock { - log::info!("Shutting down sidecar"); - *self.stdin_writer.lock().unwrap() = None; - let _ = child.kill(); - let _ = child.wait(); + let mut providers = self.providers.lock().unwrap(); + for (name, mut proc) in providers.drain() { + log::info!("Shutting down {} sidecar", name); + let _ = proc.child.kill(); + let _ = proc.child.wait(); } - *child_lock = None; - *self.ready.lock().unwrap() = false; + self.session_providers.lock().unwrap().clear(); Ok(()) } + /// Returns true if the default (claude) provider sidecar is ready. pub fn is_ready(&self) -> bool { - *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_with_config(&self, config: &SidecarConfig) -> Result { - Self::resolve_sidecar_for_provider_with_config(config, "claude") + let providers = self.providers.lock().unwrap(); + providers.get("claude") + .map(|p| p.ready) + .unwrap_or(false) } /// Resolve a sidecar command for a specific provider's runner file. @@ -369,12 +420,11 @@ impl SidecarManager { /// First line of defense: strips provider-specific prefixes to prevent nesting detection /// and credential leakage. JS runners apply a second layer of provider-specific stripping. /// -/// Stripped prefixes: CLAUDE*, CODEX*, OLLAMA*, ANTHROPIC_* +/// Stripped prefixes: CLAUDE*, CODEX*, OLLAMA*, AIDER*, ANTHROPIC_* /// Whitelisted: CLAUDE_CODE_EXPERIMENTAL_* (feature flags like agent teams) /// -/// Note: OPENAI_* is NOT stripped here because the Codex runner needs OPENAI_API_KEY -/// from the environment (it re-injects it after its own stripping). If Codex support -/// moves to extraEnv-based key injection, add OPENAI to this list. +/// Note: OPENAI_* and OPENROUTER_* are NOT stripped here because runners need +/// these keys from the environment or extraEnv injection. fn strip_provider_env_var(key: &str) -> bool { if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") { return true; @@ -382,6 +432,7 @@ fn strip_provider_env_var(key: &str) -> bool { if key.starts_with("CLAUDE") || key.starts_with("CODEX") || key.starts_with("OLLAMA") + || key.starts_with("AIDER") || key.starts_with("ANTHROPIC_") { return false; diff --git a/v2/package.json b/v2/package.json index 8e14e16..1a75f78 100644 --- a/v2/package.json +++ b/v2/package.json @@ -17,7 +17,7 @@ "test:e2e": "wdio run tests/e2e/wdio.conf.js", "test:all": "bash scripts/test-all.sh", "test:all:e2e": "bash scripts/test-all.sh --e2e", - "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs && esbuild sidecar/codex-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/codex-runner.mjs && esbuild sidecar/ollama-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/ollama-runner.mjs" + "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs && esbuild sidecar/codex-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/codex-runner.mjs && esbuild sidecar/ollama-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/ollama-runner.mjs && esbuild sidecar/aider-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/aider-runner.mjs" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", diff --git a/v2/sidecar/aider-runner.ts b/v2/sidecar/aider-runner.ts new file mode 100644 index 0000000..31afbd2 --- /dev/null +++ b/v2/sidecar/aider-runner.ts @@ -0,0 +1,261 @@ +// Aider Runner — Node.js sidecar entry point for Aider coding agent +// Spawned by Rust SidecarManager, communicates via stdio NDJSON +// Spawns `aider` CLI as subprocess in non-interactive mode + +import { stdin, stdout, stderr } from 'process'; +import { createInterface } from 'readline'; +import { spawn, type ChildProcess } from 'child_process'; +import { accessSync, constants } from 'fs'; +import { join } from 'path'; + +const rl = createInterface({ input: stdin }); + +const sessions = new Map(); + +function send(msg: Record) { + stdout.write(JSON.stringify(msg) + '\n'); +} + +function log(message: string) { + stderr.write(`[aider-sidecar] ${message}\n`); +} + +rl.on('line', (line: string) => { + try { + const msg = JSON.parse(line); + handleMessage(msg).catch((err: unknown) => { + log(`Unhandled error in message handler: ${err}`); + }); + } catch { + log(`Invalid JSON: ${line}`); + } +}); + +interface QueryMessage { + type: 'query'; + sessionId: string; + prompt: string; + cwd?: string; + model?: string; + systemPrompt?: string; + extraEnv?: Record; + providerConfig?: Record; +} + +interface StopMessage { + type: 'stop'; + sessionId: string; +} + +async function handleMessage(msg: Record) { + switch (msg.type) { + case 'ping': + send({ type: 'pong' }); + break; + case 'query': + await handleQuery(msg as unknown as QueryMessage); + break; + case 'stop': + handleStop(msg as unknown as StopMessage); + break; + default: + send({ type: 'error', message: `Unknown message type: ${msg.type}` }); + } +} + +async function handleQuery(msg: QueryMessage) { + const { sessionId, prompt, cwd, model, systemPrompt, extraEnv, providerConfig } = msg; + + if (sessions.has(sessionId)) { + send({ type: 'error', sessionId, message: 'Session already running' }); + return; + } + + // Find aider binary + const aiderPath = which('aider'); + if (!aiderPath) { + send({ + type: 'agent_error', + sessionId, + message: 'Aider not found. Install with: pipx install aider-chat', + }); + return; + } + + const aiderModel = model || 'openrouter/anthropic/claude-sonnet-4'; + log(`Starting Aider session ${sessionId} with model ${aiderModel}`); + + const controller = new AbortController(); + + // Build aider command args + const args: string[] = [ + '--model', aiderModel, + '--message', prompt, + '--yes-always', // Auto-accept all file changes + '--no-pretty', // Plain text output (no terminal formatting) + '--no-stream', // Complete response (easier to parse) + '--no-git', // Let the outer project handle git + '--no-auto-commits', // Don't auto-commit changes + '--no-check-model-accepts-settings', // Don't warn about model settings + ]; + + // Add system prompt via --read or environment + if (systemPrompt) { + // Aider doesn't have --system-prompt flag, pass via environment + // The model will receive it as part of the conversation + args.push('--message', `[System Context] ${systemPrompt}\n\n${prompt}`); + // Remove the earlier --message prompt since we're combining + const msgIdx = args.indexOf('--message'); + if (msgIdx !== -1) { + args.splice(msgIdx, 2); // Remove first --message and its value + } + } + + // Extra aider flags from providerConfig + if (providerConfig?.editFormat && typeof providerConfig.editFormat === 'string') { + args.push('--edit-format', providerConfig.editFormat); + } + if (providerConfig?.architect === true) { + args.push('--architect'); + } + + // Build environment + const env: Record = { ...process.env as Record }; + + // Pass through API keys from extraEnv + if (extraEnv) { + Object.assign(env, extraEnv); + } + + // OpenRouter API key from environment or providerConfig + if (providerConfig?.openrouterApiKey && typeof providerConfig.openrouterApiKey === 'string') { + env.OPENROUTER_API_KEY = providerConfig.openrouterApiKey; + } + + send({ type: 'agent_started', sessionId }); + + // Emit init event + send({ + type: 'agent_event', + sessionId, + event: { + type: 'system', + subtype: 'init', + session_id: sessionId, + model: aiderModel, + cwd: cwd || process.cwd(), + }, + }); + + // Spawn aider process + const child = spawn(aiderPath, args, { + cwd: cwd || process.cwd(), + env, + stdio: ['pipe', 'pipe', 'pipe'], + signal: controller.signal, + }); + + sessions.set(sessionId, { process: child, controller }); + + let stdoutBuffer = ''; + let stderrBuffer = ''; + + // Stream stdout as text chunks + child.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + stdoutBuffer += text; + + // Emit each line as a text event + const lines = text.split('\n'); + for (const line of lines) { + if (!line) continue; + send({ + type: 'agent_event', + sessionId, + event: { + type: 'assistant', + message: { role: 'assistant', content: line }, + }, + }); + } + }); + + // Capture stderr for logging + child.stderr?.on('data', (data: Buffer) => { + const text = data.toString(); + stderrBuffer += text; + // Log but don't emit to UI (same pattern as other runners) + for (const line of text.split('\n')) { + if (line.trim()) log(`[stderr] ${line}`); + } + }); + + // Handle process exit + child.on('close', (code: number | null, signal: string | null) => { + sessions.delete(sessionId); + + // Emit final result as a single text block + if (stdoutBuffer.trim()) { + send({ + type: 'agent_event', + sessionId, + event: { + type: 'result', + subtype: 'result', + result: stdoutBuffer.trim(), + cost_usd: 0, + duration_ms: 0, + num_turns: 1, + is_error: code !== 0 && code !== null, + session_id: sessionId, + }, + }); + } + + if (controller.signal.aborted) { + send({ type: 'agent_stopped', sessionId, exitCode: null, signal: 'SIGTERM' }); + } else if (code !== 0 && code !== null) { + const errorDetail = stderrBuffer.trim() || `Aider exited with code ${code}`; + send({ type: 'agent_error', sessionId, message: errorDetail }); + } else { + send({ type: 'agent_stopped', sessionId, exitCode: code, signal }); + } + }); + + child.on('error', (err: Error) => { + sessions.delete(sessionId); + log(`Aider spawn error: ${err.message}`); + send({ type: 'agent_error', sessionId, message: `Failed to start Aider: ${err.message}` }); + }); +} + +function handleStop(msg: StopMessage) { + const { sessionId } = msg; + const session = sessions.get(sessionId); + if (!session) { + send({ type: 'error', sessionId, message: 'Session not found' }); + return; + } + + log(`Stopping Aider session ${sessionId}`); + session.controller.abort(); + session.process.kill('SIGTERM'); +} + +function which(name: string): string | null { + const pathDirs = (process.env.PATH || '').split(':'); + for (const dir of pathDirs) { + const full = join(dir, name); + try { + accessSync(full, constants.X_OK); + return full; + } catch { + continue; + } + } + return null; +} + +log('Aider sidecar started'); +log(`Found aider at: ${which('aider') ?? 'NOT FOUND'}`); +send({ type: 'ready' }); diff --git a/v2/src-tauri/src/secrets.rs b/v2/src-tauri/src/secrets.rs index 1ad1cbe..7b547f6 100644 --- a/v2/src-tauri/src/secrets.rs +++ b/v2/src-tauri/src/secrets.rs @@ -14,6 +14,7 @@ const KEYS_META: &str = "__bterminal_keys__"; pub const KNOWN_KEYS: &[&str] = &[ "anthropic_api_key", "openai_api_key", + "openrouter_api_key", "github_token", "relay_token", ]; diff --git a/v2/src-tauri/tauri.conf.json b/v2/src-tauri/tauri.conf.json index f9abccb..1f8f791 100644 --- a/v2/src-tauri/tauri.conf.json +++ b/v2/src-tauri/tauri.conf.json @@ -46,6 +46,7 @@ ], "resources": [ "../sidecar/dist/claude-runner.mjs", + "../sidecar/dist/aider-runner.mjs", "../../btmsg", "../../bttask" ], diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 8ee5af1..4d3d64b 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -9,6 +9,7 @@ import { CLAUDE_PROVIDER } from './lib/providers/claude'; import { CODEX_PROVIDER } from './lib/providers/codex'; import { OLLAMA_PROVIDER } from './lib/providers/ollama'; + import { AIDER_PROVIDER } from './lib/providers/aider'; import { registerMemoryAdapter } from './lib/adapters/memory-adapter'; import { MemoraAdapter } from './lib/adapters/memora-bridge'; import { @@ -102,6 +103,7 @@ registerProvider(CLAUDE_PROVIDER); registerProvider(CODEX_PROVIDER); registerProvider(OLLAMA_PROVIDER); + registerProvider(AIDER_PROVIDER); const memora = new MemoraAdapter(); registerMemoryAdapter(memora); memora.checkAvailability(); diff --git a/v2/src/lib/adapters/aider-messages.ts b/v2/src/lib/adapters/aider-messages.ts new file mode 100644 index 0000000..5f8c3b1 --- /dev/null +++ b/v2/src/lib/adapters/aider-messages.ts @@ -0,0 +1,94 @@ +// Aider Message Adapter — transforms Aider runner events to internal AgentMessage format +// Aider runner emits: system/init, assistant (text lines), result, error + +import type { + AgentMessage, + InitContent, + TextContent, + CostContent, + ErrorContent, +} from './claude-messages'; + +import { str, num } from '../utils/type-guards'; + +/** + * Adapt a raw Aider runner event to AgentMessage[]. + * + * The Aider runner emits events in this format: + * - {type:'system', subtype:'init', model, session_id, cwd} + * - {type:'assistant', message:{role:'assistant', content:'...'}} + * - {type:'result', subtype:'result', result:'...', cost_usd, duration_ms, is_error} + * - {type:'error', message:'...'} + */ +export function adaptAiderMessage(raw: Record): AgentMessage[] { + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case 'system': + if (str(raw.subtype) === 'init') { + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: [], + } satisfies InitContent, + timestamp, + }]; + } + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + + case 'assistant': { + const msg = typeof raw.message === 'object' && raw.message !== null + ? raw.message as Record + : {}; + const text = str(msg.content); + if (!text) return []; + return [{ + id: uuid, + type: 'text', + content: { text } satisfies TextContent, + timestamp, + }]; + } + + case 'result': + return [{ + id: uuid, + type: 'cost', + content: { + totalCostUsd: num(raw.cost_usd), + durationMs: num(raw.duration_ms), + inputTokens: 0, + outputTokens: 0, + numTurns: num(raw.num_turns) || 1, + isError: raw.is_error === true, + } satisfies CostContent, + timestamp, + }]; + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(raw.message, 'Aider error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} diff --git a/v2/src/lib/adapters/message-adapters.ts b/v2/src/lib/adapters/message-adapters.ts index b816b91..b041548 100644 --- a/v2/src/lib/adapters/message-adapters.ts +++ b/v2/src/lib/adapters/message-adapters.ts @@ -6,6 +6,7 @@ import type { ProviderId } from '../providers/types'; import { adaptSDKMessage } from './claude-messages'; import { adaptCodexMessage } from './codex-messages'; import { adaptOllamaMessage } from './ollama-messages'; +import { adaptAiderMessage } from './aider-messages'; /** Function signature for a provider message adapter */ export type MessageAdapter = (raw: Record) => AgentMessage[]; @@ -31,3 +32,4 @@ export function adaptMessage(providerId: ProviderId, raw: Record { export const SECRET_KEY_LABELS: Record = { anthropic_api_key: 'Anthropic API Key', openai_api_key: 'OpenAI API Key', + openrouter_api_key: 'OpenRouter API Key', github_token: 'GitHub Token', relay_token: 'Relay Token', }; diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte index 118e201..a6f598a 100644 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -26,6 +26,7 @@ import type { AgentMessage } from '../../adapters/claude-messages'; import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte'; import { loadAnchorsForProject } from '../../stores/anchors.svelte'; + import { getSecret } from '../../adapters/secrets-bridge'; import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte'; import { SessionId, ProjectId } from '../../types/ids'; import AgentPane from '../Agent/AgentPane.svelte'; @@ -58,15 +59,34 @@ return project.systemPrompt || undefined; }); + // Provider-specific API keys loaded from system keyring + let openrouterKey = $state(null); + + $effect(() => { + if (providerId === 'aider') { + getSecret('openrouter_api_key').then(key => { + openrouterKey = key; + }).catch(() => {}); + } else { + openrouterKey = null; + } + }); + // Inject BTMSG_AGENT_ID for agent projects so they can use btmsg/bttask CLIs // Manager agents also get CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS to enable subagent delegation + // Provider-specific API keys are injected from the system keyring let agentEnv = $derived.by(() => { - if (!project.isAgent) return undefined; - const env: Record = { BTMSG_AGENT_ID: project.id }; - if (project.agentRole === 'manager') { - env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; + const env: Record = {}; + if (project.isAgent) { + env.BTMSG_AGENT_ID = project.id; + if (project.agentRole === 'manager') { + env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; + } } - return env; + if (openrouterKey) { + env.OPENROUTER_API_KEY = openrouterKey; + } + return Object.keys(env).length > 0 ? env : undefined; }); // Periodic context re-injection timer diff --git a/v2/src/lib/providers/aider.ts b/v2/src/lib/providers/aider.ts new file mode 100644 index 0000000..24dae7e --- /dev/null +++ b/v2/src/lib/providers/aider.ts @@ -0,0 +1,32 @@ +// Aider Provider — metadata and capabilities for Aider (OpenRouter / multi-model agent) + +import type { ProviderMeta } from './types'; + +export const AIDER_PROVIDER: ProviderMeta = { + id: 'aider', + name: 'Aider', + description: 'Aider AI coding agent — supports OpenRouter, OpenAI, Anthropic and local models', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: false, + supportsCost: false, + supportsResume: false, + }, + sidecarRunner: 'aider-runner.mjs', + defaultModel: 'openrouter/anthropic/claude-sonnet-4', + models: [ + { id: 'openrouter/anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (OpenRouter)' }, + { id: 'openrouter/anthropic/claude-haiku-4', label: 'Claude Haiku 4 (OpenRouter)' }, + { id: 'openrouter/openai/gpt-4.1', label: 'GPT-4.1 (OpenRouter)' }, + { id: 'openrouter/openai/o3', label: 'o3 (OpenRouter)' }, + { id: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro (OpenRouter)' }, + { id: 'openrouter/deepseek/deepseek-r1', label: 'DeepSeek R1 (OpenRouter)' }, + { id: 'openrouter/meta-llama/llama-4-maverick', label: 'Llama 4 Maverick (OpenRouter)' }, + { id: 'anthropic/claude-sonnet-4-5-20250514', label: 'Claude Sonnet 4.5 (direct)' }, + { id: 'o3', label: 'o3 (OpenAI direct)' }, + { id: 'ollama/qwen3:8b', label: 'Qwen3 8B (Ollama)' }, + ], +}; diff --git a/v2/src/lib/providers/types.ts b/v2/src/lib/providers/types.ts index d8d0cc1..e8fe138 100644 --- a/v2/src/lib/providers/types.ts +++ b/v2/src/lib/providers/types.ts @@ -1,6 +1,6 @@ // Provider abstraction types — defines the interface for multi-provider agent support -export type ProviderId = 'claude' | 'codex' | 'ollama'; +export type ProviderId = 'claude' | 'codex' | 'ollama' | 'aider'; /** What a provider can do — UI gates features on these flags */ export interface ProviderCapabilities {