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:
parent
d8d7ad16f3
commit
1efcb13869
27 changed files with 276 additions and 49 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
@ -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'
|
||||||
29
v2/src/lib/adapters/message-adapters.ts
Normal file
29
v2/src/lib/adapters/message-adapters.ts
Normal 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);
|
||||||
26
v2/src/lib/adapters/provider-bridge.ts
Normal file
26
v2/src/lib/adapters/provider-bridge.ts
Normal 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 '';
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
20
v2/src/lib/providers/claude.ts
Normal file
20
v2/src/lib/providers/claude.ts
Normal 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',
|
||||||
|
};
|
||||||
26
v2/src/lib/providers/registry.svelte.ts
Normal file
26
v2/src/lib/providers/registry.svelte.ts
Normal 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);
|
||||||
|
}
|
||||||
34
v2/src/lib/providers/types.ts
Normal file
34
v2/src/lib/providers/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue