6.7 KiB
Sidecar Architecture
The sidecar is the bridge between agor's Rust backend and AI provider APIs. Because the Claude Agent SDK, OpenAI Codex SDK, and Ollama API are JavaScript/TypeScript libraries, they cannot run inside Rust or WebKit2GTK's webview. Instead, the Rust backend spawns child processes (sidecars) that handle AI interactions and communicate back via stdio NDJSON.
Overview
Rust Backend (SidecarManager)
|
+-- Spawns child process (Deno preferred, Node.js fallback)
+-- Writes QueryMessage to stdin (NDJSON)
+-- Reads response lines from stdout (NDJSON)
+-- Emits Tauri events for each message
+-- Manages lifecycle (start, stop, crash recovery)
|
v
Sidecar Process (one of):
+-- claude-runner.mjs -> @anthropic-ai/claude-agent-sdk
+-- codex-runner.mjs -> @openai/codex-sdk
+-- ollama-runner.mjs -> native fetch to localhost:11434
Provider Runners
Each provider has its own runner file in sidecar/, compiled to a standalone ESM bundle in sidecar/dist/ by esbuild. The runners are self-contained — all dependencies (including SDKs) are bundled into the .mjs file.
Claude Runner (claude-runner.ts -> claude-runner.mjs)
The primary runner. Uses @anthropic-ai/claude-agent-sdk query() function.
Startup sequence:
- Reads NDJSON messages from stdin in a loop
- On
querymessage: resolves Claude CLI path viafindClaudeCli() - Calls SDK
query()with options: prompt, cwd, permissionMode, model, settingSources, systemPrompt, additionalDirectories, worktreeName, pathToClaudeCodeExecutable - Streams SDK messages as NDJSON to stdout
- On
stopmessage: calls AbortController.abort()
Claude CLI detection (findClaudeCli()):
Checks paths in order: ~/.local/bin/claude -> ~/.claude/local/claude -> /usr/local/bin/claude -> /usr/bin/claude -> which claude. If none found, emits agent_error immediately.
Session resume: Passes resume: sessionId to the SDK.
Multi-account support: When claudeConfigDir is provided, it is set as CLAUDE_CONFIG_DIR in the SDK's env option.
Worktree isolation: When worktreeName is provided, it is passed as extraArgs: { worktree: name } to the SDK, which translates to --worktree <name> on the CLI.
Codex Runner (codex-runner.ts -> codex-runner.mjs)
Uses @openai/codex-sdk via dynamic import (graceful failure if not installed).
Key differences from Claude:
- Authentication via
CODEX_API_KEYenvironment variable - Sandbox mode mapping:
bypassPermissions->full-auto,default->suggest - Session resume via thread ID
- No profile/skill support
- ThreadEvent format parsed by
codex-messages.ts
Ollama Runner (ollama-runner.ts -> ollama-runner.mjs)
Direct HTTP to Ollama's REST API — zero external dependencies.
Key differences:
- No SDK — uses native
fetch()tohttp://localhost:11434/api/chat - Health check on startup (
GET /api/tags) - Supports Qwen3's
<think>tags for reasoning display - Configurable: host, model, num_ctx, temperature
- Cost is always $0 (local inference)
- No subagent support, no profiles, no skills
Communication Protocol
Messages from Rust to Sidecar (stdin)
// Query -- start a new agent session
{
"type": "query",
"session_id": "uuid",
"prompt": "Fix the bug in auth.ts",
"cwd": "/home/user/project",
"provider": "claude",
"model": "claude-sonnet-4-6",
"permission_mode": "bypassPermissions",
"resume_session_id": "previous-uuid", // optional
"system_prompt": "You are an architect...", // optional
"claude_config_dir": "~/.config/switcher-claude/work/", // optional
"setting_sources": ["user", "project"], // optional
"additional_directories": ["/shared/lib"], // optional
"worktree_name": "session-123", // optional
"provider_config": { ... }, // provider-specific blob
"extra_env": { "BTMSG_AGENT_ID": "manager-1" } // optional
}
// Stop -- abort a running session
{ "type": "stop", "session_id": "uuid" }
Messages from Sidecar to Rust (stdout)
The sidecar writes one JSON object per line (NDJSON). Claude messages follow the same format as the Claude CLI's --output-format stream-json.
Environment Variable Stripping
When agor is launched from within a Claude Code terminal session, parent CLAUDE* environment variables must not leak to the sidecar. The solution is dual-layer stripping:
- Rust layer (primary):
SidecarManagercallsenv_clear()on the child process command, then explicitly sets only needed variables. - JavaScript layer (defense-in-depth): Each runner strips provider-specific variables (Claude:
CLAUDE*exceptCLAUDE_CODE_EXPERIMENTAL_*; Codex:CODEX*; Ollama:OLLAMA*exceptOLLAMA_HOST).
The extra_env field in AgentQueryOptions allows injecting specific variables (like BTMSG_AGENT_ID) after stripping.
Sidecar Lifecycle
Startup
SidecarManager is initialized during Tauri app setup. No sidecar processes spawn until the first agent query.
Runtime Resolution
resolve_sidecar_for_provider(provider) finds the appropriate runner:
- Looks for
{provider}-runner.mjsin sidecar dist directory - Checks for Deno first, then Node.js
- Returns
SidecarCommandstruct with runtime binary and script path
Deno preferred (~50ms cold-start vs ~150ms for Node.js).
Crash Recovery (SidecarSupervisor)
See production/hardening.md for details. Exponential backoff (1s-30s cap), max 5 restart attempts, SidecarHealth enum.
Shutdown
On app exit, SidecarManager sends stop messages to all active sessions and kills remaining child processes. Drop implementation ensures cleanup even on panic.
Build Pipeline
npm run build:sidecar
# Internally runs esbuild 3 times:
# sidecar/claude-runner.ts -> sidecar/dist/claude-runner.mjs
# sidecar/codex-runner.ts -> sidecar/dist/codex-runner.mjs
# sidecar/ollama-runner.ts -> sidecar/dist/ollama-runner.mjs
Each bundle is standalone ESM with all dependencies included. Built .mjs files are included as Tauri resources in tauri.conf.json.
Message Adapter Layer
On the frontend, raw sidecar messages pass through a provider-specific adapter before reaching the agent store:
Sidecar stdout -> Rust SidecarManager -> Tauri event
-> agent-dispatcher.ts
-> message-adapters.ts (registry)
-> claude-messages.ts / codex-messages.ts / ollama-messages.ts
-> AgentMessage[] (common type)
-> agents.svelte.ts store
The AgentMessage type is provider-agnostic. The adapter layer is the only code that understands provider-specific formats.
Test Coverage
claude-messages.test.ts— 25 testscodex-messages.test.ts— 19 testsollama-messages.test.ts— 11 tests