Files
dirigent/crates/dirigent_core/CLAUDE.md
T
2026-05-08 01:59:04 +02:00

23 KiB

Package: dirigent_core

Core orchestration engine for multi-connector agent system management.

Quick Facts

  • Type: Library
  • Main Entry: src/lib.rs
  • Dependencies: dirigent_protocol, tokio, axum, serde, uuid

Architecture Overview

The dirigent_core package provides a runtime-based architecture for managing long-lived connections to external agent systems (OpenCode.ai, ACP agents, etc.). The core abstraction is the Connector, which represents a bidirectional communication channel to an agent system.

Core Components

CoreRuntime

The central orchestrator for managing connectors. It maintains:

  • Registry of active connectors (keyed by ConnectorId)
  • Global event broadcast channel for system-wide events
  • User registry for ownership and authorization
  • Configuration state (with persistence support)

CoreHandle

Lightweight, cloneable wrapper around CoreRuntime. Uses Arc internally for cheap cloning across async tasks and server functions.

Connector Trait

Defines the interface for connector implementations:

  • Command channel (mpsc) for control operations
  • Event broadcast channel for publishing events
  • State tracking (Initializing, Connecting, Ready, Error, Stopped)
  • User ownership for authorization

ConnectorHandle

Concrete implementation of the Connector trait that wraps:

  • Metadata (id, kind, owner, title)
  • Shared state (protected by RwLock)
  • Command and event channels
  • Optional task handle for lifecycle management

Connector Implementations

OpenCodeConnector

Connector for OpenCode.ai REST + SSE API:

  • HTTP client for session/message operations
  • SSE event stream for real-time updates
  • Background task loop for command processing
  • State machine for connection lifecycle
  • TurnComplete emission: Uses TurnCompleteTrigger::ExplicitSignal after MessageCompleted events (based on upstream session.idle signals)

AcpConnector (Future)

Connector for Agent-Client Protocol:

  • WebSocket or HTTP/2 transport
  • ACP message protocol handling
  • Tool execution and streaming support

Key Files

Core Runtime

  • src/runtime.rs - CoreRuntime and CoreHandle implementation
  • src/types.rs - Core types (ConnectorId, ConnectorState, User, etc.)
  • src/error.rs - Error types for the runtime
  • src/config.rs - Configuration types and template system

Connectors

  • src/connectors/mod.rs - Connector trait and ConnectorHandle
  • src/connectors/opencode/mod.rs - OpenCode connector implementation
  • src/connectors/opencode/config.rs - OpenCode-specific configuration
  • src/connectors/acp/mod.rs - ACP connector implementation (in progress)

ACP Protocol Implementation

  • src/acp/protocol/initialize.rs - Protocol initialization and capability negotiation
  • src/acp/protocol/authenticate.rs - Optional authentication flow
  • src/acp/protocol/session.rs - Session lifecycle (new, load, set_mode, cancel)
  • src/acp/protocol/prompt.rs - Prompt requests with content blocks (Phases 3-7 complete)
  • src/acp/protocol/streaming.rs - Session update notifications and handlers
  • src/acp/protocol/stop_reason.rs - Stop reason interpretation and actions
  • src/acp/protocol/cancellation.rs - Cancellation and disconnect handling
  • src/acp/protocol/error.rs - Error classification and retry logic
  • src/acp/connector_state.rs - Connection and session state management
  • src/acp/transport/mod.rs - Transport abstraction layer
  • src/acp/transport/stdio.rs - Stdio transport (process spawning)
  • src/acp/transport/http.rs - HTTP+SSE transport

Bidirectional Request Handling Pattern

The ACP connector implements true bidirectional communication where both client and agent can send JSON-RPC requests at any time. This creates an architectural challenge: how to maintain synchronous request/response semantics (async/await) while handling incoming agent requests during outgoing client requests.

The Challenge:

When the connector sends session/prompt to the agent:

  1. It calls send_request() which awaits the JSON-RPC response
  2. The agent may send permission requests (e.g., tools/write) before responding
  3. These agent requests arrive as ConnectorCommand::AgentResponse via the command channel
  4. If send_request() only polls transport and response channels, the command channel isn't polled
  5. Result: Deadlock - agent waits for permission → permission stuck in channel → client waits for prompt response

The Solution (src/connectors/acp/connector.rs:1253-1523):

The send_request() method uses tokio::select! to poll three sources simultaneously:

  • Response channel (response_rx) - Waiting for the correlated JSON-RPC response
  • Transport channel - Receiving messages/notifications from agent (may trigger response)
  • Command channel (cmd_rx) - Receiving commands from event bridge (e.g., AgentResponse)

When an AgentResponse command arrives during send_request():

  1. Extract the response payload from the command
  2. Send it to the agent via transport immediately
  3. Remove from pending requests map
  4. Continue waiting for the original prompt response

This pattern is idiomatic async Rust for implementing synchronous abstractions over bidirectional transports. It's similar to:

  • gRPC bidirectional streaming with request/response correlation
  • WebSocket clients with RPC-style method calls
  • HTTP/2 multiplexing with concurrent streams

Why Not Separate Tasks?

Alternative architectures (separate command processing task, full actor model) introduce:

  • Complex synchronization between tasks
  • Race conditions on shared state
  • Message ordering guarantees across channels
  • Significantly more code and cognitive overhead

The single-task, multi-channel select pattern keeps all state local and eliminates these issues.

Key Invariant:

Any method that blocks waiting for a response MUST also poll the command channel to process AgentResponse commands, otherwise bidirectional flows deadlock.

Main Exports

Runtime

  • CoreRuntime - Main orchestrator
  • CoreHandle - Cloneable runtime handle
  • CoreConfig - Runtime configuration

Types

  • ConnectorId, UserId - Type aliases for IDs
  • ConnectorKind - Enum of connector types (OpenCode, Acp, Mock)
  • ConnectorState - Lifecycle state enum
  • ConnectorSummary - Lightweight connector view
  • User - User information

Connectors

  • Connector - Trait for connector implementations
  • ConnectorHandle - Handle to a running connector
  • ConnectorCommand - Commands sent to connectors
  • OpenCodeConnector - OpenCode.ai integration
  • OpenCodeConfig - OpenCode connector configuration

Configuration

  • ConnectorConfig - Configuration for creating connectors
  • apply_template() - Apply connector templates with patches

ACP Protocol Types (Phases 3-7)

  • Prompt Turn:

    • SessionPromptRequest, SessionPromptResponse - Prompt requests/responses
    • ContentBlock - Text, Image, Audio, Resource, ResourceLink content
    • EmbeddedResource - Text or Blob embedded resources
    • StopReason - EndTurn, MaxTokens, MaxTurnRequests, Refusal, Cancelled
    • PromptError - Timeout, JsonRpcError, TransportError, Cancelled, ValidationError
  • Streaming Updates:

    • SessionUpdate - All update types (agent_message_chunk, tool_call, plan, etc.)
    • SessionUpdateNotification - Notification wrapper with session_id
    • ToolCallInfo - Tool call tracking with status and content
    • ToolKind - Read, Edit, Search, Execute, Think, Other
    • ToolCallStatus - Pending, InProgress, Completed, Failed, Cancelled
    • ToolCallContent - Content, Diff, Terminal output
    • MessageAccumulator - Message chunk accumulation helper
    • PlanEntry, Command - Plan and command structures
  • Stop Reason Handling:

    • StopReasonAction - Complete, ShowWarning, ShowError, ShowInfo
    • handle_stop_reason() - Interpret stop reasons
    • is_continuable(), is_error() - Stop reason classification
  • Cancellation:

    • handle_cancellation() - Cancel pending operations
    • cancel_pending_tool_calls() - Mark tool calls as cancelled
    • handle_disconnect() - Handle transport disconnect
  • Error Classification:

    • ClassifiedError - Error with class, message, details, retry_after
    • ErrorClass - Transient, Terminal, User
    • ErrorSeverity - Info, Warning, Error, Fatal
    • ErrorAction - Retry, Reconnect, CheckConfig, ContactSupport, Dismiss
    • classify_jsonrpc_error(), classify_transport_error() - Classify errors
    • exponential_backoff() - Calculate retry delays

Usage Examples

Creating a Runtime

use dirigent_core::{CoreRuntime, CoreConfig, CoreHandle};

// Load config from file or use default
let config = CoreConfig::load_config(None)?;

// Create runtime
let runtime = CoreRuntime::new(config);

// Wrap in handle for cheap cloning
let handle = CoreHandle::new(runtime);

Creating a Connector

use dirigent_core::{ConnectorConfig, ConnectorKind, OpenCodeConfig};
use serde_json::json;

// Build OpenCode connector config
let config = OpenCodeConfig {
    base_url: "http://localhost:12225".to_string(),
    title: "My OpenCode".to_string(),
    initial_session: None,
};

// Serialize to JSON for ConnectorConfig
let params = serde_json::to_value(&config)?;

let connector_config = ConnectorConfig {
    id: None, // Runtime generates ID
    kind: ConnectorKind::OpenCode,
    owner: Some("user-123".to_string()),
    title: Some("My OpenCode".to_string()),
    params,
};

// Create connector via runtime
let connector_id = handle.create_connector(
    "user-123".to_string(),
    connector_config
).await?;

Using Templates

use dirigent_core::{apply_template, ConnectorKind};
use serde_json::json;

// Use default template with custom URL
let connector_config = apply_template(
    ConnectorKind::OpenCode,
    "default",
    json!({
        "base_url": "http://localhost:8080",
        "title": "Custom OpenCode"
    })
)?;

let connector_id = handle.create_connector(
    "user-123".to_string(),
    connector_config
).await?;

Managing Connectors

// List all connectors
let all_connectors = handle.list_connectors(None).await;

// List connectors for a specific user
let user_connectors = handle.list_connectors(Some("user-123".to_string())).await;

// Get a specific connector
let connector = handle.get_connector(&connector_id).await;

// Stop a connector
handle.stop_connector(&connector_id).await?;

// Restart a stopped connector
handle.restart_connector(&connector_id).await?;

// Remove a connector
handle.remove_connector(&connector_id).await?;

Connector Lifecycle with Restart

Connectors can be restarted after being stopped or entering an error state:

// Create and start a connector
let connector_id = handle.create_connector(
    "user-123".to_string(),
    connector_config
).await?;
// Connector is now in Ready state

// Stop the connector
handle.stop_connector(&connector_id).await?;
// Connector is now in Stopped state

// Restart the connector (recreates background task with fresh channels)
handle.restart_connector(&connector_id).await?;
// Connector transitions: Stopped → Initializing → Connecting → Ready

// Restart preserves:
// - Connector ID (same instance)
// - Configuration (base_url, title, etc.)
// - Event broadcast channel (subscribers continue receiving)
// - State Arc (observers see real-time updates)

// Restart recreates:
// - Command channel (new sender/receiver pair)
// - Connector instance (fresh OpenCodeConnector)
// - Background task (new spawn)
// - Task handle (new JoinHandle)

Sending Commands

use dirigent_core::connectors::ConnectorCommand;

// Get connector handle
let connector = handle.get_connector(&connector_id).await.unwrap();

// Subscribe to events
let mut events = connector.subscribe();

// Send a command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::ListSessions).await?;

// Receive events
while let Ok(event) = events.recv().await {
    match event {
        Event::SessionsListed { sessions } => {
            println!("Got {} sessions", sessions.len());
            break;
        }
        Event::Error { message } => {
            eprintln!("Error: {}", message);
            break;
        }
        _ => {}
    }
}

Global Event Stream

// Subscribe to every event on the SharingBus. Callers can also pick an
// `EventFilter` via `subscribe_filtered()` to receive only the events
// they care about.
let mut bus_rx = handle.sharing_bus().subscribe_all().await;

tokio::spawn(async move {
    while let Some(bus_event) = bus_rx.rx.recv().await {
        println!("Bus event: {:?}", bus_event.event);
    }
});

Configuration

Runtime Configuration (dirigent.toml or dirigent.json)

port = 3000
project_dir = "."
project_name = "my_project"
templates_enabled = true

[[connectors]]
id = "opencode-1"
kind = "OpenCode"
owner = "user-123"
title = "OpenCode Local"

[connectors.params]
base_url = "http://localhost:12225"
title = "OpenCode Local"
initial_session = null

Templates

Available templates:

  • opencode/default - Standard localhost OpenCode connector
  • acp/claude-default - Claude API connector (stub for future)

Connector Event Emission Patterns

TurnComplete Event Semantics

All connectors emit Event::TurnComplete to signal that a turn/message is finalized. This is the primary signal for:

  • Archivist to finalize and write message to disk
  • UI cache to lock message state as immutable
  • Conductor bridge to flush response to upstream

Event ordering guarantee:

MessageCompleted → TurnComplete → SessionIdle

Connector-Specific TurnComplete Strategies

Different connectors use different strategies to determine when a turn is complete:

OpenCode Connector

  • Trigger: TurnCompleteTrigger::ExplicitSignal
  • Strategy: Relies on upstream session.idle events from OpenCode.ai
  • Implementation: After translating MessageCompleted, emits TurnComplete then SessionIdle
  • Code location: crates/dirigent_core/src/connectors/opencode.rs:550-575

ACP Connector (stdio transport)

  • Trigger: TurnCompleteTrigger::ResponseReceived
  • Strategy: JSON-RPC response message is the final message in a turn
  • Implementation: Emits TurnComplete after receiving the JSON-RPC response to session/prompt
  • Code location: crates/dirigent_core/src/connectors/acp/connector.rs:328

Gateway Connector

  • Trigger: TurnCompleteTrigger::OperationsComplete
  • Strategy: Tracks pending tool calls and emits when all operations resolve
  • Implementation: Monitors tool call status changes and emits when last pending call completes
  • Code location: crates/dirigent_core/src/connectors/gateway/mod.rs:464,556,626,678

Important: Connectors MUST emit TurnComplete exactly once per turn. Duplicate emissions can cause archiving issues and UI state corruption.

Gateway Session Transfer Mechanics

The Gateway connector serves as an entry point for incoming ACP connections. Sessions can be transferred to real agent connectors (Claude, etc.) via /select-connector commands.

Key Principle: New Connector Is Authority

After transfer, the target connector becomes the sole authority for session configuration.

Before transfer:
  Gateway has placeholder modes: "ask", "write", "yolo"
  Gateway has placeholder models: "simple", "default", "high"

After transfer to Claude:
  Claude's actual modes/models become authoritative
  Gateway's placeholders are irrelevant
  Editor receives config_option_update with Claude's real options

Transfer Flow

  1. User sends /select-connector claude in Gateway session
  2. Gateway emits SessionTransferRequest to CoreRuntime
  3. CoreRuntime creates/loads session in target connector
  4. Target connector emits SessionCreated with its modes/models
  5. CoreRuntime extracts modes/models from SessionCreated event
  6. CoreRuntime emits SessionTransferred event with modes/models
  7. Event bridge sends config_option_update to editor with target's modes/models

What Does NOT Happen

  • Gateway does NOT adjust or map its values to the target connector
  • Gateway does NOT remain involved after transfer completes
  • Target connector does NOT inherit Gateway's mode/model selections
  • No "mapping" between Gateway placeholders and real connector values

Code Locations

  • Transfer request handling: src/runtime.rs:execute_transfer()
  • Gateway commands: src/connectors/gateway/commands.rs
  • SessionTransferred event: dirigent_protocol/src/events/mod.rs
  • config_option_update emission: dirigent_acp_api/src/event_bridge.rs:handle_session_transferred_internal()

Architecture Patterns

Request-Response Pattern

For operations that need results (list_sessions, list_messages):

  1. Subscribe to connector events
  2. Send command via command channel
  3. Wait for corresponding response event (with timeout)
  4. Return result or error

Fire-and-Forget Pattern

For operations that stream results (send_message):

  1. Send command via command channel
  2. Return immediately
  3. Clients subscribe to event stream for updates

Lifecycle Management

Connectors progress through states:

  1. Initializing - Created but not connecting
  2. Connecting - Attempting to establish connection
  3. Ready - Connected and operational
  4. Error - Encountered failure (with error message)
  5. Stopped - Shutdown or unrecoverable error

Restart Support

Connectors in Stopped or Error state can be restarted:

  • restart_connector() recreates the connector's background task with fresh channels
  • Preserves connector identity (ID, owner, configuration)
  • Preserves event broadcast channel (existing subscribers continue receiving)
  • Recreates command channel and background task
  • State transitions: Stopped/ErrorInitializingConnectingReady

Configuration Persistence

The runtime automatically saves configuration to disk when:

  • A connector is created
  • A connector is removed
  • Configuration is explicitly saved

This ensures connectors are restored on server restart.

Session Tracking Responsibilities

IMPORTANT: CoreRuntime is a stateless orchestrator for session operations. It does NOT maintain session state or cache message history.

What CoreRuntime Does

  • Route Commands: Forward session/message commands to appropriate connectors
  • Broadcast Events: Relay connector events to global event stream
  • Manage Connectors: Track which connectors are active and available
  • Persist Configuration: Save/load connector configuration (not session data)

What CoreRuntime Does NOT Do

  • Cache Sessions: Does not maintain lists of sessions or session metadata
  • Store Messages: Does not retain message content or history
  • Buffer Events: Does not cache events for replay or historical access
  • Track Session State: Does not know which sessions exist or their current state

Stateless Design Rationale

  1. Multi-Connector Support: With multiple connectors (OpenCode, ACP, etc.), caching sessions would require complex invalidation
  2. Memory Efficiency: Long-running server should not accumulate unbounded session history
  3. Single Source of Truth: External APIs (OpenCode.ai, ACP agents) are authoritative for session state
  4. Scalability: Stateless design supports future horizontal scaling

Data Flow for Session Operations

List Sessions:

Server Function → CoreRuntime.get_connector() → Send Command → Connector queries API
                       ↓                                              ↓
                   Returns handle                              Broadcasts SessionsListed
                                                                      ↓
                                                           Server function returns to UI
                                                           (CoreRuntime does NOT cache list)

Send Message:

Server Function → CoreRuntime.get_connector() → Send Command → Connector sends to API
                       ↓                                              ↓
                   Returns handle                              Broadcasts MessageSent
                                                                      ↓
                                                           SSE pushes to UI in real-time
                                                           (CoreRuntime does NOT store message)

All session data passes through CoreRuntime but is never cached. See docs/architecture/session_tracking.md for the complete three-layer architecture (CoreRuntime, UI Cache, Archivist).

Phase 4: SharingBus + StreamRegistry (2026-04-21)

Every Event emitted by a connector or the runtime is published onto a single SharingBus that owns:

  • A broadcast::Sender<BusEvent> as the internal multicast.
  • A worker task that receives from that broadcast and dispatches per- subscriber mpsc::Sender<BusEvent> pipes with filter matching.
  • A HashMap<(connector_id, native_session_id), scroll_id> cache that late-binds routing.scroll_id for events emitted before their SessionRegistered event arrived.

Subscriber model

SharingBus::subscribe_all() and subscribe_filtered(EventFilter, cap) return a BusReceiver { id, rx, lagged }. Filters are applied by the worker — subscribers never allocate a closure for skipped events.

EventFilter variants: All | ScrollId | ConnectorUid | Kinds | AnyOf | AllOf.

Stream registry

The runtime owns a StreamRegistry and a StreamFactoryRegistry. Streams are attached via CoreRuntime::attach_stream(StreamConfig):

  • Factory registry resolves the kind string → concrete Arc<dyn SessionStream>.
  • The new stream gets its own BusReceiver scoped via scope_to_filter (Session → ScrollId, Connector → ConnectorUid, ArchiveWide → All).
  • A worker task pumps events into stream.on_event(&bus_event).await.

Health drift: record_failure / record_success in sharing/health.rs transitions HealthStatus on 5 consecutive failures (Healthy → Degraded → Unavailable).

Replay

CoreRuntime::replay_session_to_stream(scroll_id, stream_id, opts) loads archived messages via the archivist's read API and calls stream.on_event directly, bypassing the bus. Each replayed event has origin = EventOrigin::Replay { replay_id } and routing.scroll_id = Some(scroll_id).

Config

[[streams]] blocks in dirigent.toml are parsed into StreamsConfig and applied at boot (best-effort: failures log + continue).

  • api - Server functions that wrap CoreRuntime operations
  • web - Dioxus UI that calls server functions
  • dirigent_protocol - Shared event and message types
  • opencode_client - Low-level OpenCode.ai HTTP client (used by OpenCodeConnector)

Documentation

  • README: ./README.md
  • Architecture: ../../docs/architecture/overview.md
  • Migration Guide: ../../docs/migration/singleton_to_runtime.md