feat: Agent Orchestrator — multi-project agent dashboard
Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents. Includes Claude, Aider, Codex, and Ollama provider support, multi-agent communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search, Landlock sandboxing, and 507 vitest + 110 cargo tests.
This commit is contained in:
commit
3672e92b7e
272 changed files with 68600 additions and 0 deletions
209
sidecar/agent-runner-deno.ts
Normal file
209
sidecar/agent-runner-deno.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// Agent Runner — Deno sidecar entry point
|
||||
// Drop-in replacement for agent-runner.ts using Deno APIs
|
||||
// Uses @anthropic-ai/claude-agent-sdk via npm: specifier
|
||||
// Run: deno run --allow-run --allow-env --allow-read --allow-write --allow-net agent-runner-deno.ts
|
||||
|
||||
import { TextLineStream } from "https://deno.land/std@0.224.0/streams/text_line_stream.ts";
|
||||
import { query } from "npm:@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Active sessions with abort controllers
|
||||
const sessions = new Map<string, AbortController>();
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
Deno.stdout.writeSync(encoder.encode(JSON.stringify(msg) + "\n"));
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
Deno.stderr.writeSync(encoder.encode(`[sidecar] ${message}\n`));
|
||||
}
|
||||
|
||||
interface QueryMessage {
|
||||
type: "query";
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
resumeSessionId?: string;
|
||||
permissionMode?: string;
|
||||
settingSources?: string[];
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
claudeConfigDir?: string;
|
||||
additionalDirectories?: string[];
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
type: "stop";
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
function handleMessage(msg: Record<string, unknown>) {
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
break;
|
||||
case "query":
|
||||
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, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: "error", sessionId, message: "Session already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Starting agent session ${sessionId} via SDK`);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// Strip CLAUDE* env vars to prevent nesting detection
|
||||
const cleanEnv: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(Deno.env.toObject())) {
|
||||
if (!key.startsWith("CLAUDE")) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
// Override CLAUDE_CONFIG_DIR for multi-account support
|
||||
if (claudeConfigDir) {
|
||||
cleanEnv["CLAUDE_CONFIG_DIR"] = claudeConfigDir;
|
||||
}
|
||||
|
||||
if (!claudePath) {
|
||||
send({ type: "agent_error", sessionId, message: "Claude CLI not found. Install Claude Code first." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
abortController: controller,
|
||||
cwd: cwd || Deno.cwd(),
|
||||
env: cleanEnv,
|
||||
maxTurns: maxTurns ?? undefined,
|
||||
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
||||
resume: resumeSessionId ?? undefined,
|
||||
allowedTools: [
|
||||
"Bash", "Read", "Write", "Edit", "Glob", "Grep",
|
||||
"WebSearch", "WebFetch", "TodoWrite", "NotebookEdit",
|
||||
],
|
||||
permissionMode: (permissionMode ?? "bypassPermissions") as "bypassPermissions" | "default",
|
||||
allowDangerouslySkipPermissions: (permissionMode ?? "bypassPermissions") === "bypassPermissions",
|
||||
settingSources: settingSources ?? ["user", "project"],
|
||||
systemPrompt: systemPrompt
|
||||
? systemPrompt
|
||||
: { type: "preset" as const, preset: "claude_code" as const },
|
||||
model: model ?? undefined,
|
||||
additionalDirectories: additionalDirectories ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
sessions.set(sessionId, controller);
|
||||
send({ type: "agent_started", sessionId });
|
||||
|
||||
for await (const message of q) {
|
||||
const sdkMsg = message as Record<string, unknown>;
|
||||
send({
|
||||
type: "agent_event",
|
||||
sessionId,
|
||||
event: sdkMsg,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: "agent_stopped",
|
||||
sessionId,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
sessions.delete(sessionId);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (errMsg.includes("aborted") || errMsg.includes("AbortError")) {
|
||||
log(`Agent session ${sessionId} aborted`);
|
||||
send({
|
||||
type: "agent_stopped",
|
||||
sessionId,
|
||||
exitCode: null,
|
||||
signal: "SIGTERM",
|
||||
});
|
||||
} else {
|
||||
log(`Agent session ${sessionId} error: ${errMsg}`);
|
||||
send({
|
||||
type: "agent_error",
|
||||
sessionId,
|
||||
message: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop(msg: StopMessage) {
|
||||
const { sessionId } = msg;
|
||||
const controller = sessions.get(sessionId);
|
||||
if (!controller) {
|
||||
send({ type: "error", sessionId, message: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Stopping agent session ${sessionId}`);
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
function findClaudeCli(): string | undefined {
|
||||
const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? "";
|
||||
const candidates = [
|
||||
`${home}/.local/bin/claude`,
|
||||
`${home}/.claude/local/claude`,
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { Deno.statSync(p); return p; } catch { /* not found */ }
|
||||
}
|
||||
try {
|
||||
const proc = new Deno.Command("which", { args: ["claude"], stdout: "piped", stderr: "null" });
|
||||
const out = new TextDecoder().decode(proc.outputSync().stdout).trim();
|
||||
if (out) return out.split("\n")[0];
|
||||
} catch { /* not found */ }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const claudePath = findClaudeCli();
|
||||
if (claudePath) {
|
||||
log(`Found Claude CLI at ${claudePath}`);
|
||||
} else {
|
||||
log("WARNING: Claude CLI not found — agent sessions will fail");
|
||||
}
|
||||
|
||||
// Main: read NDJSON from stdin
|
||||
log("Sidecar started (Deno)");
|
||||
send({ type: "ready" });
|
||||
|
||||
const lines = Deno.stdin.readable
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new TextLineStream());
|
||||
|
||||
for await (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
handleMessage(msg);
|
||||
} catch {
|
||||
log(`Invalid JSON: ${line}`);
|
||||
}
|
||||
}
|
||||
731
sidecar/aider-parser.test.ts
Normal file
731
sidecar/aider-parser.test.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
looksLikePrompt,
|
||||
shouldSuppress,
|
||||
parseTurnOutput,
|
||||
extractSessionCost,
|
||||
prefetchContext,
|
||||
execShell,
|
||||
PROMPT_RE,
|
||||
SUPPRESS_RE,
|
||||
SHELL_CMD_RE,
|
||||
} from './aider-parser';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — realistic Aider output samples used as format-drift canaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIXTURE_STARTUP = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format',
|
||||
'Weak model: openrouter/anthropic/claude-haiku-4',
|
||||
'Git repo: none',
|
||||
'Repo-map: disabled',
|
||||
'Use /help to see in-chat commands, run with --help to see cmd line args',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_SIMPLE_ANSWER = [
|
||||
'► THINKING',
|
||||
'The user wants me to check the task board.',
|
||||
'► ANSWER',
|
||||
'I will check the task board for you.',
|
||||
'bttask board',
|
||||
'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_CODE_BLOCK_SHELL = [
|
||||
'Here is the command to send a message:',
|
||||
'```bash',
|
||||
'$ btmsg send manager-001 "Task complete"',
|
||||
'```',
|
||||
'Tokens: 800 sent, 40 received. Cost: $0.0010 message, $0.0021 session',
|
||||
'aider> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_MIXED_BLOCKS = [
|
||||
'► THINKING',
|
||||
'I need to check inbox then update the task.',
|
||||
'► ANSWER',
|
||||
'Let me check your inbox first.',
|
||||
'btmsg inbox',
|
||||
'Now updating the task status.',
|
||||
'```bash',
|
||||
'bttask status task-42 done',
|
||||
'```',
|
||||
'All done!',
|
||||
'Tokens: 2000 sent, 120 received. Cost: $0.0040 message, $0.0080 session',
|
||||
'my-repo> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_APPLIED_EDIT_NOISE = [
|
||||
'I will edit the file.',
|
||||
'Applied edit to src/main.ts',
|
||||
'Fix any errors below',
|
||||
'Running: flake8 src/main.ts',
|
||||
'The edit is complete.',
|
||||
'Tokens: 500 sent, 30 received. Cost: $0.0005 message, $0.0010 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_DOLLAR_PREFIX_SHELL = [
|
||||
'Run this command:',
|
||||
'$ git status',
|
||||
'After that, commit your changes.',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_RUNNING_PREFIX_SHELL = [
|
||||
'Running git log --oneline -5',
|
||||
'Tokens: 300 sent, 20 received. Cost: $0.0003 message, $0.0006 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_NO_COST = [
|
||||
'► THINKING',
|
||||
'Checking the situation.',
|
||||
'► ANSWER',
|
||||
'Nothing to do right now.',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// looksLikePrompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('looksLikePrompt', () => {
|
||||
it('detects bare "> " prompt', () => {
|
||||
expect(looksLikePrompt('> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects "aider> " prompt', () => {
|
||||
expect(looksLikePrompt('aider> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects repo-named prompt like "my-repo> "', () => {
|
||||
expect(looksLikePrompt('my-repo> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt after multi-line output', () => {
|
||||
const buffer = 'Some output line\nAnother line\naider> ';
|
||||
expect(looksLikePrompt(buffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt when trailing blank lines follow', () => {
|
||||
const buffer = 'aider> \n\n';
|
||||
expect(looksLikePrompt(buffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a full sentence ending in > but not a prompt', () => {
|
||||
expect(looksLikePrompt('This is greater than> something')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(looksLikePrompt('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for string with only blank lines', () => {
|
||||
expect(looksLikePrompt('\n\n\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain text with no prompt', () => {
|
||||
expect(looksLikePrompt('I have analyzed the task and will now proceed.')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles dotted repo names like "my.project> "', () => {
|
||||
expect(looksLikePrompt('my.project> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt in full startup fixture', () => {
|
||||
expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldSuppress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldSuppress', () => {
|
||||
it('suppresses empty string', () => {
|
||||
expect(shouldSuppress('')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses whitespace-only string', () => {
|
||||
expect(shouldSuppress(' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses Aider version line', () => {
|
||||
expect(shouldSuppress('Aider v0.72.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Main model:" line', () => {
|
||||
expect(shouldSuppress('Main model: claude-sonnet-4 with diff format')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Weak model:" line', () => {
|
||||
expect(shouldSuppress('Weak model: claude-haiku-4')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Git repo:" line', () => {
|
||||
expect(shouldSuppress('Git repo: none')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Repo-map:" line', () => {
|
||||
expect(shouldSuppress('Repo-map: disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Use /help" line', () => {
|
||||
expect(shouldSuppress('Use /help to see in-chat commands, run with --help to see cmd line args')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress regular answer text', () => {
|
||||
expect(shouldSuppress('I will check the task board for you.')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress a shell command line', () => {
|
||||
expect(shouldSuppress('bttask board')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress a cost line', () => {
|
||||
expect(shouldSuppress('Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session')).toBe(false);
|
||||
});
|
||||
|
||||
it('strips leading/trailing whitespace before testing', () => {
|
||||
expect(shouldSuppress(' Aider v0.70.0 ')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — thinking blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — thinking blocks', () => {
|
||||
it('extracts a thinking block using ► THINKING / ► ANSWER markers', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('check the task board');
|
||||
});
|
||||
|
||||
it('extracts thinking with ▶ arrow variant', () => {
|
||||
const buffer = '▶ THINKING\nSome reasoning here.\n▶ ANSWER\nHere is the answer.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
expect(blocks[0].content).toContain('Some reasoning here.');
|
||||
});
|
||||
|
||||
it('extracts thinking with > arrow variant', () => {
|
||||
const buffer = '> THINKING\nDeep thoughts.\n> ANSWER\nFinal answer.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('Deep thoughts.');
|
||||
});
|
||||
|
||||
it('handles missing ANSWER marker — flushes thinking at end', () => {
|
||||
const buffer = '► THINKING\nIncomplete thinking block.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('Incomplete thinking block.');
|
||||
});
|
||||
|
||||
it('produces no thinking block when no THINKING marker present', () => {
|
||||
const buffer = 'Just plain text.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'thinking')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — text blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — text blocks', () => {
|
||||
it('extracts text after ANSWER marker', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
expect(texts.length).toBeGreaterThan(0);
|
||||
expect(texts[0].content).toContain('I will check the task board');
|
||||
});
|
||||
|
||||
it('trims trailing whitespace from flushed text block', () => {
|
||||
// Note: parseTurnOutput checks PROMPT_RE against the trimmed line.
|
||||
// ">" (trimmed from "> ") does not match PROMPT_RE (which requires trailing space),
|
||||
// so the final flush trims the accumulated content via .trim().
|
||||
const buffer = 'Some text with trailing space. ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
expect(texts[0].content).toBe('Some text with trailing space.');
|
||||
});
|
||||
|
||||
it('does not produce a text block from suppressed startup lines alone', () => {
|
||||
// All Aider startup lines are suppressed by SUPPRESS_RE.
|
||||
// The ">" (trimmed from "> ") does NOT match PROMPT_RE (requires trailing space),
|
||||
// but it is also not a recognized command or thinking marker, so it lands in answerLines.
|
||||
// The final text block is trimmed — ">".trim() = ">", non-empty, so one text block with ">" appears.
|
||||
// What we care about is that suppressed startup noise does NOT appear in text.
|
||||
const buffer = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: some-model',
|
||||
].join('\n');
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('suppresses Applied edit / flake8 / Running: lines in answer text', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
const combined = texts.map(b => b.content).join(' ');
|
||||
expect(combined).not.toContain('Applied edit');
|
||||
expect(combined).not.toContain('Fix any errors');
|
||||
expect(combined).not.toContain('Running:');
|
||||
});
|
||||
|
||||
it('preserves non-suppressed text around noise lines', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
const combined = texts.map(b => b.content).join(' ');
|
||||
expect(combined).toContain('I will edit the file');
|
||||
expect(combined).toContain('The edit is complete');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — shell blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — shell blocks from code blocks', () => {
|
||||
it('extracts btmsg command from ```bash block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_CODE_BLOCK_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('btmsg send manager-001 "Task complete"');
|
||||
});
|
||||
|
||||
it('strips leading "$ " from commands inside code block', () => {
|
||||
const buffer = '```bash\n$ btmsg inbox\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells[0].content).toBe('btmsg inbox');
|
||||
});
|
||||
|
||||
it('extracts commands from ```shell block', () => {
|
||||
const buffer = '```shell\nbttask board\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1);
|
||||
expect(blocks.find(b => b.type === 'shell')!.content).toBe('bttask board');
|
||||
});
|
||||
|
||||
it('extracts commands from plain ``` block (no language tag)', () => {
|
||||
const buffer = '```\nbtmsg inbox\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not extract non-shell-command lines from code blocks', () => {
|
||||
const buffer = '```bash\nsome arbitrary text without a known prefix\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not extract commands from ```python blocks', () => {
|
||||
const buffer = '```python\nbtmsg send something "hello"\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
// Python blocks should not be treated as shell commands
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnOutput — shell blocks from inline prefixes', () => {
|
||||
it('detects "$ " prefix shell command', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('git status');
|
||||
});
|
||||
|
||||
it('detects "Running " prefix shell command', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_RUNNING_PREFIX_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('git log --oneline -5');
|
||||
});
|
||||
|
||||
it('detects bare btmsg/bttask commands in ANSWER section', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells.some(s => s.content === 'bttask board')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not extract bare commands from THINKING section', () => {
|
||||
const buffer = '► THINKING\nbtmsg inbox\n► ANSWER\nDone.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
// btmsg inbox in thinking section should be accumulated as thinking, not shell
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flushes preceding text block before a shell block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL);
|
||||
const textIdx = blocks.findIndex(b => b.type === 'text');
|
||||
const shellIdx = blocks.findIndex(b => b.type === 'shell');
|
||||
expect(textIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(shellIdx).toBeGreaterThan(textIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — cost blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — cost blocks', () => {
|
||||
it('extracts cost line as a cost block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const costs = blocks.filter(b => b.type === 'cost');
|
||||
expect(costs).toHaveLength(1);
|
||||
expect(costs[0].content).toContain('Cost:');
|
||||
});
|
||||
|
||||
it('preserves the full cost line as content', () => {
|
||||
const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session';
|
||||
const buffer = `Some text.\n${costLine}\n> `;
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const cost = blocks.find(b => b.type === 'cost');
|
||||
expect(cost?.content).toBe(costLine);
|
||||
});
|
||||
|
||||
it('produces no cost block when no cost line present', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_NO_COST);
|
||||
expect(blocks.filter(b => b.type === 'cost')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — mixed turn (thinking + text + shell + cost)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — mixed blocks', () => {
|
||||
it('produces all four block types from a mixed turn', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
const types = blocks.map(b => b.type);
|
||||
expect(types).toContain('thinking');
|
||||
expect(types).toContain('text');
|
||||
expect(types).toContain('shell');
|
||||
expect(types).toContain('cost');
|
||||
});
|
||||
|
||||
it('preserves block order: thinking → text → shell → text → cost', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
// At least one shell block present
|
||||
const shellIdx = blocks.findIndex(b => b.type === 'shell');
|
||||
expect(shellIdx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('extracts both btmsg and bttask shell commands from mixed turn', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
const shells = blocks.filter(b => b.type === 'shell').map(b => b.content);
|
||||
expect(shells).toContain('btmsg inbox');
|
||||
expect(shells).toContain('bttask status task-42 done');
|
||||
});
|
||||
|
||||
it('returns empty array for empty buffer', () => {
|
||||
expect(parseTurnOutput('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for buffer with only suppressed lines', () => {
|
||||
// All Aider startup noise is covered by SUPPRESS_RE.
|
||||
// A buffer of only suppressed lines produces no output blocks.
|
||||
const buffer = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: claude-sonnet-4',
|
||||
].join('\n');
|
||||
expect(parseTurnOutput(buffer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractSessionCost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractSessionCost', () => {
|
||||
it('extracts session cost from a cost line', () => {
|
||||
const buffer = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session\n> ';
|
||||
expect(extractSessionCost(buffer)).toBeCloseTo(0.0045);
|
||||
});
|
||||
|
||||
it('returns 0 when no cost line present', () => {
|
||||
expect(extractSessionCost('Some answer without cost.\n> ')).toBe(0);
|
||||
});
|
||||
|
||||
it('correctly picks session cost (second dollar amount), not message cost (first)', () => {
|
||||
const buffer = 'Cost: $0.0100 message, $0.0250 session';
|
||||
expect(extractSessionCost(buffer)).toBeCloseTo(0.0250);
|
||||
});
|
||||
|
||||
it('handles zero cost values', () => {
|
||||
expect(extractSessionCost('Cost: $0.0000 message, $0.0000 session')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prefetchContext — mocked child_process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('prefetchContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns inbox and board sections when both CLIs succeed', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('Message from manager-001: fix bug' as never)
|
||||
.mockReturnValueOnce('task-1 | In Progress | Fix login bug' as never);
|
||||
|
||||
const result = prefetchContext({ BTMSG_AGENT_ID: 'agent-001' }, '/tmp');
|
||||
|
||||
expect(result).toContain('## Your Inbox');
|
||||
expect(result).toContain('Message from manager-001');
|
||||
expect(result).toContain('## Task Board');
|
||||
expect(result).toContain('task-1');
|
||||
});
|
||||
|
||||
it('falls back to "No messages" when btmsg unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); })
|
||||
.mockReturnValueOnce('task-1 | todo' as never);
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('No messages (or btmsg unavailable).');
|
||||
expect(result).toContain('## Task Board');
|
||||
});
|
||||
|
||||
it('falls back to "No tasks" when bttask unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('inbox message' as never)
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); });
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('## Your Inbox');
|
||||
expect(result).toContain('No tasks (or bttask unavailable).');
|
||||
});
|
||||
|
||||
it('falls back for both when both CLIs unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync.mockImplementation(() => { throw new Error('not found'); });
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('No messages (or btmsg unavailable).');
|
||||
expect(result).toContain('No tasks (or bttask unavailable).');
|
||||
});
|
||||
|
||||
it('wraps inbox content in fenced code block', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('inbox line 1\ninbox line 2' as never)
|
||||
.mockReturnValueOnce('' as never);
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toMatch(/```\ninbox line 1\ninbox line 2\n```/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// execShell — mocked child_process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('execShell', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns trimmed stdout and exitCode 0 on success', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockReturnValue('hello world\n' as never);
|
||||
|
||||
const result = execShell('echo hello world', {}, '/tmp');
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns stderr content and non-zero exitCode on failure', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = Object.assign(new Error('Command failed'), {
|
||||
stderr: 'No such file or directory',
|
||||
status: 127,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const result = execShell('missing-cmd', {}, '/tmp');
|
||||
|
||||
expect(result.exitCode).toBe(127);
|
||||
expect(result.stdout).toContain('No such file or directory');
|
||||
});
|
||||
|
||||
it('falls back to stdout field on error if stderr is empty', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = Object.assign(new Error('fail'), {
|
||||
stdout: 'partial output',
|
||||
stderr: '',
|
||||
status: 1,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const result = execShell('cmd', {}, '/tmp');
|
||||
|
||||
expect(result.stdout).toBe('partial output');
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-drift canary — realistic Aider output samples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('format-drift canary', () => {
|
||||
it('correctly parses a full realistic turn with thinking, commands, and cost', () => {
|
||||
// Represents what aider actually outputs in practice with --no-stream --no-pretty
|
||||
const realisticOutput = [
|
||||
'► THINKING',
|
||||
'The user needs me to check the inbox and act on any pending tasks.',
|
||||
'I should run btmsg inbox to see messages, then bttask board to see tasks.',
|
||||
'► ANSWER',
|
||||
'I will check your inbox and task board now.',
|
||||
'```bash',
|
||||
'$ btmsg inbox',
|
||||
'```',
|
||||
'```bash',
|
||||
'$ bttask board',
|
||||
'```',
|
||||
'Based on the results, I will proceed.',
|
||||
'Tokens: 3500 sent, 250 received. Cost: $0.0070 message, $0.0140 session',
|
||||
'aider> ',
|
||||
].join('\n');
|
||||
|
||||
const blocks = parseTurnOutput(realisticOutput);
|
||||
const types = blocks.map(b => b.type);
|
||||
|
||||
expect(types).toContain('thinking');
|
||||
expect(types).toContain('text');
|
||||
expect(types).toContain('shell');
|
||||
expect(types).toContain('cost');
|
||||
|
||||
const shells = blocks.filter(b => b.type === 'shell').map(b => b.content);
|
||||
expect(shells).toContain('btmsg inbox');
|
||||
expect(shells).toContain('bttask board');
|
||||
|
||||
expect(extractSessionCost(realisticOutput)).toBeCloseTo(0.0140);
|
||||
});
|
||||
|
||||
it('startup fixture: looksLikePrompt matches after typical Aider startup output', () => {
|
||||
expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true);
|
||||
});
|
||||
|
||||
it('startup fixture: all startup lines are suppressed by shouldSuppress', () => {
|
||||
const startupLines = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format',
|
||||
'Weak model: openrouter/anthropic/claude-haiku-4',
|
||||
'Git repo: none',
|
||||
'Repo-map: disabled',
|
||||
'Use /help to see in-chat commands, run with --help to see cmd line args',
|
||||
];
|
||||
for (const line of startupLines) {
|
||||
expect(shouldSuppress(line), `Expected shouldSuppress("${line}") to be true`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('PROMPT_RE matches all expected prompt forms', () => {
|
||||
const validPrompts = ['> ', 'aider> ', 'my-repo> ', 'project.name> ', 'repo_123> '];
|
||||
for (const p of validPrompts) {
|
||||
expect(PROMPT_RE.test(p), `Expected PROMPT_RE to match "${p}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('PROMPT_RE rejects non-prompt forms', () => {
|
||||
const notPrompts = ['> something', 'text> more text ', '>text', ''];
|
||||
for (const p of notPrompts) {
|
||||
expect(PROMPT_RE.test(p), `Expected PROMPT_RE not to match "${p}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('SHELL_CMD_RE matches all documented command prefixes', () => {
|
||||
const cmds = [
|
||||
'btmsg send agent-001 "hello"',
|
||||
'bttask status task-42 done',
|
||||
'cat /etc/hosts',
|
||||
'ls -la',
|
||||
'find . -name "*.ts"',
|
||||
'grep -r "TODO" src/',
|
||||
'mkdir -p /tmp/test',
|
||||
'cd /home/user',
|
||||
'cp file.ts file2.ts',
|
||||
'mv old.ts new.ts',
|
||||
'rm -rf /tmp/test',
|
||||
'pip install requests',
|
||||
'npm install',
|
||||
'git status',
|
||||
'curl https://example.com',
|
||||
'wget https://example.com/file',
|
||||
'python script.py',
|
||||
'node index.js',
|
||||
'bash run.sh',
|
||||
'sh script.sh',
|
||||
];
|
||||
for (const cmd of cmds) {
|
||||
expect(SHELL_CMD_RE.test(cmd), `Expected SHELL_CMD_RE to match "${cmd}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('parseTurnOutput produces no shell blocks for non-shell code blocks (e.g. markdown python)', () => {
|
||||
const buffer = [
|
||||
'Here is example Python code:',
|
||||
'```python',
|
||||
'import os',
|
||||
'print(os.getcwd())',
|
||||
'```',
|
||||
'> ',
|
||||
].join('\n');
|
||||
const shells = parseTurnOutput(buffer).filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cost regex format has not changed — still "Cost: $X.XX message, $Y.YY session"', () => {
|
||||
const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session';
|
||||
expect(extractSessionCost(costLine)).toBeCloseTo(0.0045);
|
||||
// Verify the message cost is different from session cost (they're two separate values)
|
||||
const msgMatch = costLine.match(/Cost: \$([0-9.]+) message/);
|
||||
expect(msgMatch).not.toBeNull();
|
||||
expect(parseFloat(msgMatch![1])).toBeCloseTo(0.0023);
|
||||
});
|
||||
});
|
||||
243
sidecar/aider-parser.ts
Normal file
243
sidecar/aider-parser.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// aider-parser.ts — Pure parsing functions extracted from aider-runner.ts
|
||||
// Exported for unit testing. aider-runner.ts imports from here.
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TurnBlock {
|
||||
type: 'thinking' | 'text' | 'shell' | 'cost';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
// Prompt detection: Aider with --no-pretty --no-fancy-input shows prompts like:
|
||||
// > or aider> or repo-name>
|
||||
export const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/;
|
||||
|
||||
// Lines to suppress from UI (aider startup noise)
|
||||
export const SUPPRESS_RE = [
|
||||
/^Aider v\d/,
|
||||
/^Main model:/,
|
||||
/^Weak model:/,
|
||||
/^Git repo:/,
|
||||
/^Repo-map:/,
|
||||
/^Use \/help/,
|
||||
];
|
||||
|
||||
// Known shell command patterns — commands from btmsg/bttask/common tools
|
||||
export const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/;
|
||||
|
||||
// --- Pure parsing functions ---
|
||||
|
||||
/**
|
||||
* Detects whether the last non-empty line of a buffer looks like an Aider prompt.
|
||||
* Aider with --no-pretty --no-fancy-input shows prompts like: `> `, `aider> `, `repo-name> `
|
||||
*/
|
||||
export function looksLikePrompt(buffer: string): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for lines that should be suppressed from the UI output.
|
||||
* Covers Aider startup noise and empty lines.
|
||||
*/
|
||||
export function shouldSuppress(line: string): boolean {
|
||||
const t = line.trim();
|
||||
return t === '' || SUPPRESS_RE.some(p => p.test(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses complete Aider turn output into structured blocks.
|
||||
* Handles thinking sections, text, shell commands extracted from code blocks
|
||||
* or inline, cost lines, and suppresses startup noise.
|
||||
*/
|
||||
export function parseTurnOutput(buffer: string): TurnBlock[] {
|
||||
const blocks: TurnBlock[] = [];
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
let thinkingLines: string[] = [];
|
||||
let answerLines: string[] = [];
|
||||
let inThinking = false;
|
||||
let inAnswer = false;
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLang = '';
|
||||
let codeBlockLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
|
||||
// Skip suppressed lines
|
||||
if (shouldSuppress(line) && !inCodeBlock) continue;
|
||||
|
||||
// Prompt markers — skip
|
||||
if (PROMPT_RE.test(t)) continue;
|
||||
|
||||
// Thinking block markers (handle various unicode arrows and spacing)
|
||||
if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) {
|
||||
inThinking = true;
|
||||
inAnswer = false;
|
||||
continue;
|
||||
}
|
||||
if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) {
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
thinkingLines = [];
|
||||
}
|
||||
inThinking = false;
|
||||
inAnswer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code block detection (```bash, ```shell, ```)
|
||||
if (t.startsWith('```') && !inCodeBlock) {
|
||||
inCodeBlock = true;
|
||||
codeBlockLang = t.slice(3).trim().toLowerCase();
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (t === '```' && inCodeBlock) {
|
||||
inCodeBlock = false;
|
||||
// If this was a bash/shell code block, extract commands
|
||||
if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) {
|
||||
for (const cmdLine of codeBlockLines) {
|
||||
const cmd = cmdLine.trim().replace(/^\$ /, '');
|
||||
if (cmd && SHELL_CMD_RE.test(cmd)) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: cmd });
|
||||
}
|
||||
}
|
||||
}
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cost line
|
||||
if (/^Tokens: .+Cost:/.test(t)) {
|
||||
blocks.push({ type: 'cost', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shell command ($ prefix or Running prefix)
|
||||
if (t.startsWith('$ ') || t.startsWith('Running ')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bare btmsg/bttask commands in answer text
|
||||
if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aider's "Applied edit" / flake8 output — suppress from answer text
|
||||
if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue;
|
||||
|
||||
// Accumulate into thinking or answer
|
||||
if (inThinking) {
|
||||
thinkingLines.push(line);
|
||||
} else {
|
||||
answerLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
}
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n').trim() });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session cost from a raw turn buffer.
|
||||
* Returns 0 when no cost line is present.
|
||||
*/
|
||||
export function extractSessionCost(buffer: string): number {
|
||||
const match = buffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
|
||||
return match ? parseFloat(match[2]) : 0;
|
||||
}
|
||||
|
||||
// --- I/O helpers (require real child_process; mock in tests) ---
|
||||
|
||||
function log(message: string) {
|
||||
process.stderr.write(`[aider-parser] ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a CLI command and returns its trimmed stdout, or null on failure/empty.
|
||||
*/
|
||||
export function runCmd(cmd: string, env: Record<string, string>, cwd: string): string | null {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetches btmsg inbox and bttask board context.
|
||||
* Returns formatted markdown with both sections.
|
||||
*/
|
||||
export 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command and returns stdout + exit code.
|
||||
* On failure, returns stderr/error message with a non-zero exit code.
|
||||
*/
|
||||
export function execShell(cmd: string, env: Record<string, string>, cwd: string): { stdout: string; exitCode: number } {
|
||||
try {
|
||||
const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return { stdout: result.trim(), exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 };
|
||||
}
|
||||
}
|
||||
407
sidecar/aider-runner.ts
Normal file
407
sidecar/aider-runner.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// Aider Runner — Node.js sidecar entry point for Aider coding agent
|
||||
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
|
||||
// Runs aider in interactive mode — persistent process with stdin/stdout chat
|
||||
// Pre-fetches btmsg/bttask context so the LLM has actionable data immediately.
|
||||
//
|
||||
// Parsing logic lives in aider-parser.ts (exported for unit testing).
|
||||
|
||||
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';
|
||||
import {
|
||||
type TurnBlock,
|
||||
looksLikePrompt,
|
||||
parseTurnOutput,
|
||||
prefetchContext,
|
||||
execShell,
|
||||
extractSessionCost,
|
||||
PROMPT_RE,
|
||||
} from './aider-parser.js';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
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;
|
||||
autonomousMode: 'restricted' | 'autonomous';
|
||||
}
|
||||
|
||||
const sessions = new Map<string, AiderSession>();
|
||||
|
||||
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}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing, I/O helpers, and constants are imported from aider-parser.ts
|
||||
|
||||
// --- 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 autonomousMode = (providerConfig?.autonomousMode as string) === 'autonomous' ? 'autonomous' : 'restricted' as const;
|
||||
|
||||
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++;
|
||||
|
||||
send({ type: 'agent_started', sessionId });
|
||||
|
||||
// Show the incoming prompt in the console
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'input', prompt },
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// New session — spawn aider
|
||||
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();
|
||||
|
||||
const args: string[] = [
|
||||
'--model', aiderModel,
|
||||
'--yes-always',
|
||||
'--no-pretty',
|
||||
'--no-fancy-input',
|
||||
'--no-stream', // Complete responses (no token fragments)
|
||||
'--no-git',
|
||||
'--no-auto-commits',
|
||||
'--suggest-shell-commands',
|
||||
'--no-check-model-accepts-settings',
|
||||
];
|
||||
|
||||
if (providerConfig?.editFormat && typeof providerConfig.editFormat === 'string') {
|
||||
args.push('--edit-format', providerConfig.editFormat);
|
||||
}
|
||||
if (providerConfig?.architect === true) {
|
||||
args.push('--architect');
|
||||
}
|
||||
|
||||
send({ type: 'agent_started', sessionId });
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'system', subtype: 'init', session_id: sessionId, model: aiderModel, cwd },
|
||||
});
|
||||
|
||||
// Show the incoming prompt in the console
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'input', prompt },
|
||||
});
|
||||
|
||||
const child = spawn(aiderPath, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const session: AiderSession = {
|
||||
process: child,
|
||||
controller,
|
||||
sessionId,
|
||||
model: aiderModel,
|
||||
lineBuffer: '',
|
||||
turnBuffer: '',
|
||||
turnStartTime: Date.now(),
|
||||
turns: 0,
|
||||
ready: false,
|
||||
env,
|
||||
cwd,
|
||||
autonomousMode,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Pre-fetch btmsg/bttask context
|
||||
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 = '';
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
|
||||
// Phase 1: wait for aider startup to finish
|
||||
if (!session.ready) {
|
||||
startupBuffer += text;
|
||||
if (looksLikePrompt(startupBuffer)) {
|
||||
session.ready = true;
|
||||
session.turns = 1;
|
||||
session.turnStartTime = Date.now();
|
||||
log(`Aider ready, sending initial prompt (${fullPrompt.length} chars)`);
|
||||
child.stdin?.write(fullPrompt + '\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: accumulate entire turn output, emit as batched blocks
|
||||
session.turnBuffer += text;
|
||||
|
||||
// Only process when turn is complete (aider shows prompt again)
|
||||
if (!looksLikePrompt(session.turnBuffer)) return;
|
||||
|
||||
const duration = Date.now() - session.turnStartTime;
|
||||
const blocks = parseTurnOutput(session.turnBuffer);
|
||||
|
||||
// Emit structured blocks and execute shell commands
|
||||
const shellResults: string[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'thinking', content: block.content },
|
||||
});
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (block.content) {
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'assistant', message: { role: 'assistant', content: block.content } },
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'shell': {
|
||||
const cmdId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_use',
|
||||
id: cmdId,
|
||||
name: 'Bash',
|
||||
input: { command: block.content },
|
||||
},
|
||||
});
|
||||
|
||||
if (session.autonomousMode === 'autonomous') {
|
||||
log(`[exec] Running: ${block.content}`);
|
||||
const result = execShell(block.content, session.env, session.cwd);
|
||||
const output = result.stdout || '(no output)';
|
||||
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_result',
|
||||
tool_use_id: cmdId,
|
||||
content: output,
|
||||
},
|
||||
});
|
||||
|
||||
shellResults.push(`$ ${block.content}\n${output}`);
|
||||
} else {
|
||||
log(`[restricted] Blocked: ${block.content}`);
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_result',
|
||||
tool_use_id: cmdId,
|
||||
content: `[BLOCKED] Shell execution disabled in restricted mode. Command not executed: ${block.content}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cost':
|
||||
// Parsed below for the result event
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cost and emit result
|
||||
const costUsd = extractSessionCost(session.turnBuffer);
|
||||
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'result',
|
||||
subtype: 'result',
|
||||
result: '',
|
||||
cost_usd: costUsd,
|
||||
duration_ms: duration,
|
||||
num_turns: session.turns,
|
||||
is_error: false,
|
||||
session_id: sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
send({ type: 'agent_stopped', sessionId, exitCode: 0, signal: null });
|
||||
session.turnBuffer = '';
|
||||
|
||||
// If commands were executed, feed results back to aider for next turn
|
||||
if (shellResults.length > 0 && child.exitCode === null) {
|
||||
const feedback = `The following commands were executed and here are the results:\n\n${shellResults.join('\n\n')}\n\nBased on these results, continue your work. If the task is complete, say "DONE".`;
|
||||
log(`[exec] Feeding ${shellResults.length} command results back to aider`);
|
||||
session.turnBuffer = '';
|
||||
session.turnStartTime = Date.now();
|
||||
session.turns++;
|
||||
send({ type: 'agent_started', sessionId });
|
||||
child.stdin?.write(feedback + '\n');
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
send({ type: 'agent_stopped', sessionId, exitCode: null, signal: 'SIGTERM' });
|
||||
} else if (code !== 0 && code !== null) {
|
||||
send({ type: 'agent_error', sessionId, message: `Aider exited with code ${code}` });
|
||||
} 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.process.stdin?.write('/exit\n');
|
||||
const killTimer = setTimeout(() => {
|
||||
session.controller.abort();
|
||||
session.process.kill('SIGTERM');
|
||||
}, 3000);
|
||||
session.process.once('close', () => clearTimeout(killTimer));
|
||||
}
|
||||
|
||||
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' });
|
||||
224
sidecar/claude-runner.ts
Normal file
224
sidecar/claude-runner.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Claude Runner — Node.js sidecar entry point for Claude Code provider
|
||||
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
|
||||
// Uses @anthropic-ai/claude-agent-sdk for Claude session management
|
||||
|
||||
import { stdin, stdout, stderr } from 'process';
|
||||
import { createInterface } from 'readline';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
// Active agent sessions keyed by session ID
|
||||
const sessions = new Map<string, { query: Query; controller: AbortController }>();
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
stderr.write(`[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;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
resumeSessionId?: string;
|
||||
permissionMode?: string;
|
||||
settingSources?: string[];
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
claudeConfigDir?: string;
|
||||
additionalDirectories?: string[];
|
||||
worktreeName?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
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, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Starting agent session ${sessionId} via SDK`);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// Strip CLAUDE* and ANTHROPIC_* env vars to prevent nesting detection by the spawned CLI.
|
||||
// Whitelist CLAUDE_CODE_EXPERIMENTAL_* so feature flags (e.g. agent teams) pass through.
|
||||
const cleanEnv: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('CLAUDE_CODE_EXPERIMENTAL_')) {
|
||||
cleanEnv[key] = value;
|
||||
} else if (!key.startsWith('CLAUDE') && !key.startsWith('ANTHROPIC_')) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
// Override CLAUDE_CONFIG_DIR for multi-account support
|
||||
if (claudeConfigDir) {
|
||||
cleanEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir;
|
||||
}
|
||||
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
|
||||
if (extraEnv) {
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!claudePath) {
|
||||
send({ type: 'agent_error', sessionId, message: 'Claude CLI not found. Install Claude Code first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
abortController: controller,
|
||||
cwd: cwd || process.cwd(),
|
||||
env: cleanEnv,
|
||||
maxTurns: maxTurns ?? undefined,
|
||||
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
||||
resume: resumeSessionId ?? undefined,
|
||||
allowedTools: [
|
||||
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
|
||||
],
|
||||
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
|
||||
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
|
||||
settingSources: settingSources ?? ['user', 'project'],
|
||||
systemPrompt: systemPrompt
|
||||
? systemPrompt
|
||||
: { type: 'preset' as const, preset: 'claude_code' as const },
|
||||
model: model ?? undefined,
|
||||
additionalDirectories: additionalDirectories ?? undefined,
|
||||
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
sessions.set(sessionId, { query: q, controller });
|
||||
send({ type: 'agent_started', sessionId });
|
||||
|
||||
for await (const message of q) {
|
||||
// Forward SDK messages as-is — they use the same format as CLI stream-json
|
||||
const sdkMsg = message as Record<string, unknown>;
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: sdkMsg,
|
||||
});
|
||||
}
|
||||
|
||||
// Session completed normally
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
sessions.delete(sessionId);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
log(`Agent session ${sessionId} aborted`);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: null,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
} else {
|
||||
log(`Agent session ${sessionId} error: ${errMsg}`);
|
||||
send({
|
||||
type: 'agent_error',
|
||||
sessionId,
|
||||
message: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 agent session ${sessionId}`);
|
||||
session.controller.abort();
|
||||
}
|
||||
|
||||
function findClaudeCli(): string | undefined {
|
||||
// Check common locations
|
||||
const candidates = [
|
||||
join(homedir(), '.local', 'bin', 'claude'),
|
||||
join(homedir(), '.claude', 'local', 'claude'),
|
||||
'/usr/local/bin/claude',
|
||||
'/usr/bin/claude',
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
// Fall back to which/where
|
||||
try {
|
||||
return execSync('which claude 2>/dev/null || where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const claudePath = findClaudeCli();
|
||||
if (claudePath) {
|
||||
log(`Found Claude CLI at ${claudePath}`);
|
||||
} else {
|
||||
log('WARNING: Claude CLI not found — agent sessions will fail');
|
||||
}
|
||||
|
||||
log('Sidecar started');
|
||||
send({ type: 'ready' });
|
||||
229
sidecar/codex-runner.ts
Normal file
229
sidecar/codex-runner.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// Codex Runner — Node.js sidecar entry point for OpenAI Codex provider
|
||||
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
|
||||
// Uses @openai/codex-sdk for Codex session management
|
||||
|
||||
import { stdin, stdout, stderr } from 'process';
|
||||
import { createInterface } from 'readline';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
const sessions = new Map<string, { controller: AbortController }>();
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
stderr.write(`[codex-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;
|
||||
maxTurns?: number;
|
||||
resumeSessionId?: string;
|
||||
permissionMode?: string;
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
extraEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
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, maxTurns, resumeSessionId, permissionMode, model, providerConfig, extraEnv } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Starting Codex session ${sessionId}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// Strip CODEX*/OPENAI* env vars to prevent nesting issues
|
||||
const cleanEnv: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!key.startsWith('CODEX') && !key.startsWith('OPENAI')) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
// Re-inject the API key
|
||||
const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
|
||||
if (apiKey) {
|
||||
cleanEnv['CODEX_API_KEY'] = apiKey;
|
||||
}
|
||||
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
|
||||
if (extraEnv) {
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically import SDK — fails gracefully if not installed
|
||||
let Codex: any;
|
||||
try {
|
||||
const sdk = await import('@openai/codex-sdk');
|
||||
Codex = sdk.Codex ?? sdk.default;
|
||||
} catch {
|
||||
send({ type: 'agent_error', sessionId, message: 'Codex SDK not installed. Run: npm install @openai/codex-sdk' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
send({ type: 'agent_error', sessionId, message: 'No API key. Set CODEX_API_KEY or OPENAI_API_KEY.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Map permission mode to Codex sandbox/approval settings
|
||||
const sandbox = mapSandboxMode(providerConfig?.sandbox as string | undefined, permissionMode);
|
||||
const approvalPolicy = permissionMode === 'bypassPermissions' ? 'never' : 'on-request';
|
||||
|
||||
const codex = new Codex({
|
||||
env: cleanEnv as Record<string, string>,
|
||||
config: {
|
||||
model: model ?? 'gpt-5.4',
|
||||
approval_policy: approvalPolicy,
|
||||
sandbox: sandbox,
|
||||
},
|
||||
});
|
||||
|
||||
const threadOpts: Record<string, unknown> = {
|
||||
workingDirectory: cwd || process.cwd(),
|
||||
};
|
||||
|
||||
const thread = resumeSessionId
|
||||
? codex.resumeThread(resumeSessionId)
|
||||
: codex.startThread(threadOpts);
|
||||
|
||||
sessions.set(sessionId, { controller });
|
||||
send({ type: 'agent_started', sessionId });
|
||||
|
||||
const streamResult = await thread.runStreamed(prompt);
|
||||
|
||||
for await (const event of streamResult.events) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
// Forward raw Codex events — the message adapter parses them
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
sessions.delete(sessionId);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
log(`Codex session ${sessionId} aborted`);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: null,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
} else {
|
||||
log(`Codex session ${sessionId} error: ${errMsg}`);
|
||||
send({
|
||||
type: 'agent_error',
|
||||
sessionId,
|
||||
message: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Codex session ${sessionId}`);
|
||||
session.controller.abort();
|
||||
}
|
||||
|
||||
function mapSandboxMode(
|
||||
configSandbox: string | undefined,
|
||||
permissionMode: string | undefined,
|
||||
): string {
|
||||
if (configSandbox) return configSandbox;
|
||||
if (permissionMode === 'bypassPermissions') return 'danger-full-access';
|
||||
return 'workspace-write';
|
||||
}
|
||||
|
||||
function findCodexCli(): string | undefined {
|
||||
const candidates = [
|
||||
join(homedir(), '.local', 'bin', 'codex'),
|
||||
'/usr/local/bin/codex',
|
||||
'/usr/bin/codex',
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
try {
|
||||
return execSync('which codex 2>/dev/null || where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const codexPath = findCodexCli();
|
||||
if (codexPath) {
|
||||
log(`Found Codex CLI at ${codexPath}`);
|
||||
} else {
|
||||
log('Codex CLI not found — will use SDK if available');
|
||||
}
|
||||
|
||||
log('Codex sidecar started');
|
||||
send({ type: 'ready' });
|
||||
269
sidecar/ollama-runner.ts
Normal file
269
sidecar/ollama-runner.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// Ollama Runner — Node.js sidecar entry point for local Ollama provider
|
||||
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
|
||||
// Uses direct HTTP to Ollama REST API (no external dependencies)
|
||||
|
||||
import { stdin, stdout, stderr } from 'process';
|
||||
import { createInterface } from 'readline';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
const sessions = new Map<string, { controller: AbortController }>();
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
stderr.write(`[ollama-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;
|
||||
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, providerConfig } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ollamaHost = (providerConfig?.host as string) || process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
|
||||
const ollamaModel = model || 'qwen3:8b';
|
||||
const numCtx = (providerConfig?.num_ctx as number) || 32768;
|
||||
const think = (providerConfig?.think as boolean) ?? false;
|
||||
|
||||
log(`Starting Ollama session ${sessionId} with model ${ollamaModel}`);
|
||||
|
||||
// Health check
|
||||
try {
|
||||
const healthRes = await fetch(`${ollamaHost}/api/version`);
|
||||
if (!healthRes.ok) {
|
||||
send({ type: 'agent_error', sessionId, message: `Ollama not reachable at ${ollamaHost} (HTTP ${healthRes.status})` });
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
send({ type: 'agent_error', sessionId, message: `Cannot connect to Ollama at ${ollamaHost}: ${errMsg}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
sessions.set(sessionId, { controller });
|
||||
send({ type: 'agent_started', sessionId });
|
||||
|
||||
// Emit init event
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: sessionId,
|
||||
model: ollamaModel,
|
||||
cwd: cwd || process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
// Build messages array
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (systemPrompt && typeof systemPrompt === 'string') {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ollamaHost}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ollamaModel,
|
||||
messages,
|
||||
stream: true,
|
||||
options: { num_ctx: numCtx },
|
||||
think,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
let errMsg: string;
|
||||
try {
|
||||
const parsed = JSON.parse(errBody);
|
||||
errMsg = parsed.error || errBody;
|
||||
} catch {
|
||||
errMsg = errBody;
|
||||
}
|
||||
send({ type: 'agent_error', sessionId, message: `Ollama error (${res.status}): ${errMsg}` });
|
||||
sessions.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
send({ type: 'agent_error', sessionId, message: 'No response body from Ollama' });
|
||||
sessions.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse NDJSON stream
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
|
||||
// Check for mid-stream error
|
||||
if (typeof chunk.error === 'string') {
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: { type: 'error', message: chunk.error },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Forward as chunk event for the message adapter
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'chunk',
|
||||
message: chunk.message,
|
||||
done: chunk.done,
|
||||
done_reason: chunk.done_reason,
|
||||
model: chunk.model,
|
||||
prompt_eval_count: chunk.prompt_eval_count,
|
||||
eval_count: chunk.eval_count,
|
||||
eval_duration: chunk.eval_duration,
|
||||
total_duration: chunk.total_duration,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
log(`Failed to parse Ollama chunk: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const chunk = JSON.parse(buffer.trim()) as Record<string, unknown>;
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'chunk',
|
||||
message: chunk.message,
|
||||
done: chunk.done,
|
||||
done_reason: chunk.done_reason,
|
||||
model: chunk.model,
|
||||
prompt_eval_count: chunk.prompt_eval_count,
|
||||
eval_count: chunk.eval_count,
|
||||
eval_duration: chunk.eval_duration,
|
||||
total_duration: chunk.total_duration,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
log(`Failed to parse final Ollama buffer: ${buffer}`);
|
||||
}
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
sessions.delete(sessionId);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
log(`Ollama session ${sessionId} aborted`);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: null,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
} else {
|
||||
log(`Ollama session ${sessionId} error: ${errMsg}`);
|
||||
send({
|
||||
type: 'agent_error',
|
||||
sessionId,
|
||||
message: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Ollama session ${sessionId}`);
|
||||
session.controller.abort();
|
||||
}
|
||||
|
||||
log('Ollama sidecar started');
|
||||
send({ type: 'ready' });
|
||||
481
sidecar/package-lock.json
generated
Normal file
481
sidecar/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
{
|
||||
"name": "bterminal-sidecar",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bterminal-sidecar",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
|
||||
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
|
||||
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
|
||||
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
|
||||
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.4",
|
||||
"@esbuild/android-arm": "0.25.4",
|
||||
"@esbuild/android-arm64": "0.25.4",
|
||||
"@esbuild/android-x64": "0.25.4",
|
||||
"@esbuild/darwin-arm64": "0.25.4",
|
||||
"@esbuild/darwin-x64": "0.25.4",
|
||||
"@esbuild/freebsd-arm64": "0.25.4",
|
||||
"@esbuild/freebsd-x64": "0.25.4",
|
||||
"@esbuild/linux-arm": "0.25.4",
|
||||
"@esbuild/linux-arm64": "0.25.4",
|
||||
"@esbuild/linux-ia32": "0.25.4",
|
||||
"@esbuild/linux-loong64": "0.25.4",
|
||||
"@esbuild/linux-mips64el": "0.25.4",
|
||||
"@esbuild/linux-ppc64": "0.25.4",
|
||||
"@esbuild/linux-riscv64": "0.25.4",
|
||||
"@esbuild/linux-s390x": "0.25.4",
|
||||
"@esbuild/linux-x64": "0.25.4",
|
||||
"@esbuild/netbsd-arm64": "0.25.4",
|
||||
"@esbuild/netbsd-x64": "0.25.4",
|
||||
"@esbuild/openbsd-arm64": "0.25.4",
|
||||
"@esbuild/openbsd-x64": "0.25.4",
|
||||
"@esbuild/sunos-x64": "0.25.4",
|
||||
"@esbuild/win32-arm64": "0.25.4",
|
||||
"@esbuild/win32-ia32": "0.25.4",
|
||||
"@esbuild/win32-x64": "0.25.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
sidecar/package.json
Normal file
12
sidecar/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "bterminal-sidecar",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild claude-runner.ts --bundle --platform=node --target=node20 --outfile=dist/claude-runner.mjs --format=esm"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.4"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue