feat(provider-adapter): implement multi-provider abstraction layer (Phase 1)

Add provider types, registry, capabilities, and message adapter registry.
Rename sdk-messages→claude-messages, agent-runner→claude-runner,
ClaudeSession→AgentSession. Update Rust AgentQueryOptions with provider
and provider_config fields. Capability-driven AgentPane rendering.
This commit is contained in:
Hibryda 2026-03-11 02:08:45 +01:00
parent d8d7ad16f3
commit 1efcb13869
27 changed files with 276 additions and 49 deletions

View file

@ -12,6 +12,8 @@ use crate::event::EventSink;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentQueryOptions { pub struct AgentQueryOptions {
#[serde(default = "default_provider")]
pub provider: String,
pub session_id: String, pub session_id: String,
pub prompt: String, pub prompt: String,
pub cwd: Option<String>, pub cwd: Option<String>,
@ -24,6 +26,13 @@ pub struct AgentQueryOptions {
pub model: Option<String>, pub model: Option<String>,
pub claude_config_dir: Option<String>, pub claude_config_dir: Option<String>,
pub additional_directories: Option<Vec<String>>, pub additional_directories: Option<Vec<String>>,
/// Provider-specific configuration blob (passed through to sidecar as-is)
#[serde(default)]
pub provider_config: serde_json::Value,
}
fn default_provider() -> String {
"claude".to_string()
} }
/// Directories to search for sidecar scripts. /// Directories to search for sidecar scripts.
@ -66,12 +75,13 @@ impl SidecarManager {
log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" ")); log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" "));
// Build a clean environment stripping CLAUDE* vars to prevent // Build a clean environment stripping provider-specific vars to prevent
// the SDK from detecting nesting when BTerminal is launched from a Claude Code terminal. // SDKs from detecting nesting when BTerminal is launched from a provider terminal.
// Whitelist CLAUDE_CODE_EXPERIMENTAL_* so feature flags (e.g. agent teams) pass through. // Per-provider prefixes: CLAUDE* (whitelist CLAUDE_CODE_EXPERIMENTAL_*),
// CODEX* and OLLAMA* for future providers.
let clean_env: Vec<(String, String)> = std::env::vars() let clean_env: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| { .filter(|(k, _)| {
!k.starts_with("CLAUDE") || k.starts_with("CLAUDE_CODE_EXPERIMENTAL_") strip_provider_env_var(k)
}) })
.collect(); .collect();
@ -177,8 +187,23 @@ impl SidecarManager {
return Err("Sidecar not ready".to_string()); return Err("Sidecar not ready".to_string());
} }
// Validate that the requested provider has a runner available
let runner_name = format!("{}-runner.mjs", options.provider);
let runner_exists = self
.config
.search_paths
.iter()
.any(|base| base.join("dist").join(&runner_name).exists());
if !runner_exists {
return Err(format!(
"No sidecar runner found for provider '{}' (expected {})",
options.provider, runner_name
));
}
let msg = serde_json::json!({ let msg = serde_json::json!({
"type": "query", "type": "query",
"provider": options.provider,
"sessionId": options.session_id, "sessionId": options.session_id,
"prompt": options.prompt, "prompt": options.prompt,
"cwd": options.cwd, "cwd": options.cwd,
@ -191,6 +216,7 @@ impl SidecarManager {
"model": options.model, "model": options.model,
"claudeConfigDir": options.claude_config_dir, "claudeConfigDir": options.claude_config_dir,
"additionalDirectories": options.additional_directories, "additionalDirectories": options.additional_directories,
"providerConfig": options.provider_config,
}); });
self.send_message(&msg) self.send_message(&msg)
@ -227,8 +253,16 @@ impl SidecarManager {
*self.ready.lock().unwrap() *self.ready.lock().unwrap()
} }
/// Resolve a sidecar runner command. Uses the default claude-runner for startup.
/// Future providers will have their own runners (e.g. codex-runner.mjs).
fn resolve_sidecar_command(&self) -> Result<SidecarCommand, String> { fn resolve_sidecar_command(&self) -> Result<SidecarCommand, String> {
// Single bundled .mjs works with both Deno and Node.js. self.resolve_sidecar_for_provider("claude")
}
/// Resolve a sidecar command for a specific provider's runner file.
fn resolve_sidecar_for_provider(&self, provider: &str) -> Result<SidecarCommand, String> {
let runner_name = format!("{}-runner.mjs", provider);
// Try Deno first (faster startup, better perf), fall back to Node.js. // Try Deno first (faster startup, better perf), fall back to Node.js.
let has_deno = Command::new("deno") let has_deno = Command::new("deno")
.arg("--version") .arg("--version")
@ -246,7 +280,7 @@ impl SidecarManager {
let mut checked = Vec::new(); let mut checked = Vec::new();
for base in &self.config.search_paths { for base in &self.config.search_paths {
let mjs_path = base.join("dist").join("agent-runner.mjs"); let mjs_path = base.join("dist").join(&runner_name);
if mjs_path.exists() { if mjs_path.exists() {
if has_deno { if has_deno {
return Ok(SidecarCommand { return Ok(SidecarCommand {
@ -279,13 +313,27 @@ impl SidecarManager {
"" ""
}; };
Err(format!( Err(format!(
"Sidecar not found. Checked: {}{}", "Sidecar not found for provider '{}'. Checked: {}{}",
provider,
paths.join(", "), paths.join(", "),
runtime_note, runtime_note,
)) ))
} }
} }
/// Returns true if the env var should be KEPT (not stripped).
/// Strips CLAUDE*, CODEX*, OLLAMA* prefixes to prevent nesting detection.
/// Whitelists CLAUDE_CODE_EXPERIMENTAL_* for feature flags.
fn strip_provider_env_var(key: &str) -> bool {
if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") {
return true;
}
if key.starts_with("CLAUDE") || key.starts_with("CODEX") || key.starts_with("OLLAMA") {
return false;
}
true
}
impl Drop for SidecarManager { impl Drop for SidecarManager {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.shutdown(); let _ = self.shutdown();

View file

@ -14,7 +14,7 @@
"tauri:build": "cargo tauri build", "tauri:build": "cargo tauri build",
"test": "vitest run", "test": "vitest run",
"test:e2e": "wdio run tests/e2e/wdio.conf.js", "test:e2e": "wdio run tests/e2e/wdio.conf.js",
"build:sidecar": "esbuild sidecar/agent-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/agent-runner.mjs" "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",

View file

@ -1,6 +1,6 @@
// Agent Runner — Node.js sidecar entry point // Claude Runner — Node.js sidecar entry point for Claude Code provider
// Spawned by Rust backend, communicates via stdio NDJSON // Spawned by Rust SidecarManager, communicates via stdio NDJSON
// Uses @anthropic-ai/claude-agent-sdk for proper Claude session management // Uses @anthropic-ai/claude-agent-sdk for Claude session management
import { stdin, stdout, stderr } from 'process'; import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline'; import { createInterface } from 'readline';

View file

@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "esbuild agent-runner.ts --bundle --platform=node --target=node20 --outfile=dist/agent-runner.mjs --format=esm" "build": "esbuild claude-runner.ts --bundle --platform=node --target=node20 --outfile=dist/claude-runner.mjs --format=esm"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "0.25.4" "esbuild": "0.25.4"

View file

@ -44,7 +44,7 @@
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": [ "resources": [
"../sidecar/dist/agent-runner.mjs" "../sidecar/dist/claude-runner.mjs"
], ],
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Multi-session Claude agent dashboard", "shortDescription": "Multi-session Claude agent dashboard",

View file

@ -5,6 +5,8 @@
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach'; import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher'; import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte'; import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte';
import { registerProvider } from './lib/providers/registry.svelte';
import { CLAUDE_PROVIDER } from './lib/providers/claude';
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
// Workspace components // Workspace components
@ -65,6 +67,7 @@
getSetting('project_max_aspect').then(v => { getSetting('project_max_aspect').then(v => {
if (v) document.documentElement.style.setProperty('--project-max-aspect', v); if (v) document.documentElement.style.setProperty('--project-max-aspect', v);
}); });
registerProvider(CLAUDE_PROVIDER);
startAgentDispatcher(); startAgentDispatcher();
startHealthTick(); startHealthTick();

View file

@ -4,7 +4,10 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type { ProviderId } from '../providers/types';
export interface AgentQueryOptions { export interface AgentQueryOptions {
provider?: ProviderId;
session_id: string; session_id: string;
prompt: string; prompt: string;
cwd?: string; cwd?: string;
@ -17,6 +20,7 @@ export interface AgentQueryOptions {
model?: string; model?: string;
claude_config_dir?: string; claude_config_dir?: string;
additional_directories?: string[]; additional_directories?: string[];
provider_config?: Record<string, unknown>;
remote_machine_id?: string; remote_machine_id?: string;
} }

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { adaptSDKMessage } from './sdk-messages'; import { adaptSDKMessage } from './claude-messages';
import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './sdk-messages'; import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './claude-messages';
// Mock crypto.randomUUID for deterministic IDs when uuid is missing // Mock crypto.randomUUID for deterministic IDs when uuid is missing
beforeEach(() => { beforeEach(() => {

View file

@ -1,5 +1,5 @@
// SDK Message Adapter — insulates UI from Claude Agent SDK wire format changes // Claude Message Adapter — transforms Claude Agent SDK wire format to internal AgentMessage format
// This is the ONLY place that knows SDK internals. // This is the ONLY place that knows Claude SDK internals.
export type AgentMessageType = export type AgentMessageType =
| 'init' | 'init'

View file

@ -0,0 +1,29 @@
// Message Adapter Registry — routes raw provider messages to the correct parser
// Each provider registers its own adapter; the dispatcher calls adaptMessage()
import type { AgentMessage } from './claude-messages';
import type { ProviderId } from '../providers/types';
import { adaptSDKMessage } from './claude-messages';
/** Function signature for a provider message adapter */
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
const adapters = new Map<ProviderId, MessageAdapter>();
/** Register a message adapter for a provider */
export function registerMessageAdapter(providerId: ProviderId, adapter: MessageAdapter): void {
adapters.set(providerId, adapter);
}
/** Adapt a raw message using the appropriate provider adapter */
export function adaptMessage(providerId: ProviderId, raw: Record<string, unknown>): AgentMessage[] {
const adapter = adapters.get(providerId);
if (!adapter) {
console.warn(`No message adapter for provider: ${providerId}, falling back to claude`);
return adaptSDKMessage(raw);
}
return adapter(raw);
}
// Register Claude adapter by default
registerMessageAdapter('claude', adaptSDKMessage);

View file

@ -0,0 +1,26 @@
// Provider Bridge — generic adapter that delegates to provider-specific bridges
// Currently only Claude is implemented; future providers add their own bridge files
import type { ProviderId } from '../providers/types';
import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge';
// Re-export types for consumers
export type { ClaudeProfile, ClaudeSkill };
/** List profiles for a given provider (only Claude supports this) */
export async function listProviderProfiles(provider: ProviderId): Promise<ClaudeProfile[]> {
if (provider === 'claude') return claudeListProfiles();
return [];
}
/** List skills for a given provider (only Claude supports this) */
export async function listProviderSkills(provider: ProviderId): Promise<ClaudeSkill[]> {
if (provider === 'claude') return claudeListSkills();
return [];
}
/** Read a skill file (only Claude supports this) */
export async function readProviderSkill(provider: ProviderId, path: string): Promise<string> {
if (provider === 'claude') return claudeReadSkill(path);
return '';
}

View file

@ -51,8 +51,10 @@ vi.mock('./adapters/agent-bridge', () => ({
restartAgent: (...args: unknown[]) => mockRestartAgent(...args), restartAgent: (...args: unknown[]) => mockRestartAgent(...args),
})); }));
vi.mock('./adapters/sdk-messages', () => ({ vi.mock('./providers/types', () => ({}));
adaptSDKMessage: vi.fn((raw: Record<string, unknown>) => {
vi.mock('./adapters/message-adapters', () => ({
adaptMessage: vi.fn((_provider: string, raw: Record<string, unknown>) => {
if (raw.type === 'system' && raw.subtype === 'init') { if (raw.type === 'system' && raw.subtype === 'init') {
return [{ return [{
id: 'msg-1', id: 'msg-1',

View file

@ -2,8 +2,9 @@
// Single listener that routes sidecar messages to the correct agent session // Single listener that routes sidecar messages to the correct agent session
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
import { adaptSDKMessage } from './adapters/sdk-messages'; import { adaptMessage } from './adapters/message-adapters';
import type { InitContent, CostContent, ToolCallContent } from './adapters/sdk-messages'; import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
import type { ProviderId } from './providers/types';
import { import {
updateAgentStatus, updateAgentStatus,
setAgentSdkSessionId, setAgentSdkSessionId,
@ -34,14 +35,18 @@ let unlistenExit: (() => void) | null = null;
// Map sessionId -> projectId for persistence routing // Map sessionId -> projectId for persistence routing
const sessionProjectMap = new Map<string, string>(); const sessionProjectMap = new Map<string, string>();
// Map sessionId -> provider for message adapter routing
const sessionProviderMap = new Map<string, ProviderId>();
// Map sessionId -> start timestamp for metrics // Map sessionId -> start timestamp for metrics
const sessionStartTimes = new Map<string, number>(); const sessionStartTimes = new Map<string, number>();
// In-flight persistence counter — prevents teardown from racing with async saves // In-flight persistence counter — prevents teardown from racing with async saves
let pendingPersistCount = 0; let pendingPersistCount = 0;
export function registerSessionProject(sessionId: string, projectId: string): void { export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void {
sessionProjectMap.set(sessionId, projectId); sessionProjectMap.set(sessionId, projectId);
sessionProviderMap.set(sessionId, provider);
} }
// Sidecar liveness — checked by UI components // Sidecar liveness — checked by UI components
@ -149,7 +154,8 @@ const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']);
const toolUseToChildPane = new Map<string, string>(); const toolUseToChildPane = new Map<string, string>();
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void { function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
const messages = adaptSDKMessage(event); const provider = sessionProviderMap.get(sessionId) ?? 'claude';
const messages = adaptMessage(provider, event);
// Route messages with parentId to the appropriate child pane // Route messages with parentId to the appropriate child pane
const mainMessages: typeof messages = []; const mainMessages: typeof messages = [];
@ -401,5 +407,6 @@ export function stopAgentDispatcher(): void {
// Clear routing maps to prevent unbounded memory growth // Clear routing maps to prevent unbounded memory growth
toolUseToChildPane.clear(); toolUseToChildPane.clear();
sessionProjectMap.clear(); sessionProjectMap.clear();
sessionProviderMap.clear();
sessionStartTimes.clear(); sessionStartTimes.clear();
} }

View file

@ -21,7 +21,8 @@
CostContent, CostContent,
ErrorContent, ErrorContent,
StatusContent, StatusContent,
} from '../../adapters/sdk-messages'; } from '../../adapters/claude-messages';
import type { ProviderId, ProviderCapabilities } from '../../providers/types';
// Tool-aware truncation limits // Tool-aware truncation limits
const MAX_BASH_LINES = 500; const MAX_BASH_LINES = 500;
@ -29,15 +30,28 @@
const MAX_GLOB_LINES = 20; const MAX_GLOB_LINES = 20;
const MAX_DEFAULT_LINES = 30; const MAX_DEFAULT_LINES = 30;
// Default capabilities (Claude — all enabled)
const DEFAULT_CAPABILITIES: ProviderCapabilities = {
hasProfiles: true,
hasSkills: true,
hasModelSelection: true,
hasSandbox: false,
supportsSubagents: true,
supportsCost: true,
supportsResume: true,
};
interface Props { interface Props {
sessionId: string; sessionId: string;
prompt?: string; prompt?: string;
cwd?: string; cwd?: string;
profile?: string; profile?: string;
provider?: ProviderId;
capabilities?: ProviderCapabilities;
onExit?: () => void; onExit?: () => void;
} }
let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, onExit }: Props = $props(); let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props();
let session = $derived(getAgentSession(sessionId)); let session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt); let inputPrompt = $state(initialPrompt);
@ -106,9 +120,10 @@
onMount(async () => { onMount(async () => {
await getHighlighter(); await getHighlighter();
// Only load profiles/skills for providers that support them
const [profileList, skillList] = await Promise.all([ const [profileList, skillList] = await Promise.all([
listProfiles().catch(() => []), capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]),
listSkills().catch(() => []), capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]),
]); ]);
profiles = profileList; profiles = profileList;
skills = skillList; skills = skillList;
@ -144,6 +159,7 @@
const profile = profileName ? profiles.find(p => p.name === profileName) : undefined; const profile = profileName ? profiles.find(p => p.name === profileName) : undefined;
await queryAgent({ await queryAgent({
provider: providerId,
session_id: sessionId, session_id: sessionId,
prompt: text, prompt: text,
cwd: initialCwd || undefined, cwd: initialCwd || undefined,
@ -335,7 +351,7 @@
{#if msg.type === 'init'} {#if msg.type === 'init'}
<div class="msg-init"> <div class="msg-init">
<span class="label">Session started</span> <span class="label">Session started</span>
<span class="model">{(msg.content as import('../../adapters/sdk-messages').InitContent).model}</span> <span class="model">{(msg.content as import('../../adapters/claude-messages').InitContent).model}</span>
</div> </div>
{:else if msg.type === 'text'} {:else if msg.type === 'text'}
{@const textContent = (msg.content as TextContent).text} {@const textContent = (msg.content as TextContent).text}
@ -490,19 +506,21 @@
</svg> </svg>
New Session New Session
</button> </button>
<button class="session-btn session-btn-continue" onclick={() => promptRef?.focus()}> {#if capabilities.supportsResume}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <button class="session-btn session-btn-continue" onclick={() => promptRef?.focus()}>
<polyline points="9 18 15 12 9 6"></polyline> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</svg> <polyline points="9 18 15 12 9 6"></polyline>
Continue </svg>
</button> Continue
</button>
{/if}
</div> </div>
{/if} {/if}
<!-- Unified prompt input --> <!-- Unified prompt input -->
<div class="prompt-container" class:disabled={isRunning}> <div class="prompt-container" class:disabled={isRunning}>
<div class="prompt-wrapper"> <div class="prompt-wrapper">
{#if showSkillMenu && filteredSkills.length > 0} {#if capabilities.hasSkills && showSkillMenu && filteredSkills.length > 0}
<div class="skill-menu"> <div class="skill-menu">
{#each filteredSkills as skill, i (skill.name)} {#each filteredSkills as skill, i (skill.name)}
<button <button

View file

@ -13,7 +13,7 @@
ErrorContent, ErrorContent,
TextContent, TextContent,
AgentMessage, AgentMessage,
} from '../../adapters/sdk-messages'; } from '../../adapters/claude-messages';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
interface Props { interface Props {

View file

@ -16,7 +16,8 @@
setAgentSdkSessionId, setAgentSdkSessionId,
getAgentSession, getAgentSession,
} from '../../stores/agents.svelte'; } from '../../stores/agents.svelte';
import type { AgentMessage } from '../../adapters/sdk-messages'; import type { AgentMessage } from '../../adapters/claude-messages';
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import AgentPane from '../Agent/AgentPane.svelte'; import AgentPane from '../Agent/AgentPane.svelte';
interface Props { interface Props {
@ -26,6 +27,9 @@
let { project, onsessionid }: Props = $props(); let { project, onsessionid }: Props = $props();
let providerId = $derived(project.provider ?? getDefaultProviderId());
let providerMeta = $derived(getProvider(providerId));
let sessionId = $state(crypto.randomUUID()); let sessionId = $state(crypto.randomUUID());
let lastState = $state<ProjectAgentState | null>(null); let lastState = $state<ProjectAgentState | null>(null);
let loading = $state(true); let loading = $state(true);
@ -35,7 +39,7 @@
sessionId = crypto.randomUUID(); sessionId = crypto.randomUUID();
hasRestoredHistory = false; hasRestoredHistory = false;
lastState = null; lastState = null;
registerSessionProject(sessionId, project.id); registerSessionProject(sessionId, project.id, providerId);
trackProject(project.id, sessionId); trackProject(project.id, sessionId);
onsessionid?.(sessionId); onsessionid?.(sessionId);
} }
@ -70,7 +74,7 @@
} finally { } finally {
loading = false; loading = false;
// Register session -> project mapping for persistence + health tracking // Register session -> project mapping for persistence + health tracking
registerSessionProject(sessionId, project.id); registerSessionProject(sessionId, project.id, providerId);
trackProject(project.id, sessionId); trackProject(project.id, sessionId);
onsessionid?.(sessionId); onsessionid?.(sessionId);
} }
@ -112,7 +116,7 @@
} }
</script> </script>
<div class="claude-session"> <div class="agent-session">
{#if loading} {#if loading}
<div class="loading-state">Loading session...</div> <div class="loading-state">Loading session...</div>
{:else} {:else}
@ -120,13 +124,15 @@
{sessionId} {sessionId}
cwd={project.cwd} cwd={project.cwd}
profile={project.profile || undefined} profile={project.profile || undefined}
provider={providerId}
capabilities={providerMeta?.capabilities}
onExit={handleNewSession} onExit={handleNewSession}
/> />
{/if} {/if}
</div> </div>
<style> <style>
.claude-session { .agent-session {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte'; import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte';
import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/sdk-messages'; import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/claude-messages';
import { extractFilePaths } from '../../utils/tool-files'; import { extractFilePaths } from '../../utils/tool-files';
interface Props { interface Props {

View file

@ -3,7 +3,7 @@
import type { ProjectConfig } from '../../types/groups'; import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups'; import { PROJECT_ACCENTS } from '../../types/groups';
import ProjectHeader from './ProjectHeader.svelte'; import ProjectHeader from './ProjectHeader.svelte';
import ClaudeSession from './ClaudeSession.svelte'; import AgentSession from './AgentSession.svelte';
import TerminalTabs from './TerminalTabs.svelte'; import TerminalTabs from './TerminalTabs.svelte';
import TeamAgentsPanel from './TeamAgentsPanel.svelte'; import TeamAgentsPanel from './TeamAgentsPanel.svelte';
import ProjectFiles from './ProjectFiles.svelte'; import ProjectFiles from './ProjectFiles.svelte';
@ -148,7 +148,7 @@
<div class="project-content-area"> <div class="project-content-area">
<!-- PERSISTED-EAGER: always mounted, toggled via display --> <!-- PERSISTED-EAGER: always mounted, toggled via display -->
<div class="content-pane" style:display={activeTab === 'model' ? 'flex' : 'none'}> <div class="content-pane" style:display={activeTab === 'model' ? 'flex' : 'none'}>
<ClaudeSession {project} onsessionid={(id) => mainSessionId = id} /> <AgentSession {project} onsessionid={(id) => mainSessionId = id} />
{#if mainSessionId} {#if mainSessionId}
<TeamAgentsPanel {mainSessionId} /> <TeamAgentsPanel {mainSessionId} />
{/if} {/if}

View file

@ -0,0 +1,20 @@
// Claude Provider — metadata and capabilities for Claude Code
import type { ProviderMeta } from './types';
export const CLAUDE_PROVIDER: ProviderMeta = {
id: 'claude',
name: 'Claude Code',
description: 'Anthropic Claude Code agent via SDK',
capabilities: {
hasProfiles: true,
hasSkills: true,
hasModelSelection: true,
hasSandbox: false,
supportsSubagents: true,
supportsCost: true,
supportsResume: true,
},
sidecarRunner: 'claude-runner.mjs',
defaultModel: 'claude-sonnet-4-20250514',
};

View file

@ -0,0 +1,26 @@
// Provider Registry — singleton registry of available providers (Svelte 5 runes)
import type { ProviderId, ProviderMeta } from './types';
const providers = $state(new Map<ProviderId, ProviderMeta>());
export function registerProvider(meta: ProviderMeta): void {
providers.set(meta.id, meta);
}
export function getProvider(id: ProviderId): ProviderMeta | undefined {
return providers.get(id);
}
export function getProviders(): ProviderMeta[] {
return Array.from(providers.values());
}
export function getDefaultProviderId(): ProviderId {
return 'claude';
}
/** Check if a specific provider is registered */
export function hasProvider(id: ProviderId): boolean {
return providers.has(id);
}

View file

@ -0,0 +1,34 @@
// Provider abstraction types — defines the interface for multi-provider agent support
export type ProviderId = 'claude' | 'codex' | 'ollama';
/** What a provider can do — UI gates features on these flags */
export interface ProviderCapabilities {
hasProfiles: boolean;
hasSkills: boolean;
hasModelSelection: boolean;
hasSandbox: boolean;
supportsSubagents: boolean;
supportsCost: boolean;
supportsResume: boolean;
}
/** Static metadata about a provider */
export interface ProviderMeta {
id: ProviderId;
name: string;
description: string;
capabilities: ProviderCapabilities;
/** Name of the sidecar runner file (e.g. 'claude-runner.mjs') */
sidecarRunner: string;
/** Default model identifier, if applicable */
defaultModel?: string;
}
/** Per-provider configuration (stored in settings) */
export interface ProviderSettings {
enabled: boolean;
defaultModel?: string;
/** Provider-specific config blob */
config: Record<string, unknown>;
}

View file

@ -1,7 +1,7 @@
// Agent tracking state — Svelte 5 runes // Agent tracking state — Svelte 5 runes
// Manages agent session lifecycle and message history // Manages agent session lifecycle and message history
import type { AgentMessage } from '../adapters/sdk-messages'; import type { AgentMessage } from '../adapters/claude-messages';
export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error'; export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error';

View file

@ -1,3 +1,5 @@
import type { ProviderId } from '../providers/types';
export interface ProjectConfig { export interface ProjectConfig {
id: string; id: string;
name: string; name: string;
@ -7,6 +9,8 @@ export interface ProjectConfig {
cwd: string; cwd: string;
profile: string; profile: string;
enabled: boolean; enabled: boolean;
/** Agent provider for this project (defaults to 'claude') */
provider?: ProviderId;
/** When true, agents for this project use git worktrees for isolation */ /** When true, agents for this project use git worktrees for isolation */
useWorktrees?: boolean; useWorktrees?: boolean;
} }

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree'; import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree';
import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/sdk-messages'; import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/claude-messages';
import type { AgentTreeNode } from './agent-tree'; import type { AgentTreeNode } from './agent-tree';
// Helper to create typed AgentMessages // Helper to create typed AgentMessages

View file

@ -1,7 +1,7 @@
// Agent tree builder — constructs hierarchical tree from agent messages // Agent tree builder — constructs hierarchical tree from agent messages
// Subagents are identified by parent_tool_use_id on their messages // Subagents are identified by parent_tool_use_id on their messages
import type { AgentMessage, ToolCallContent, CostContent } from '../adapters/sdk-messages'; import type { AgentMessage, ToolCallContent, CostContent } from '../adapters/claude-messages';
export interface AgentTreeNode { export interface AgentTreeNode {
id: string; id: string;

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files'; import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files';
import type { ToolCallContent } from '../adapters/sdk-messages'; import type { ToolCallContent } from '../adapters/claude-messages';
function makeTc(name: string, input: unknown): ToolCallContent { function makeTc(name: string, input: unknown): ToolCallContent {
return { toolUseId: `tu-${Math.random()}`, name, input }; return { toolUseId: `tu-${Math.random()}`, name, input };

View file

@ -1,7 +1,7 @@
// Extracts file paths from agent tool_call inputs // Extracts file paths from agent tool_call inputs
// Used by ContextTab (all file ops) and conflicts store (write ops only) // Used by ContextTab (all file ops) and conflicts store (write ops only)
import type { ToolCallContent } from '../adapters/sdk-messages'; import type { ToolCallContent } from '../adapters/claude-messages';
export interface ToolFileRef { export interface ToolFileRef {
path: string; path: string;