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:
parent
35963be686
commit
5b7ad30573
12 changed files with 549 additions and 84 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
261
v2/sidecar/aider-runner.ts
Normal 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' });
|
||||||
|
|
@ -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",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
94
v2/src/lib/adapters/aider-messages.ts
Normal file
94
v2/src/lib/adapters/aider-messages.ts
Normal 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,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
32
v2/src/lib/providers/aider.ts
Normal file
32
v2/src/lib/providers/aider.ts
Normal 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)' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue