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
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' });
|
||||
Loading…
Add table
Add a link
Reference in a new issue