Fix provider/model persistence and rewrite Aider runner for interactive mode

Rust groups.rs ProjectConfig was missing provider, model, and other optional
fields — serde silently dropped them on save, causing all projects to fall
back to claude/Opus on reload. Added all missing fields to both ProjectConfig
and GroupAgentConfig structs.

Rewrote aider-runner from one-shot --message mode to interactive stdin/stdout:
- Persistent aider process with multi-turn conversation support
- Pre-fetches btmsg inbox and bttask board before sending prompt to LLM
- Autonomous agent override prompt so LLM acts instead of asking for files
- Line-buffered output (no token-by-token fragments)
- Thinking block classification for DeepSeek R1
- Graceful /exit shutdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DexterFromLab 2026-03-12 16:01:46 +01:00
parent e8555625ff
commit 862ddfcbb8
2 changed files with 290 additions and 99 deletions

View file

@ -1,16 +1,31 @@
// Aider Runner — Node.js sidecar entry point for Aider coding agent // Aider Runner — Node.js sidecar entry point for Aider coding agent
// Spawned by Rust SidecarManager, communicates via stdio NDJSON // Spawned by Rust SidecarManager, communicates via stdio NDJSON
// Spawns `aider` CLI as subprocess in non-interactive mode // Runs aider in interactive mode — persistent process with stdin/stdout chat
// Pre-fetches btmsg/bttask context so the LLM has actionable data immediately.
import { stdin, stdout, stderr } from 'process'; import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
import { spawn, type ChildProcess } from 'child_process'; import { spawn, execSync, type ChildProcess } from 'child_process';
import { accessSync, constants } from 'fs'; import { accessSync, constants } from 'fs';
import { join } from 'path'; import { join } from 'path';
const rl = createInterface({ input: stdin }); const rl = createInterface({ input: stdin });
const sessions = new Map<string, { process: ChildProcess; controller: AbortController }>(); interface AiderSession {
process: ChildProcess;
controller: AbortController;
sessionId: string;
model: string;
lineBuffer: string; // partial line accumulator for streaming
turnBuffer: string; // full turn output
turnStartTime: number;
turns: number;
ready: boolean;
env: Record<string, string>;
cwd: string;
}
const sessions = new Map<string, AiderSession>();
function send(msg: Record<string, unknown>) { function send(msg: Record<string, unknown>) {
stdout.write(JSON.stringify(msg) + '\n'); stdout.write(JSON.stringify(msg) + '\n');
@ -63,22 +78,125 @@ async function handleMessage(msg: Record<string, unknown>) {
} }
} }
async function handleQuery(msg: QueryMessage) { // --- Context pre-fetching ---
const { sessionId, prompt, cwd, model, systemPrompt, extraEnv, providerConfig } = msg; // Execute btmsg/bttask CLIs to gather context BEFORE sending prompt to LLM.
// This way the LLM gets real data to act on instead of suggesting commands.
if (sessions.has(sessionId)) { function runCmd(cmd: string, env: Record<string, string>, cwd: string): string | null {
send({ type: 'error', sessionId, message: 'Session already running' }); try {
const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim();
log(`[prefetch] ${cmd}${result.length} chars`);
return result || null;
} catch (e: unknown) {
log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
function prefetchContext(env: Record<string, string>, cwd: string): string {
log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`);
const parts: string[] = [];
const inbox = runCmd('btmsg inbox', env, cwd);
if (inbox) {
parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``);
} else {
parts.push('## Your Inbox\nNo messages (or btmsg unavailable).');
}
const board = runCmd('bttask board', env, cwd);
if (board) {
parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``);
} else {
parts.push('## Task Board\nNo tasks (or bttask unavailable).');
}
return parts.join('\n\n');
}
// --- Prompt detection ---
// Aider with --no-pretty --no-fancy-input shows prompts like:
// > or aider> or repo-name>
const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/;
function looksLikePrompt(buffer: string): boolean {
// Check the last non-empty line
const lines = buffer.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const l = lines[i];
if (l.trim() === '') continue;
return PROMPT_RE.test(l);
}
return false;
}
// Lines to suppress from UI (aider startup noise)
const SUPPRESS_RE = [
/^Aider v\d/,
/^Main model:/,
/^Weak model:/,
/^Git repo:/,
/^Repo-map:/,
/^Use \/help/,
];
function shouldSuppress(line: string): boolean {
const t = line.trim();
return t === '' || SUPPRESS_RE.some(p => p.test(t));
}
// --- Output line classification ---
// Thinking blocks: ► THINKING ... ► ANSWER
let inThinking = false;
function classifyLine(line: string): 'thinking' | 'shell' | 'cost' | 'prompt' | 'text' {
const t = line.trim();
if (t === '► THINKING' || t === '► THINKING') { inThinking = true; return 'thinking'; }
if (t === '► ANSWER' || t === '► ANSWER') { inThinking = false; return 'thinking'; }
if (inThinking) return 'thinking';
if (t.startsWith('$ ') || t.startsWith('Running ')) return 'shell';
if (/^Tokens: .+Cost:/.test(t)) return 'cost';
if (PROMPT_RE.test(t)) return 'prompt';
return 'text';
}
// --- Main query handler ---
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd: cwdOpt, model, systemPrompt, extraEnv, providerConfig } = msg;
const cwd = cwdOpt || process.cwd();
// Build environment
const env: Record<string, string> = { ...process.env as Record<string, string> };
if (extraEnv) Object.assign(env, extraEnv);
if (providerConfig?.openrouterApiKey && typeof providerConfig.openrouterApiKey === 'string') {
env.OPENROUTER_API_KEY = providerConfig.openrouterApiKey;
}
const existing = sessions.get(sessionId);
// Follow-up prompt on existing session
if (existing && existing.process.exitCode === null) {
log(`Continuing session ${sessionId} with follow-up prompt`);
existing.turnBuffer = '';
existing.lineBuffer = '';
existing.turnStartTime = Date.now();
existing.turns++;
inThinking = false;
send({ type: 'agent_started', sessionId });
// Pre-fetch fresh context for follow-up turns too
const ctx = prefetchContext(existing.env, existing.cwd);
const fullPrompt = `${ctx}\n\nNow act on the above. Your current task:\n${prompt}`;
existing.process.stdin?.write(fullPrompt + '\n');
return; return;
} }
// Find aider binary // New session — spawn aider
const aiderPath = which('aider'); const aiderPath = which('aider');
if (!aiderPath) { if (!aiderPath) {
send({ send({ type: 'agent_error', sessionId, message: 'Aider not found. Install with: pipx install aider-chat' });
type: 'agent_error',
sessionId,
message: 'Aider not found. Install with: pipx install aider-chat',
});
return; return;
} }
@ -87,31 +205,18 @@ async function handleQuery(msg: QueryMessage) {
const controller = new AbortController(); const controller = new AbortController();
// Build aider command args
const args: string[] = [ const args: string[] = [
'--model', aiderModel, '--model', aiderModel,
'--message', prompt, '--yes-always',
'--yes-always', // Auto-accept all file changes '--no-pretty',
'--no-pretty', // Plain text output (no terminal formatting) '--no-fancy-input',
'--no-stream', // Complete response (easier to parse) '--no-stream', // Complete responses (no token fragments)
'--no-git', // Let the outer project handle git '--no-git',
'--no-auto-commits', // Don't auto-commit changes '--no-auto-commits',
'--no-check-model-accepts-settings', // Don't warn about model settings '--suggest-shell-commands',
'--no-check-model-accepts-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') { if (providerConfig?.editFormat && typeof providerConfig.editFormat === 'string') {
args.push('--edit-format', providerConfig.editFormat); args.push('--edit-format', providerConfig.editFormat);
} }
@ -119,104 +224,162 @@ async function handleQuery(msg: QueryMessage) {
args.push('--architect'); 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 }); send({ type: 'agent_started', sessionId });
// Emit init event
send({ send({
type: 'agent_event', type: 'agent_event',
sessionId, sessionId,
event: { event: { type: 'system', subtype: 'init', session_id: sessionId, model: aiderModel, cwd },
type: 'system',
subtype: 'init',
session_id: sessionId,
model: aiderModel,
cwd: cwd || process.cwd(),
},
}); });
// Spawn aider process
const child = spawn(aiderPath, args, { const child = spawn(aiderPath, args, {
cwd: cwd || process.cwd(), cwd,
env, env,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
signal: controller.signal, signal: controller.signal,
}); });
sessions.set(sessionId, { process: child, controller }); const session: AiderSession = {
process: child,
controller,
sessionId,
model: aiderModel,
lineBuffer: '',
turnBuffer: '',
turnStartTime: Date.now(),
turns: 0,
ready: false,
env,
cwd,
};
sessions.set(sessionId, session);
let stdoutBuffer = ''; // Pre-fetch btmsg/bttask context
let stderrBuffer = ''; const prefetched = prefetchContext(env, cwd);
// Build full initial prompt — our context FIRST, with explicit override
const promptParts: string[] = [];
promptParts.push(`IMPORTANT: You are an autonomous agent in a multi-agent system. Your PRIMARY job is to act on messages and tasks below, NOT to ask the user for files. You can run shell commands to accomplish tasks. If you need to read files, use shell commands like \`cat\`, \`find\`, \`ls\`. If you need to send messages, use \`btmsg send <agent-id> "message"\`. If you need to update tasks, use \`bttask status <task-id> done\`.`);
if (systemPrompt) promptParts.push(systemPrompt);
promptParts.push(prefetched);
promptParts.push(`---\n\nNow act on the above. Your current task:\n${prompt}`);
const fullPrompt = promptParts.join('\n\n');
// Startup buffer — wait for first prompt before sending
let startupBuffer = '';
// Stream stdout as text chunks
child.stdout?.on('data', (data: Buffer) => { child.stdout?.on('data', (data: Buffer) => {
const text = data.toString(); const text = data.toString();
stdoutBuffer += text;
// Emit each line as a text event // Phase 1: wait for aider startup to finish
const lines = text.split('\n'); if (!session.ready) {
for (const line of lines) { startupBuffer += text;
if (!line) continue; if (looksLikePrompt(startupBuffer)) {
session.ready = true;
session.turns = 1;
session.turnStartTime = Date.now();
inThinking = false;
log(`Aider ready, sending initial prompt (${fullPrompt.length} chars)`);
child.stdin?.write(fullPrompt + '\n');
}
return;
}
// Phase 2: accumulate output, emit complete lines
session.lineBuffer += text;
session.turnBuffer += text;
// Process complete lines only
const parts = session.lineBuffer.split('\n');
session.lineBuffer = parts.pop() || ''; // keep incomplete last part
for (const line of parts) {
if (shouldSuppress(line)) continue;
const cls = classifyLine(line);
switch (cls) {
case 'thinking':
// Emit thinking as a collapsed block
send({
type: 'agent_event',
sessionId,
event: { type: 'assistant', message: { role: 'assistant', content: line } },
});
break;
case 'shell':
send({ send({
type: 'agent_event', type: 'agent_event',
sessionId, sessionId,
event: { event: {
type: 'assistant', type: 'tool_call',
message: { role: 'assistant', content: line }, content: {
toolName: 'shell',
toolUseId: `shell-${Date.now()}`,
input: { command: line.replace(/^(Running |\$ )/, '') },
},
}, },
}); });
} break;
case 'cost':
// Parse cost and include in result
break;
case 'prompt':
// Prompt marker — turn is complete
break;
case 'text':
send({
type: 'agent_event',
sessionId,
event: { type: 'assistant', message: { role: 'assistant', content: line } },
}); });
break;
// 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 // Check if turn is complete (aider showing prompt again)
child.on('close', (code: number | null, signal: string | null) => { if (looksLikePrompt(session.turnBuffer)) {
sessions.delete(sessionId); const duration = Date.now() - session.turnStartTime;
const costMatch = session.turnBuffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
const costUsd = costMatch ? parseFloat(costMatch[2]) : 0;
// Emit final result as a single text block
if (stdoutBuffer.trim()) {
send({ send({
type: 'agent_event', type: 'agent_event',
sessionId, sessionId,
event: { event: {
type: 'result', type: 'result',
subtype: 'result', subtype: 'result',
result: stdoutBuffer.trim(), result: '',
cost_usd: 0, cost_usd: costUsd,
duration_ms: 0, duration_ms: duration,
num_turns: 1, num_turns: session.turns,
is_error: code !== 0 && code !== null, is_error: false,
session_id: sessionId, session_id: sessionId,
}, },
}); });
}
send({ type: 'agent_stopped', sessionId, exitCode: 0, signal: null });
session.turnBuffer = '';
session.lineBuffer = '';
inThinking = false;
}
});
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n')) {
if (line.trim()) log(`[stderr] ${line}`);
}
});
child.on('close', (code: number | null, signal: string | null) => {
sessions.delete(sessionId);
if (controller.signal.aborted) { if (controller.signal.aborted) {
send({ type: 'agent_stopped', sessionId, exitCode: null, signal: 'SIGTERM' }); send({ type: 'agent_stopped', sessionId, exitCode: null, signal: 'SIGTERM' });
} else if (code !== 0 && code !== null) { } else if (code !== 0 && code !== null) {
const errorDetail = stderrBuffer.trim() || `Aider exited with code ${code}`; send({ type: 'agent_error', sessionId, message: `Aider exited with code ${code}` });
send({ type: 'agent_error', sessionId, message: errorDetail });
} else { } else {
send({ type: 'agent_stopped', sessionId, exitCode: code, signal }); send({ type: 'agent_stopped', sessionId, exitCode: code, signal });
} }
@ -238,8 +401,12 @@ function handleStop(msg: StopMessage) {
} }
log(`Stopping Aider session ${sessionId}`); log(`Stopping Aider session ${sessionId}`);
session.process.stdin?.write('/exit\n');
const killTimer = setTimeout(() => {
session.controller.abort(); session.controller.abort();
session.process.kill('SIGTERM'); session.process.kill('SIGTERM');
}, 3000);
session.process.once('close', () => clearTimeout(killTimer));
} }
function which(name: string): string | null { function which(name: string): string | null {

View file

@ -25,6 +25,24 @@ pub struct ProjectConfig {
pub cwd: String, pub cwd: String,
pub profile: String, pub profile: String,
pub enabled: bool, pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_worktrees: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_budget_scale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stall_threshold_min: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_agent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -34,6 +52,8 @@ pub struct GroupAgentConfig {
pub name: String, pub name: String,
pub role: String, pub role: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>, pub cwd: Option<String>,
@ -42,6 +62,10 @@ pub struct GroupAgentConfig {
pub enabled: bool, pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub wake_interval_min: Option<u32>, pub wake_interval_min: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wake_strategy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wake_threshold: Option<f64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]