agent-orchestrator/docs/sidecar/architecture.md

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:

  1. Reads NDJSON messages from stdin in a loop
  2. On query message: resolves Claude CLI path via findClaudeCli()
  3. Calls SDK query() with options: prompt, cwd, permissionMode, model, settingSources, systemPrompt, additionalDirectories, worktreeName, pathToClaudeCodeExecutable
  4. Streams SDK messages as NDJSON to stdout
  5. On stop message: 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_KEY environment 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() to http://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:

  1. Rust layer (primary): SidecarManager calls env_clear() on the child process command, then explicitly sets only needed variables.
  2. JavaScript layer (defense-in-depth): Each runner strips provider-specific variables (Claude: CLAUDE* except CLAUDE_CODE_EXPERIMENTAL_*; Codex: CODEX*; Ollama: OLLAMA* except OLLAMA_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:

  1. Looks for {provider}-runner.mjs in sidecar dist directory
  2. Checks for Deno first, then Node.js
  3. Returns SidecarCommand struct 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 tests
  • codex-messages.test.ts — 19 tests
  • ollama-messages.test.ts — 11 tests