Add Aider provider with OpenRouter support and per-provider sidecar routing

- Add aider-runner.ts sidecar that spawns aider CLI in non-interactive mode
- Add Aider provider metadata with OpenRouter model presets
- Add aider-messages.ts adapter for Aider event format
- Refactor SidecarManager from single-process to per-provider process management
  with lazy startup on first query and session→provider routing
- Add openrouter_api_key to secrets system (keyring storage)
- Inject OPENROUTER_API_KEY from secrets into Aider agent environment
- Register Aider in provider registry, build pipeline, and resource bundle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DexterFromLab 2026-03-12 13:33:39 +01:00
parent 35963be686
commit 5b7ad30573
12 changed files with 549 additions and 84 deletions

View file

@ -1,7 +1,10 @@
// Sidecar lifecycle management (Deno-first, Node.js fallback) // 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 serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@ -58,10 +61,18 @@ struct SidecarCommand {
args: Vec<String>, args: Vec<String>,
} }
/// Per-provider sidecar process state.
struct ProviderProcess {
child: Child,
stdin_writer: Box<dyn Write + Send>,
ready: bool,
}
pub struct SidecarManager { pub struct SidecarManager {
child: Arc<Mutex<Option<Child>>>, /// Provider name → running sidecar process
stdin_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>, providers: Arc<Mutex<HashMap<String, ProviderProcess>>>,
ready: Arc<Mutex<bool>>, /// Session ID → provider name (for routing stop messages)
session_providers: Arc<Mutex<HashMap<String, String>>>,
sink: Arc<dyn EventSink>, sink: Arc<dyn EventSink>,
config: Mutex<SidecarConfig>, config: Mutex<SidecarConfig>,
} }
@ -69,9 +80,8 @@ pub struct SidecarManager {
impl SidecarManager { impl SidecarManager {
pub fn new(sink: Arc<dyn EventSink>, config: SidecarConfig) -> Self { pub fn new(sink: Arc<dyn EventSink>, config: SidecarConfig) -> Self {
Self { Self {
child: Arc::new(Mutex::new(None)), providers: Arc::new(Mutex::new(HashMap::new())),
stdin_writer: Arc::new(Mutex::new(None)), session_providers: Arc::new(Mutex::new(HashMap::new())),
ready: Arc::new(Mutex::new(false)),
sink, sink,
config: Mutex::new(config), config: Mutex::new(config),
} }
@ -82,21 +92,25 @@ impl SidecarManager {
self.config.lock().unwrap().sandbox = sandbox; self.config.lock().unwrap().sandbox = sandbox;
} }
/// Start the default (claude) provider sidecar. Called on app startup.
pub fn start(&self) -> Result<(), String> { pub fn start(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap(); self.start_provider("claude")
if child_lock.is_some() { }
return Err("Sidecar already running".to_string());
/// 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 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 // Build a clean environment stripping provider-specific vars to prevent
// SDKs from detecting nesting when BTerminal is launched from a provider terminal. // 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() let clean_env: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| { .filter(|(k, _)| {
strip_provider_env_var(k) strip_provider_env_var(k)
@ -114,7 +128,6 @@ impl SidecarManager {
.stderr(Stdio::piped()); .stderr(Stdio::piped());
// Apply Landlock sandbox in child process before exec (Linux only). // Apply Landlock sandbox in child process before exec (Linux only).
// Restrictions are inherited by all child processes (provider CLIs).
#[cfg(unix)] #[cfg(unix)]
if config.sandbox.enabled { if config.sandbox.enabled {
let sandbox = config.sandbox.clone(); 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); drop(config);
let mut child = command let mut child = command
.spawn() .spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?; .map_err(|e| format!("Failed to start {} sidecar: {e}", provider))?;
let child_stdin = child let child_stdin = child
.stdin .stdin
@ -149,11 +162,10 @@ impl SidecarManager {
.take() .take()
.ok_or("Failed to capture sidecar stderr")?; .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 // Stdout reader thread — forwards NDJSON to event sink
let sink = self.sink.clone(); 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 || { thread::spawn(move || {
let reader = BufReader::new(child_stdout); let reader = BufReader::new(child_stdout);
for line in reader.lines() { for line in reader.lines() {
@ -165,83 +177,119 @@ impl SidecarManager {
match serde_json::from_str::<serde_json::Value>(&line) { match serde_json::from_str::<serde_json::Value>(&line) {
Ok(msg) => { Ok(msg) => {
if msg.get("type").and_then(|t| t.as_str()) == Some("ready") { if msg.get("type").and_then(|t| t.as_str()) == Some("ready") {
*ready.lock().unwrap() = true; if let Ok(mut provs) = providers_ref.lock() {
log::info!("Sidecar ready"); if let Some(p) = provs.get_mut(&provider_name) {
p.ready = true;
}
}
log::info!("{} sidecar ready", provider_name);
} }
sink.emit("sidecar-message", msg); sink.emit("sidecar-message", msg);
} }
Err(e) => { Err(e) => {
log::warn!("Invalid JSON from sidecar: {e}: {line}"); log::warn!("Invalid JSON from {} sidecar: {e}: {line}", provider_name);
} }
} }
} }
Err(e) => { Err(e) => {
log::error!("Sidecar stdout read error: {e}"); log::error!("{} sidecar stdout read error: {e}", provider_name);
break; break;
} }
} }
} }
log::info!("Sidecar stdout reader exited"); log::info!("{} sidecar stdout reader exited", provider_name);
sink.emit("sidecar-exited", serde_json::Value::Null); sink.emit("sidecar-exited", serde_json::json!({ "provider": provider_name }));
}); });
// Stderr reader thread — logs only // Stderr reader thread — logs only
let provider_name2 = provider.to_string();
thread::spawn(move || { thread::spawn(move || {
let reader = BufReader::new(child_stderr); let reader = BufReader::new(child_stderr);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) => log::info!("[sidecar stderr] {line}"), Ok(line) => log::info!("[{} sidecar stderr] {line}", provider_name2),
Err(e) => { Err(e) => {
log::error!("Sidecar stderr read error: {e}"); log::error!("{} sidecar stderr read error: {e}", provider_name2);
break; break;
} }
} }
} }
}); });
*child_lock = Some(child); providers.insert(provider.to_string(), ProviderProcess {
child,
stdin_writer: Box::new(child_stdin),
ready: false,
});
Ok(()) Ok(())
} }
pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> { /// Ensure a provider's sidecar is running and ready, starting it lazily if needed.
let mut writer_lock = self.stdin_writer.lock().unwrap(); fn ensure_provider(&self, provider: &str) -> Result<(), String> {
let writer = writer_lock.as_mut().ok_or("Sidecar not running")?; {
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 = // Wait for ready (up to 10 seconds)
serde_json::to_string(msg).map_err(|e| format!("JSON serialize error: {e}"))?; 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()) .write_all(line.as_bytes())
.map_err(|e| format!("Sidecar write error: {e}"))?; .map_err(|e| format!("Sidecar write error: {e}"))?;
writer proc.stdin_writer
.write_all(b"\n") .write_all(b"\n")
.map_err(|e| format!("Sidecar write error: {e}"))?; .map_err(|e| format!("Sidecar write error: {e}"))?;
writer proc.stdin_writer
.flush() .flush()
.map_err(|e| format!("Sidecar flush error: {e}"))?; .map_err(|e| format!("Sidecar flush error: {e}"))?;
Ok(()) Ok(())
} }
pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> { /// Legacy send_message — routes to the default (claude) provider.
if !*self.ready.lock().unwrap() { pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> {
return Err("Sidecar not ready".to_string()); self.send_to_provider("claude", msg)
} }
// Validate that the requested provider has a runner available pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> {
let runner_name = format!("{}-runner.mjs", options.provider); let provider = &options.provider;
let config = self.config.lock().unwrap();
let runner_exists = config // Ensure the provider's sidecar is running and ready
.search_paths self.ensure_provider(provider)?;
.iter()
.any(|base| base.join("dist").join(&runner_name).exists()); // Track session → provider mapping for stop routing
drop(config); self.session_providers.lock().unwrap()
if !runner_exists { .insert(options.session_id.clone(), provider.clone());
return Err(format!(
"No sidecar runner found for provider '{}' (expected {})",
options.provider, runner_name
));
}
let msg = serde_json::json!({ let msg = serde_json::json!({
"type": "query", "type": "query",
@ -263,7 +311,7 @@ impl SidecarManager {
"extraEnv": options.extra_env, "extraEnv": options.extra_env,
}); });
self.send_message(&msg) self.send_to_provider(provider, &msg)
} }
pub fn stop_session(&self, session_id: &str) -> Result<(), String> { pub fn stop_session(&self, session_id: &str) -> Result<(), String> {
@ -271,36 +319,39 @@ impl SidecarManager {
"type": "stop", "type": "stop",
"sessionId": session_id, "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> { pub fn restart(&self) -> Result<(), String> {
log::info!("Restarting sidecar"); log::info!("Restarting all sidecars");
let _ = self.shutdown(); let _ = self.shutdown();
self.start() self.start()
} }
pub fn shutdown(&self) -> Result<(), String> { pub fn shutdown(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap(); let mut providers = self.providers.lock().unwrap();
if let Some(ref mut child) = *child_lock { for (name, mut proc) in providers.drain() {
log::info!("Shutting down sidecar"); log::info!("Shutting down {} sidecar", name);
*self.stdin_writer.lock().unwrap() = None; let _ = proc.child.kill();
let _ = child.kill(); let _ = proc.child.wait();
let _ = child.wait();
} }
*child_lock = None; self.session_providers.lock().unwrap().clear();
*self.ready.lock().unwrap() = false;
Ok(()) Ok(())
} }
/// Returns true if the default (claude) provider sidecar is ready.
pub fn is_ready(&self) -> bool { pub fn is_ready(&self) -> bool {
*self.ready.lock().unwrap() let providers = self.providers.lock().unwrap();
} providers.get("claude")
.map(|p| p.ready)
/// Resolve a sidecar runner command. Uses the default claude-runner for startup. .unwrap_or(false)
/// Future providers will have their own runners (e.g. codex-runner.mjs).
fn resolve_sidecar_command_with_config(&self, config: &SidecarConfig) -> Result<SidecarCommand, String> {
Self::resolve_sidecar_for_provider_with_config(config, "claude")
} }
/// Resolve a sidecar command for a specific provider's runner file. /// 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 /// 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. /// 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) /// Whitelisted: CLAUDE_CODE_EXPERIMENTAL_* (feature flags like agent teams)
/// ///
/// Note: OPENAI_* is NOT stripped here because the Codex runner needs OPENAI_API_KEY /// Note: OPENAI_* and OPENROUTER_* are NOT stripped here because runners need
/// from the environment (it re-injects it after its own stripping). If Codex support /// these keys from the environment or extraEnv injection.
/// moves to extraEnv-based key injection, add OPENAI to this list.
fn strip_provider_env_var(key: &str) -> bool { fn strip_provider_env_var(key: &str) -> bool {
if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") { if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") {
return true; return true;
@ -382,6 +432,7 @@ fn strip_provider_env_var(key: &str) -> bool {
if key.starts_with("CLAUDE") if key.starts_with("CLAUDE")
|| key.starts_with("CODEX") || key.starts_with("CODEX")
|| key.starts_with("OLLAMA") || key.starts_with("OLLAMA")
|| key.starts_with("AIDER")
|| key.starts_with("ANTHROPIC_") || key.starts_with("ANTHROPIC_")
{ {
return false; return false;

View file

@ -17,7 +17,7 @@
"test:e2e": "wdio run tests/e2e/wdio.conf.js", "test:e2e": "wdio run tests/e2e/wdio.conf.js",
"test:all": "bash scripts/test-all.sh", "test:all": "bash scripts/test-all.sh",
"test:all:e2e": "bash scripts/test-all.sh --e2e", "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": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",

261
v2/sidecar/aider-runner.ts Normal file
View file

@ -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<string, { process: ChildProcess; controller: AbortController }>();
function send(msg: Record<string, unknown>) {
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<string, string>;
providerConfig?: Record<string, unknown>;
}
interface StopMessage {
type: 'stop';
sessionId: string;
}
async function handleMessage(msg: Record<string, unknown>) {
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<string, string> = { ...process.env as Record<string, string> };
// 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' });

View file

@ -14,6 +14,7 @@ const KEYS_META: &str = "__bterminal_keys__";
pub const KNOWN_KEYS: &[&str] = &[ pub const KNOWN_KEYS: &[&str] = &[
"anthropic_api_key", "anthropic_api_key",
"openai_api_key", "openai_api_key",
"openrouter_api_key",
"github_token", "github_token",
"relay_token", "relay_token",
]; ];

View file

@ -46,6 +46,7 @@
], ],
"resources": [ "resources": [
"../sidecar/dist/claude-runner.mjs", "../sidecar/dist/claude-runner.mjs",
"../sidecar/dist/aider-runner.mjs",
"../../btmsg", "../../btmsg",
"../../bttask" "../../bttask"
], ],

View file

@ -9,6 +9,7 @@
import { CLAUDE_PROVIDER } from './lib/providers/claude'; import { CLAUDE_PROVIDER } from './lib/providers/claude';
import { CODEX_PROVIDER } from './lib/providers/codex'; import { CODEX_PROVIDER } from './lib/providers/codex';
import { OLLAMA_PROVIDER } from './lib/providers/ollama'; import { OLLAMA_PROVIDER } from './lib/providers/ollama';
import { AIDER_PROVIDER } from './lib/providers/aider';
import { registerMemoryAdapter } from './lib/adapters/memory-adapter'; import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
import { MemoraAdapter } from './lib/adapters/memora-bridge'; import { MemoraAdapter } from './lib/adapters/memora-bridge';
import { import {
@ -102,6 +103,7 @@
registerProvider(CLAUDE_PROVIDER); registerProvider(CLAUDE_PROVIDER);
registerProvider(CODEX_PROVIDER); registerProvider(CODEX_PROVIDER);
registerProvider(OLLAMA_PROVIDER); registerProvider(OLLAMA_PROVIDER);
registerProvider(AIDER_PROVIDER);
const memora = new MemoraAdapter(); const memora = new MemoraAdapter();
registerMemoryAdapter(memora); registerMemoryAdapter(memora);
memora.checkAvailability(); memora.checkAvailability();

View file

@ -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<string, unknown>): 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<string, unknown>
: {};
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,
}];
}
}

View file

@ -6,6 +6,7 @@ import type { ProviderId } from '../providers/types';
import { adaptSDKMessage } from './claude-messages'; import { adaptSDKMessage } from './claude-messages';
import { adaptCodexMessage } from './codex-messages'; import { adaptCodexMessage } from './codex-messages';
import { adaptOllamaMessage } from './ollama-messages'; import { adaptOllamaMessage } from './ollama-messages';
import { adaptAiderMessage } from './aider-messages';
/** Function signature for a provider message adapter */ /** Function signature for a provider message adapter */
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[]; export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
@ -31,3 +32,4 @@ export function adaptMessage(providerId: ProviderId, raw: Record<string, unknown
registerMessageAdapter('claude', adaptSDKMessage); registerMessageAdapter('claude', adaptSDKMessage);
registerMessageAdapter('codex', adaptCodexMessage); registerMessageAdapter('codex', adaptCodexMessage);
registerMessageAdapter('ollama', adaptOllamaMessage); registerMessageAdapter('ollama', adaptOllamaMessage);
registerMessageAdapter('aider', adaptAiderMessage);

View file

@ -34,6 +34,7 @@ export async function knownSecretKeys(): Promise<string[]> {
export const SECRET_KEY_LABELS: Record<string, string> = { export const SECRET_KEY_LABELS: Record<string, string> = {
anthropic_api_key: 'Anthropic API Key', anthropic_api_key: 'Anthropic API Key',
openai_api_key: 'OpenAI API Key', openai_api_key: 'OpenAI API Key',
openrouter_api_key: 'OpenRouter API Key',
github_token: 'GitHub Token', github_token: 'GitHub Token',
relay_token: 'Relay Token', relay_token: 'Relay Token',
}; };

View file

@ -26,6 +26,7 @@
import type { AgentMessage } from '../../adapters/claude-messages'; import type { AgentMessage } from '../../adapters/claude-messages';
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte'; import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte'; import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import { getSecret } from '../../adapters/secrets-bridge';
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte'; import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
import { SessionId, ProjectId } from '../../types/ids'; import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte'; import AgentPane from '../Agent/AgentPane.svelte';
@ -58,15 +59,34 @@
return project.systemPrompt || undefined; return project.systemPrompt || undefined;
}); });
// Provider-specific API keys loaded from system keyring
let openrouterKey = $state<string | null>(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 // 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 // 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(() => { let agentEnv = $derived.by(() => {
if (!project.isAgent) return undefined; const env: Record<string, string> = {};
const env: Record<string, string> = { BTMSG_AGENT_ID: project.id }; if (project.isAgent) {
env.BTMSG_AGENT_ID = project.id;
if (project.agentRole === 'manager') { if (project.agentRole === 'manager') {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; 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 // Periodic context re-injection timer

View file

@ -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)' },
],
};

View file

@ -1,6 +1,6 @@
// Provider abstraction types — defines the interface for multi-provider agent support // 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 */ /** What a provider can do — UI gates features on these flags */
export interface ProviderCapabilities { export interface ProviderCapabilities {