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)
// 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<String>,
}
/// Per-provider sidecar process state.
struct ProviderProcess {
child: Child,
stdin_writer: Box<dyn Write + Send>,
ready: bool,
}
pub struct SidecarManager {
child: Arc<Mutex<Option<Child>>>,
stdin_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
ready: Arc<Mutex<bool>>,
/// Provider name → running sidecar process
providers: Arc<Mutex<HashMap<String, ProviderProcess>>>,
/// Session ID → provider name (for routing stop messages)
session_providers: Arc<Mutex<HashMap<String, String>>>,
sink: Arc<dyn EventSink>,
config: Mutex<SidecarConfig>,
}
@ -69,9 +80,8 @@ pub struct SidecarManager {
impl SidecarManager {
pub fn new(sink: Arc<dyn EventSink>, 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::<serde_json::Value>(&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<SidecarCommand, String> {
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;

View file

@ -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",

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] = &[
"anthropic_api_key",
"openai_api_key",
"openrouter_api_key",
"github_token",
"relay_token",
];

View file

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

View file

@ -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();

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 { 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<string, unknown>) => AgentMessage[];
@ -31,3 +32,4 @@ export function adaptMessage(providerId: ProviderId, raw: Record<string, unknown
registerMessageAdapter('claude', adaptSDKMessage);
registerMessageAdapter('codex', adaptCodexMessage);
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> = {
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',
};

View file

@ -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<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
// 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<string, string> = { BTMSG_AGENT_ID: project.id };
const env: Record<string, string> = {};
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

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
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 {