From 753fefc205397f1a9176bb312e3de9613c44191c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:31:35 +0000 Subject: [PATCH 1/6] Add WebSocket server module design document Comprehensive plan for adding codey-server, a WebSocket server that exposes the full agent interaction for automation and external client integration. Key design decisions: - Workspace restructure: codey (core), codey-tools, codey-cli, codey-server - Server-side tool execution with approval promotion over WebSocket - Reuse existing types with Serialize derives where possible - ToolEventMessage as serializable mirror of ToolEvent (channels can't serialize) - Session event loop mirrors app.rs structure --- research/websocket-server-module.md | 910 ++++++++++++++++++++++++++++ 1 file changed, 910 insertions(+) create mode 100644 research/websocket-server-module.md diff --git a/research/websocket-server-module.md b/research/websocket-server-module.md new file mode 100644 index 0000000..aeae046 --- /dev/null +++ b/research/websocket-server-module.md @@ -0,0 +1,910 @@ +# WebSocket Server Module + +## Overview + +This document outlines the plan to add a WebSocket server module (`codey-server`) that exposes the full agent interaction over WebSocket, enabling automation and integration with external clients while keeping the core CLI unaltered. + +## Goals + +1. **Daemonized agent access**: Run codey as a background service that clients can connect to +2. **Full tool execution**: Server-side tool execution with approval promotion over WebSocket +3. **Streaming responses**: Real-time streaming of agent output (text, thinking, tool events) +4. **Minimal core changes**: Keep the existing CLI working exactly as-is +5. **Shared primitives**: Reuse `Agent`, `ToolExecutor`, and existing types where possible + +## Architecture + +### Workspace Structure + +``` +codey/ +├── Cargo.toml # workspace root +├── crates/ +│ ├── codey/ # core library (agent, executor, config) +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── lib.rs # public API exports +│ │ ├── auth.rs # OAuth handling +│ │ ├── config.rs # AgentRuntimeConfig +│ │ ├── llm/ +│ │ │ ├── mod.rs +│ │ │ ├── agent.rs # Agent, AgentStep, Usage +│ │ │ ├── registry.rs # AgentRegistry (multi-agent) +│ │ │ └── background.rs # Background task coordination +│ │ ├── tools/ +│ │ │ ├── mod.rs # SimpleTool, ToolRegistry, re-exports +│ │ │ ├── exec.rs # ToolExecutor, ToolCall, ToolEvent, ToolEventMessage +│ │ │ ├── pipeline.rs # Effect, Step, ToolPipeline +│ │ │ └── io.rs # I/O helpers +│ │ ├── tool_filter.rs # Auto-approval filters +│ │ ├── transcript.rs # Conversation persistence +│ │ └── prompts.rs # System prompts +│ │ +│ ├── codey-tools/ # tool implementations +│ │ ├── Cargo.toml # depends on codey +│ │ └── src/ +│ │ ├── lib.rs # ToolSet::full(), re-exports +│ │ ├── read_file.rs +│ │ ├── write_file.rs +│ │ ├── edit_file.rs +│ │ ├── shell.rs +│ │ ├── fetch_url.rs +│ │ ├── fetch_html.rs # optional: requires chromiumoxide +│ │ ├── web_search.rs +│ │ ├── open_file.rs +│ │ ├── spawn_agent.rs +│ │ └── background_tasks.rs +│ │ +│ ├── codey-cli/ # TUI binary (existing CLI) +│ │ ├── Cargo.toml # depends on codey + codey-tools + ratatui +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── app.rs # TUI event loop +│ │ ├── commands.rs # CLI commands +│ │ ├── compaction.rs # Context compaction +│ │ ├── ui/ +│ │ │ ├── mod.rs +│ │ │ ├── chat.rs # ChatView +│ │ │ └── input.rs # InputBox +│ │ ├── ide/ +│ │ │ ├── mod.rs # Ide trait +│ │ │ └── nvim/ # Neovim integration +│ │ └── handlers.rs # Tool approval UI, effect handlers +│ │ +│ └── codey-server/ # WebSocket server binary +│ ├── Cargo.toml # depends on codey + codey-tools + tokio-tungstenite +│ └── src/ +│ ├── main.rs # CLI entry point, daemonization +│ ├── server.rs # WebSocket listener, connection accept +│ ├── session.rs # Per-connection agent session +│ ├── protocol.rs # ClientMessage, ServerMessage +│ └── handlers.rs # Tool approval routing, effect handling +``` + +### Dependency Graph + +``` +codey-cli ──────┬──► codey-tools ──► codey (core) + │ +codey-server ───┘ + +External clients ──► codey-server (WebSocket) +Library users ──────► codey (core) directly +``` + +## Implementation Plan + +### Phase 1: Serialization Support + +Add `Serialize`/`Deserialize` to core types that will be sent over the wire. + +**Files to modify:** + +1. `src/llm/agent.rs`: + ```rust + #[derive(Debug, Clone, Serialize, Deserialize)] + pub enum AgentStep { ... } + + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] + pub struct Usage { ... } + ``` + +2. `src/tools/exec.rs`: + ```rust + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct ToolCall { ... } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] + pub enum ToolDecision { ... } + + // NEW: Serializable version of ToolEvent + /// Serializable version of [`ToolEvent`] for wire protocols (WebSocket, IPC, etc.) + /// + /// This mirrors `ToolEvent` but omits the `oneshot::Sender` responder channels + /// which cannot be serialized. The internal `ToolEvent` uses channels to implement + /// the approval flow within a single process, while this type is used for + /// cross-process or network communication. + /// + /// # Why the duplication? + /// + /// `ToolEvent` contains `oneshot::Sender` for the approval flow - + /// when a tool needs approval, the executor sends the event with a channel, and + /// the receiver (TUI or WebSocket server) sends the decision back through that + /// channel. This is elegant for in-process use but channels can't cross the wire. + /// + /// TODO: Consider whether we could restructure to have a single event type with + /// the responder as an external concern (e.g., keyed by call_id in a separate map). + /// For now, the duplication is minimal and the conversion is straightforward. + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(tag = "type")] + pub enum ToolEventMessage { ... } + + impl ToolEvent { + pub fn to_message(&self) -> ToolEventMessage { ... } + } + ``` + +3. `src/tools/pipeline.rs` (if `Effect` needs to be serialized for `Delegate` events): + ```rust + #[derive(Debug, Clone, Serialize, Deserialize)] + pub enum Effect { ... } + ``` + +4. `src/transcript.rs` (for `Status` enum if included in messages): + ```rust + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum Status { ... } + ``` + +### Phase 2: Workspace Restructure + +Convert from single crate to workspace with multiple crates. + +**Step 2.1: Create workspace root Cargo.toml** + +```toml +[workspace] +resolver = "2" +members = [ + "crates/codey", + "crates/codey-tools", + "crates/codey-cli", + "crates/codey-server", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Codey Contributors"] +license = "MIT" +repository = "https://github.com/tcdent/codey" + +[workspace.dependencies] +# Shared dependencies with versions pinned at workspace level +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +# ... etc +``` + +**Step 2.2: Create crates/codey (core library)** + +Move core functionality: +- `src/lib.rs` → `crates/codey/src/lib.rs` +- `src/llm/` → `crates/codey/src/llm/` +- `src/tools/{mod.rs, exec.rs, pipeline.rs, io.rs}` → `crates/codey/src/tools/` +- `src/config.rs` → `crates/codey/src/config.rs` +- `src/auth.rs` → `crates/codey/src/auth.rs` +- `src/transcript.rs` → `crates/codey/src/transcript.rs` +- `src/prompts.rs` → `crates/codey/src/prompts.rs` +- `src/tool_filter.rs` → `crates/codey/src/tool_filter.rs` + +**Step 2.3: Create crates/codey-tools** + +Move tool implementations: +- `src/tools/impls/*.rs` → `crates/codey-tools/src/` +- `src/tools/handlers.rs` → `crates/codey-tools/src/handlers.rs` (or split) + +```rust +// crates/codey-tools/src/lib.rs +pub use read_file::ReadFileTool; +pub use write_file::WriteFileTool; +// ... etc + +/// Create a ToolRegistry with all available tools +pub fn full_registry() -> codey::ToolRegistry { + let mut registry = codey::ToolRegistry::empty(); + registry.register(Arc::new(ReadFileTool)); + registry.register(Arc::new(WriteFileTool)); + // ... etc + registry +} +``` + +**Step 2.4: Create crates/codey-cli** + +Move TUI-specific code: +- `src/main.rs` → `crates/codey-cli/src/main.rs` +- `src/app.rs` → `crates/codey-cli/src/app.rs` +- `src/ui/` → `crates/codey-cli/src/ui/` +- `src/ide/` → `crates/codey-cli/src/ide/` +- `src/commands.rs` → `crates/codey-cli/src/commands.rs` +- `src/compaction.rs` → `crates/codey-cli/src/compaction.rs` + +```toml +# crates/codey-cli/Cargo.toml +[package] +name = "codey-cli" +version.workspace = true + +[[bin]] +name = "codey" +path = "src/main.rs" + +[dependencies] +codey = { path = "../codey" } +codey-tools = { path = "../codey-tools" } +ratatui = { version = "0.30.0-beta.0", features = ["scrolling-regions"] } +crossterm = { version = "0.28", features = ["event-stream"] } +clap = { version = "4", features = ["derive", "env"] } +nvim-rs = { version = "0.9", features = ["use_tokio"] } +# ... etc +``` + +**Step 2.5: Create crates/codey-server (stub)** + +Initial skeleton for WebSocket server. + +### Phase 3: WebSocket Protocol + +Define the wire protocol for client-server communication. + +**File: `crates/codey-server/src/protocol.rs`** + +```rust +use codey::{AgentStep, ToolEventMessage, Usage}; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Client → Server +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum ClientMessage { + /// Send a message to the agent + SendMessage { + content: String, + /// Optional: specify agent ID for multi-agent sessions + #[serde(default)] + agent_id: Option, + }, + + /// Approve or deny a pending tool execution + ToolDecision { + call_id: String, + approved: bool, + }, + + /// Cancel current operation (interrupt streaming, cancel tools) + Cancel, + + /// Request conversation history + GetHistory, + + /// Request current session state (for reconnection) + GetState, +} + +// ============================================================================ +// Server → Client +// ============================================================================ + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum ServerMessage { + /// Session established, provides session info + Connected { + session_id: String, + /// Resume token for reconnection (optional feature) + resume_token: Option, + }, + + /// Streaming text from agent + TextDelta { + agent_id: u32, + content: String, + }, + + /// Streaming thinking/reasoning from agent + ThinkingDelta { + agent_id: u32, + content: String, + }, + + /// Agent requesting tool execution + ToolRequest { + agent_id: u32, + calls: Vec, + }, + + /// Tool awaiting user approval (didn't pass auto-approve filters) + ToolAwaitingApproval { + agent_id: u32, + call_id: String, + name: String, + params: serde_json::Value, + background: bool, + }, + + /// Tool execution started (after approval) + ToolStarted { + agent_id: u32, + call_id: String, + name: String, + }, + + /// Streaming output from tool execution + ToolDelta { + agent_id: u32, + call_id: String, + content: String, + }, + + /// Tool execution completed successfully + ToolCompleted { + agent_id: u32, + call_id: String, + content: String, + }, + + /// Tool execution failed or was denied + ToolError { + agent_id: u32, + call_id: String, + error: String, + }, + + /// Agent finished processing (turn complete) + Finished { + agent_id: u32, + usage: Usage, + }, + + /// Agent is retrying after transient error + Retrying { + agent_id: u32, + attempt: u32, + error: String, + }, + + /// Conversation history (response to GetHistory) + History { + messages: Vec, + }, + + /// Session state (response to GetState) + State { + agents: Vec, + pending_approvals: Vec, + }, + + /// Error occurred + Error { + message: String, + /// If true, the session is no longer usable + fatal: bool, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolCallInfo { + pub call_id: String, + pub name: String, + pub params: serde_json::Value, + pub background: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HistoryMessage { + pub role: String, // "user", "assistant", "tool" + pub content: String, + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AgentInfo { + pub id: u32, + pub name: Option, + pub is_streaming: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PendingApproval { + pub agent_id: u32, + pub call_id: String, + pub name: String, + pub params: serde_json::Value, +} +``` + +### Phase 4: Session Management + +Implement per-connection session handling. + +**File: `crates/codey-server/src/session.rs`** + +```rust +use std::collections::HashMap; +use tokio::sync::{mpsc, oneshot}; +use codey::{ + Agent, AgentRuntimeConfig, AgentStep, RequestMode, + ToolCall, ToolDecision, ToolEvent, ToolExecutor, +}; +use codey_tools::full_registry; + +use crate::protocol::{ClientMessage, ServerMessage}; + +pub struct Session { + /// Unique session identifier + id: String, + + /// The primary agent + agent: Agent, + + /// Tool executor for server-side tool execution + tool_executor: ToolExecutor, + + /// Auto-approval filters from config + filters: ToolFilters, + + /// Pending approvals: call_id -> responder channel + pending_approvals: HashMap>, + + /// Channel to send messages to WebSocket writer task + ws_tx: mpsc::UnboundedSender, + + /// Channel to receive messages from WebSocket reader task + ws_rx: mpsc::UnboundedReceiver, +} + +impl Session { + pub fn new( + config: AgentRuntimeConfig, + system_prompt: &str, + oauth: Option, + ws_tx: mpsc::UnboundedSender, + ws_rx: mpsc::UnboundedReceiver, + ) -> Self { + let tools = full_registry(); + let tool_executor = ToolExecutor::new(tools.clone()); + let agent = Agent::new(config, system_prompt, oauth, tools); + + Self { + id: uuid::Uuid::new_v4().to_string(), + agent, + tool_executor, + filters: ToolFilters::default(), // TODO: load from config + pending_approvals: HashMap::new(), + ws_tx, + ws_rx, + } + } + + /// Main event loop - mirrors app.rs structure + pub async fn run(&mut self) -> anyhow::Result<()> { + // Send connected message + self.ws_tx.send(ServerMessage::Connected { + session_id: self.id.clone(), + resume_token: None, + })?; + + loop { + tokio::select! { + // Priority 1: WebSocket messages from client + Some(msg) = self.ws_rx.recv() => { + if self.handle_client_message(msg).await? { + break; // Client requested disconnect + } + } + + // Priority 2: Agent steps (streaming, tool requests) + Some(step) = self.agent.next() => { + self.handle_agent_step(step).await?; + } + + // Priority 3: Tool executor events + Some(event) = self.tool_executor.next() => { + self.handle_tool_event(event).await?; + } + } + } + + Ok(()) + } + + async fn handle_client_message(&mut self, msg: ClientMessage) -> anyhow::Result { + match msg { + ClientMessage::SendMessage { content, .. } => { + self.agent.send_request(&content, RequestMode::Normal); + } + + ClientMessage::ToolDecision { call_id, approved } => { + if let Some(responder) = self.pending_approvals.remove(&call_id) { + let decision = if approved { + ToolDecision::Approve + } else { + ToolDecision::Deny + }; + let _ = responder.send(decision); + } + } + + ClientMessage::Cancel => { + self.agent.cancel(); + self.tool_executor.cancel(); + } + + ClientMessage::GetHistory => { + // TODO: implement history retrieval + } + + ClientMessage::GetState => { + // TODO: implement state retrieval + } + } + + Ok(false) + } + + async fn handle_agent_step(&mut self, step: AgentStep) -> anyhow::Result<()> { + let agent_id = 0; // Primary agent + + match step { + AgentStep::TextDelta(content) => { + self.ws_tx.send(ServerMessage::TextDelta { agent_id, content })?; + } + + AgentStep::ThinkingDelta(content) => { + self.ws_tx.send(ServerMessage::ThinkingDelta { agent_id, content })?; + } + + AgentStep::ToolRequest(calls) => { + // Send tool request notification + let call_infos: Vec<_> = calls.iter().map(|c| ToolCallInfo { + call_id: c.call_id.clone(), + name: c.name.clone(), + params: c.params.clone(), + background: c.background, + }).collect(); + + self.ws_tx.send(ServerMessage::ToolRequest { + agent_id, + calls: call_infos, + })?; + + // Enqueue for execution + self.tool_executor.enqueue(calls); + } + + AgentStep::Finished { usage } => { + self.ws_tx.send(ServerMessage::Finished { agent_id, usage })?; + } + + AgentStep::Retrying { attempt, error } => { + self.ws_tx.send(ServerMessage::Retrying { agent_id, attempt, error })?; + } + + AgentStep::Error(message) => { + self.ws_tx.send(ServerMessage::Error { message, fatal: false })?; + } + + AgentStep::CompactionDelta(_) => { + // TODO: decide if we want to expose compaction to clients + } + } + + Ok(()) + } + + async fn handle_tool_event(&mut self, event: ToolEvent) -> anyhow::Result<()> { + match event { + ToolEvent::AwaitingApproval { + agent_id, + call_id, + name, + params, + background, + responder, + } => { + // Check auto-approval filters first + if self.filters.should_approve(&name, ¶ms) { + let _ = responder.send(ToolDecision::Approve); + self.ws_tx.send(ServerMessage::ToolStarted { + agent_id, + call_id, + name, + })?; + } else { + // Promote to WebSocket for user decision + self.pending_approvals.insert(call_id.clone(), responder); + self.ws_tx.send(ServerMessage::ToolAwaitingApproval { + agent_id, + call_id, + name, + params, + background, + })?; + } + } + + ToolEvent::Delta { agent_id, call_id, content } => { + self.ws_tx.send(ServerMessage::ToolDelta { + agent_id, + call_id, + content, + })?; + } + + ToolEvent::Completed { agent_id, call_id, content } => { + // Submit result back to agent + self.agent.submit_tool_result(&call_id, content.clone()); + + self.ws_tx.send(ServerMessage::ToolCompleted { + agent_id, + call_id, + content, + })?; + } + + ToolEvent::Error { agent_id, call_id, content } => { + // Submit error back to agent + self.agent.submit_tool_result(&call_id, format!("Error: {}", content)); + + self.ws_tx.send(ServerMessage::ToolError { + agent_id, + call_id, + error: content, + })?; + } + + ToolEvent::Delegate { responder, .. } => { + // For now, reject delegated effects (IDE integration, sub-agents) + // TODO: implement delegation over WebSocket + let _ = responder.send(Err("Delegation not supported over WebSocket".to_string())); + } + + ToolEvent::BackgroundStarted { agent_id, call_id, name } => { + self.ws_tx.send(ServerMessage::ToolStarted { + agent_id, + call_id, + name, + })?; + } + + ToolEvent::BackgroundCompleted { agent_id, call_id, .. } => { + // Retrieve result and submit to agent + if let Some((name, output, status)) = self.tool_executor.take_result(&call_id) { + self.agent.submit_tool_result(&call_id, output.clone()); + self.ws_tx.send(ServerMessage::ToolCompleted { + agent_id, + call_id, + content: output, + })?; + } + } + } + + Ok(()) + } +} +``` + +### Phase 5: WebSocket Server + +Implement the server listener and connection handling. + +**File: `crates/codey-server/src/server.rs`** + +```rust +use std::net::SocketAddr; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::{accept_async, tungstenite::Message}; +use futures::{SinkExt, StreamExt}; + +use crate::protocol::{ClientMessage, ServerMessage}; +use crate::session::Session; + +pub struct Server { + addr: SocketAddr, + config: ServerConfig, +} + +pub struct ServerConfig { + pub system_prompt: String, + pub agent_config: AgentRuntimeConfig, + pub oauth: Option, +} + +impl Server { + pub fn new(addr: SocketAddr, config: ServerConfig) -> Self { + Self { addr, config } + } + + pub async fn run(&self) -> anyhow::Result<()> { + let listener = TcpListener::bind(&self.addr).await?; + tracing::info!("WebSocket server listening on {}", self.addr); + + while let Ok((stream, peer_addr)) = listener.accept().await { + tracing::info!("New connection from {}", peer_addr); + let config = self.config.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, config).await { + tracing::error!("Connection error: {}", e); + } + }); + } + + Ok(()) + } +} + +async fn handle_connection(stream: TcpStream, config: ServerConfig) -> anyhow::Result<()> { + let ws_stream = accept_async(stream).await?; + let (mut ws_sink, mut ws_source) = ws_stream.split(); + + // Channels for session <-> WebSocket communication + let (tx_to_ws, mut rx_to_ws) = mpsc::unbounded_channel::(); + let (tx_from_ws, rx_from_ws) = mpsc::unbounded_channel::(); + + // Spawn WebSocket writer task + let writer_handle = tokio::spawn(async move { + while let Some(msg) = rx_to_ws.recv().await { + let json = serde_json::to_string(&msg)?; + ws_sink.send(Message::Text(json)).await?; + } + Ok::<_, anyhow::Error>(()) + }); + + // Spawn WebSocket reader task + let reader_handle = tokio::spawn(async move { + while let Some(msg) = ws_source.next().await { + match msg? { + Message::Text(text) => { + let client_msg: ClientMessage = serde_json::from_str(&text)?; + tx_from_ws.send(client_msg)?; + } + Message::Close(_) => break, + _ => {} // Ignore binary, ping, pong + } + } + Ok::<_, anyhow::Error>(()) + }); + + // Create and run session + let mut session = Session::new( + config.agent_config, + &config.system_prompt, + config.oauth, + tx_to_ws, + rx_from_ws, + ); + + session.run().await?; + + // Clean up + writer_handle.abort(); + reader_handle.abort(); + + Ok(()) +} +``` + +**File: `crates/codey-server/src/main.rs`** + +```rust +use std::net::SocketAddr; +use clap::Parser; + +mod protocol; +mod server; +mod session; + +#[derive(Parser)] +#[command(name = "codey-server")] +#[command(about = "WebSocket server for codey agent")] +struct Args { + /// Address to listen on + #[arg(short, long, default_value = "127.0.0.1:9999")] + listen: SocketAddr, + + /// Path to config file + #[arg(short, long)] + config: Option, + + /// Run in foreground (don't daemonize) + #[arg(long)] + foreground: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::init(); + + let args = Args::parse(); + + // Load config + let config = load_config(args.config)?; + + // TODO: daemonization support + + let server = server::Server::new(args.listen, config); + server.run().await +} +``` + +## Implementation Order + +1. **Phase 1: Serialization** (can be done without restructure) + - Add `Serialize`/`Deserialize` to `AgentStep`, `Usage`, `ToolCall`, `ToolDecision` + - Add `ToolEventMessage` with conversion from `ToolEvent` + - Test serialization roundtrips + +2. **Phase 2: Workspace Restructure** + - Create workspace Cargo.toml + - Create `crates/codey/` with core library + - Create `crates/codey-tools/` with tool implementations + - Create `crates/codey-cli/` with TUI (verify existing behavior works) + - Update CI/CD for workspace builds + +3. **Phase 3: codey-server Skeleton** + - Create `crates/codey-server/` structure + - Implement protocol types + - Implement basic WebSocket accept loop + - Test connection establishment + +4. **Phase 4: Session Implementation** + - Implement Session struct with event loop + - Wire up Agent and ToolExecutor + - Handle ClientMessage routing + - Handle AgentStep → ServerMessage conversion + - Handle ToolEvent → ServerMessage conversion + approval flow + +5. **Phase 5: Integration & Testing** + - End-to-end testing with sample client + - Error handling and reconnection logic + - Configuration loading (filters, OAuth, etc.) + - Documentation + +## Future Considerations + +### Multi-Agent Support +The protocol includes `agent_id` fields to support multi-agent sessions. This mirrors the existing `AgentRegistry` in the CLI. + +### Session Persistence +- Save/restore session state for server restarts +- Resume tokens for client reconnection + +### Authentication +- API key authentication for server access +- Per-session OAuth forwarding + +### Sub-Agent Delegation +Currently `ToolEvent::Delegate` is rejected over WebSocket. Could potentially: +- Proxy delegation requests to client +- Handle sub-agent spawning server-side + +### IDE Integration +The WebSocket protocol could potentially support IDE effects (selections, open files) by forwarding them to the client. This would enable VS Code extension integration. + +## References + +- Current tool executor: `src/tools/exec.rs` +- Current app event loop: `src/app.rs` +- Library documentation: `LIBRARY.md` +- Sub-agent architecture: `research/sub-agent-architecture.md` From 7eb541c44a0e590fe81fc6daef8aebde20dde7f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:47:08 +0000 Subject: [PATCH 2/6] Add serialization support for wire protocols (Phase 1) Adds Serialize/Deserialize derives to core types to enable WebSocket and other wire protocol integrations: - AgentStep, Usage, RequestMode in agent.rs - ToolCall, ToolDecision in exec.rs - Effect in pipeline.rs - Edit, ToolPreview in ide/mod.rs Adds ToolEventMessage enum as a serializable version of ToolEvent. The internal ToolEvent contains oneshot::Sender channels for the approval flow which cannot be serialized. ToolEventMessage mirrors the structure but omits these channels, with a to_message() conversion. Exports new types from lib.rs public API: - ToolEventMessage, ToolDecision, Effect --- src/ide/mod.rs | 4 +- src/lib.rs | 2 +- src/llm/agent.rs | 6 +- src/tools/exec.rs | 127 +++++++++++++++++++++++++++++++++++++++++- src/tools/mod.rs | 2 +- src/tools/pipeline.rs | 2 +- 6 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/ide/mod.rs b/src/ide/mod.rs index 8a952a6..b4db4f6 100644 --- a/src/ide/mod.rs +++ b/src/ide/mod.rs @@ -28,14 +28,14 @@ pub use nvim::Nvim; // ============================================================================ /// An edit operation (old_string → new_string) -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Edit { pub old_string: String, pub new_string: String, } /// A preview to show in the IDE before tool execution -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum ToolPreview { /// Show file content (for write_file, showing what will be created) File { path: String, content: String }, diff --git a/src/lib.rs b/src/lib.rs index 994d1b4..990a683 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,4 +57,4 @@ mod tool_filter; // Re-export the public API pub use config::AgentRuntimeConfig; pub use llm::{Agent, AgentStep, RequestMode, Usage}; -pub use tools::{SimpleTool, ToolCall, ToolRegistry}; +pub use tools::{Effect, SimpleTool, ToolCall, ToolDecision, ToolEventMessage, ToolRegistry}; diff --git a/src/llm/agent.rs b/src/llm/agent.rs index 1d4b43c..8e0ad1d 100644 --- a/src/llm/agent.rs +++ b/src/llm/agent.rs @@ -49,7 +49,7 @@ impl From<&GenaiToolCall> for ToolCall { } /// Token usage tracking -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)] pub struct Usage { /// Cumulative output tokens across the session pub output_tokens: u32, @@ -92,6 +92,8 @@ impl std::ops::AddAssign for Usage { } /// Steps yielded by the agent during processing +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] pub enum AgentStep { /// Streaming text chunk TextDelta(String), @@ -120,7 +122,7 @@ enum StreamState { } /// Request mode controlling agent behavior for a single request -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)] pub enum RequestMode { /// Normal conversation mode with tool access #[default] diff --git a/src/tools/exec.rs b/src/tools/exec.rs index 2f96c28..80ee370 100644 --- a/src/tools/exec.rs +++ b/src/tools/exec.rs @@ -55,7 +55,7 @@ enum WaitingFor { } /// A tool call pending execution -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ToolCall { pub agent_id: AgentId, pub call_id: String, @@ -74,7 +74,7 @@ impl ToolCall { } /// Decision state for a pending tool -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum ToolDecision { #[default] Pending, @@ -192,6 +192,129 @@ impl ToolEvent { rx, ) } + + /// Convert to a serializable message, dropping the responder channels. + pub fn to_message(&self) -> ToolEventMessage { + match self { + Self::AwaitingApproval { agent_id, call_id, name, params, background, .. } => { + ToolEventMessage::AwaitingApproval { + agent_id: *agent_id, + call_id: call_id.clone(), + name: name.clone(), + params: params.clone(), + background: *background, + } + } + Self::Delegate { agent_id, call_id, effect, .. } => { + ToolEventMessage::Delegate { + agent_id: *agent_id, + call_id: call_id.clone(), + effect: effect.clone(), + } + } + Self::Delta { agent_id, call_id, content } => { + ToolEventMessage::Delta { + agent_id: *agent_id, + call_id: call_id.clone(), + content: content.clone(), + } + } + Self::Completed { agent_id, call_id, content } => { + ToolEventMessage::Completed { + agent_id: *agent_id, + call_id: call_id.clone(), + content: content.clone(), + } + } + Self::Error { agent_id, call_id, content } => { + ToolEventMessage::Error { + agent_id: *agent_id, + call_id: call_id.clone(), + content: content.clone(), + } + } + Self::BackgroundStarted { agent_id, call_id, name } => { + ToolEventMessage::BackgroundStarted { + agent_id: *agent_id, + call_id: call_id.clone(), + name: name.clone(), + } + } + Self::BackgroundCompleted { agent_id, call_id, name } => { + ToolEventMessage::BackgroundCompleted { + agent_id: *agent_id, + call_id: call_id.clone(), + name: name.clone(), + } + } + } + } +} + +/// Serializable version of [`ToolEvent`] for wire protocols (WebSocket, IPC, etc.) +/// +/// This mirrors `ToolEvent` but omits the `oneshot::Sender` responder channels +/// which cannot be serialized. The internal `ToolEvent` uses channels to implement +/// the approval flow within a single process, while this type is used for +/// cross-process or network communication. +/// +/// # Why the duplication? +/// +/// `ToolEvent` contains `oneshot::Sender` for the approval flow - +/// when a tool needs approval, the executor sends the event with a channel, and +/// the receiver (TUI or WebSocket server) sends the decision back through that +/// channel. This is elegant for in-process use but channels can't cross the wire. +/// +/// TODO: Consider whether we could restructure to have a single event type with +/// the responder as an external concern (e.g., keyed by call_id in a separate map). +/// For now, the duplication is minimal and the conversion is straightforward. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum ToolEventMessage { + /// Tool needs user approval + AwaitingApproval { + agent_id: AgentId, + call_id: String, + name: String, + params: serde_json::Value, + background: bool, + }, + /// Effect delegated to app (IDE, agents, etc) + Delegate { + agent_id: AgentId, + call_id: String, + effect: Effect, + }, + /// Streaming output from execution + Delta { + agent_id: AgentId, + call_id: String, + content: String, + }, + /// Tool execution completed successfully + Completed { + agent_id: AgentId, + call_id: String, + content: String, + }, + /// Tool execution failed + Error { + agent_id: AgentId, + call_id: String, + content: String, + }, + /// Background tool started - placeholder sent to agent + BackgroundStarted { + agent_id: AgentId, + call_id: String, + name: String, + }, + /// Background tool completed - notification for agent + BackgroundCompleted { + agent_id: AgentId, + call_id: String, + name: String, + }, } /// Active pipeline execution state diff --git a/src/tools/mod.rs b/src/tools/mod.rs index b5f96e0..068e44a 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -31,7 +31,7 @@ pub mod names { use std::collections::HashMap; use std::sync::Arc; -pub use exec::{ToolCall, ToolDecision, ToolEvent, ToolExecutor}; +pub use exec::{ToolCall, ToolDecision, ToolEvent, ToolEventMessage, ToolExecutor}; #[cfg(feature = "cli")] pub use impls::{ init_agent_context, update_agent_oauth, EditFileTool, FetchHtmlTool, FetchUrlTool, diff --git a/src/tools/pipeline.rs b/src/tools/pipeline.rs index 3f2e5bb..20de793 100644 --- a/src/tools/pipeline.rs +++ b/src/tools/pipeline.rs @@ -41,7 +41,7 @@ pub trait EffectHandler: Send { } /// Effects that must be delegated to the app layer -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum Effect { // === IDE === IdeOpen { path: PathBuf, line: Option, column: Option }, From 5cc5486773b3143334a1018948fbb6ac896b5857 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:56:50 +0000 Subject: [PATCH 3/6] Restructure as Cargo workspace with codey-server Major refactor to support WebSocket server as a separate binary: Workspace structure: - crates/codey: Core library + CLI binary (with cli feature) - crates/codey-server: WebSocket server binary Changes: - Move src/ to crates/codey/src/ - Create workspace Cargo.toml with shared dependencies - Add codey crate with cli feature for optional TUI/tool deps - Add codey-server crate with WebSocket protocol and session management codey-server implements: - protocol.rs: ClientMessage/ServerMessage JSON protocol - session.rs: Per-connection agent session with event loop - server.rs: WebSocket listener and connection handling - main.rs: CLI entry point with configuration The server currently streams AgentStep events over WebSocket and promotes tool approvals to the client. Full ToolExecutor integration is planned for follow-up. Makefile updated with workspace-aware targets: - make build: Build all workspace members - make build-cli: Build just the CLI - make build-server: Build just the server - make run / make run-server: Run respective binaries --- Cargo.lock | 396 ++++++++---------- Cargo.toml | 83 ++-- Makefile | 24 +- crates/codey-server/Cargo.toml | 42 ++ crates/codey-server/src/main.rs | 85 ++++ crates/codey-server/src/protocol.rs | 193 +++++++++ crates/codey-server/src/server.rs | 149 +++++++ crates/codey-server/src/session.rs | 243 +++++++++++ crates/codey/Cargo.toml | 96 +++++ {src => crates/codey/src}/app.rs | 0 {src => crates/codey/src}/auth.rs | 0 {src => crates/codey/src}/commands.rs | 0 {src => crates/codey/src}/compaction.rs | 0 {src => crates/codey/src}/config.rs | 0 {src => crates/codey/src}/ide/mod.rs | 0 .../codey/src}/ide/nvim/lua/close_preview.lua | 0 .../src}/ide/nvim/lua/has_unsaved_changes.lua | 0 .../codey/src}/ide/nvim/lua/navigate_to.lua | 0 .../codey/src}/ide/nvim/lua/reload_buffer.lua | 0 .../src}/ide/nvim/lua/selection_tracking.lua | 0 .../codey/src}/ide/nvim/lua/show_diff.lua | 0 .../src}/ide/nvim/lua/show_file_preview.lua | 0 {src => crates/codey/src}/ide/nvim/mod.rs | 0 {src => crates/codey/src}/lib.rs | 0 {src => crates/codey/src}/llm/agent.rs | 0 {src => crates/codey/src}/llm/background.rs | 0 {src => crates/codey/src}/llm/mod.rs | 0 {src => crates/codey/src}/llm/registry.rs | 0 {src => crates/codey/src}/main.rs | 0 {src => crates/codey/src}/profiler.rs | 0 {src => crates/codey/src}/prompts.rs | 0 {src => crates/codey/src}/tool_filter.rs | 0 {src => crates/codey/src}/tools/README.md | 0 {src => crates/codey/src}/tools/exec.rs | 0 {src => crates/codey/src}/tools/handlers.rs | 0 .../src}/tools/impls/background_tasks.rs | 0 .../codey/src}/tools/impls/edit_file.rs | 0 .../codey/src}/tools/impls/fetch_html.rs | 0 .../codey/src}/tools/impls/fetch_url.rs | 0 {src => crates/codey/src}/tools/impls/mod.rs | 0 .../codey/src}/tools/impls/open_file.rs | 0 .../codey/src}/tools/impls/read_file.rs | 0 .../codey/src}/tools/impls/shell.rs | 0 .../codey/src}/tools/impls/spawn_agent.rs | 0 .../codey/src}/tools/impls/web_search.rs | 0 .../codey/src}/tools/impls/write_file.rs | 0 {src => crates/codey/src}/tools/io.rs | 0 {src => crates/codey/src}/tools/mod.rs | 0 {src => crates/codey/src}/tools/pipeline.rs | 0 {src => crates/codey/src}/transcript.rs | 0 {src => crates/codey/src}/ui/chat.rs | 0 {src => crates/codey/src}/ui/input.rs | 0 {src => crates/codey/src}/ui/input_tests.rs | 0 {src => crates/codey/src}/ui/mod.rs | 0 ...__tests__snapshot_after_complex_edits.snap | 0 ...i__input__tests__snapshot_empty_input.snap | 0 ..._ui__input__tests__snapshot_multiline.snap | 0 ..._input__tests__snapshot_special_chars.snap | 0 ...t__tests__snapshot_with_ide_selection.snap | 0 ...ests__snapshot_with_pasted_attachment.snap | 0 ..._ui__input__tests__snapshot_with_text.snap | 0 ...ut__tests__snapshot_wrapped_long_text.snap | 0 62 files changed, 1039 insertions(+), 272 deletions(-) create mode 100644 crates/codey-server/Cargo.toml create mode 100644 crates/codey-server/src/main.rs create mode 100644 crates/codey-server/src/protocol.rs create mode 100644 crates/codey-server/src/server.rs create mode 100644 crates/codey-server/src/session.rs create mode 100644 crates/codey/Cargo.toml rename {src => crates/codey/src}/app.rs (100%) rename {src => crates/codey/src}/auth.rs (100%) rename {src => crates/codey/src}/commands.rs (100%) rename {src => crates/codey/src}/compaction.rs (100%) rename {src => crates/codey/src}/config.rs (100%) rename {src => crates/codey/src}/ide/mod.rs (100%) rename {src => crates/codey/src}/ide/nvim/lua/close_preview.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/has_unsaved_changes.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/navigate_to.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/reload_buffer.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/selection_tracking.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/show_diff.lua (100%) rename {src => crates/codey/src}/ide/nvim/lua/show_file_preview.lua (100%) rename {src => crates/codey/src}/ide/nvim/mod.rs (100%) rename {src => crates/codey/src}/lib.rs (100%) rename {src => crates/codey/src}/llm/agent.rs (100%) rename {src => crates/codey/src}/llm/background.rs (100%) rename {src => crates/codey/src}/llm/mod.rs (100%) rename {src => crates/codey/src}/llm/registry.rs (100%) rename {src => crates/codey/src}/main.rs (100%) rename {src => crates/codey/src}/profiler.rs (100%) rename {src => crates/codey/src}/prompts.rs (100%) rename {src => crates/codey/src}/tool_filter.rs (100%) rename {src => crates/codey/src}/tools/README.md (100%) rename {src => crates/codey/src}/tools/exec.rs (100%) rename {src => crates/codey/src}/tools/handlers.rs (100%) rename {src => crates/codey/src}/tools/impls/background_tasks.rs (100%) rename {src => crates/codey/src}/tools/impls/edit_file.rs (100%) rename {src => crates/codey/src}/tools/impls/fetch_html.rs (100%) rename {src => crates/codey/src}/tools/impls/fetch_url.rs (100%) rename {src => crates/codey/src}/tools/impls/mod.rs (100%) rename {src => crates/codey/src}/tools/impls/open_file.rs (100%) rename {src => crates/codey/src}/tools/impls/read_file.rs (100%) rename {src => crates/codey/src}/tools/impls/shell.rs (100%) rename {src => crates/codey/src}/tools/impls/spawn_agent.rs (100%) rename {src => crates/codey/src}/tools/impls/web_search.rs (100%) rename {src => crates/codey/src}/tools/impls/write_file.rs (100%) rename {src => crates/codey/src}/tools/io.rs (100%) rename {src => crates/codey/src}/tools/mod.rs (100%) rename {src => crates/codey/src}/tools/pipeline.rs (100%) rename {src => crates/codey/src}/transcript.rs (100%) rename {src => crates/codey/src}/ui/chat.rs (100%) rename {src => crates/codey/src}/ui/input.rs (100%) rename {src => crates/codey/src}/ui/input_tests.rs (100%) rename {src => crates/codey/src}/ui/mod.rs (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_after_complex_edits.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_empty_input.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_multiline.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_special_chars.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_with_ide_selection.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_with_pasted_attachment.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_with_text.snap (100%) rename {src => crates/codey/src}/ui/snapshots/codey__ui__input__tests__snapshot_wrapped_long_text.snap (100%) diff --git a/Cargo.lock b/Cargo.lock index 1986b12..4e97cfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,7 +101,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -112,7 +112,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -126,7 +126,7 @@ dependencies = [ "log", "pin-project-lite", "tokio", - "tungstenite", + "tungstenite 0.23.0", ] [[package]] @@ -258,9 +258,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "shlex", @@ -346,9 +346,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -389,14 +389,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codey" @@ -428,10 +428,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "sysinfo", "tempfile", "textwrap", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-test", @@ -445,6 +444,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "codey-server" +version = "0.1.0-rc.5" +dependencies = [ + "anyhow", + "clap", + "codey", + "dirs", + "dotenvy", + "futures 0.3.31", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -543,7 +561,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -697,7 +715,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -710,7 +728,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -721,7 +739,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -732,14 +750,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deltae" @@ -783,7 +801,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -796,7 +814,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.113", + "syn 2.0.114", "unicode-xid", ] @@ -845,7 +863,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -925,9 +943,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.11" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" dependencies = [ "num-traits", ] @@ -983,9 +1001,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "finl_unicode" @@ -1107,7 +1125,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1179,9 +1197,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1216,7 +1234,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.1", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1302,7 +1320,7 @@ dependencies = [ "markup5ever 0.12.1", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1483,7 +1501,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1616,9 +1634,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1637,9 +1655,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.0" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5" +checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8" dependencies = [ "console", "once_cell", @@ -1657,7 +1675,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1719,15 +1737,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1745,9 +1754,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1761,7 +1770,7 @@ checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -1790,7 +1799,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1801,9 +1810,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libredox" @@ -1865,9 +1874,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.2" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ "hashbrown 0.16.1", ] @@ -2055,15 +2064,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "ntapi" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2087,7 +2087,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2175,7 +2175,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2276,7 +2276,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2358,7 +2358,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2466,7 +2466,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2487,7 +2487,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2540,7 +2540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2560,7 +2560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2569,14 +2569,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2606,11 +2606,11 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools 0.14.0", + "itertools", "kasuari", "lru", "strum", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.2", @@ -2669,7 +2669,7 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools 0.14.0", + "itertools", "line-clipping", "ratatui-core", "strum", @@ -2680,12 +2680,11 @@ dependencies = [ [[package]] name = "ratskin" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d16b869729f2643509ef628e2ac0238b82752f01d08eaced4d217aa33166a01" +checksum = "16bbd34b7f15b651fab3a6b871b2546aa31fb1f851ddc76699bba969566f0ca5" dependencies = [ "ratatui", - "regex", "termimad", ] @@ -2718,7 +2717,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -2740,7 +2739,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2877,7 +2876,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2967,9 +2966,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -2977,9 +2976,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -3093,7 +3092,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3140,7 +3139,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.1", + "indexmap 2.13.0", "schemars 0.9.0", "schemars 1.2.0", "serde_core", @@ -3158,7 +3157,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3352,7 +3351,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3374,9 +3373,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -3406,20 +3405,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", -] - -[[package]] -name = "sysinfo" -version = "0.33.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "windows", + "syn 2.0.114", ] [[package]] @@ -3479,7 +3465,7 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.1.14", ] @@ -3578,11 +3564,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3593,18 +3579,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3618,9 +3604,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -3628,22 +3614,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -3710,7 +3696,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3755,6 +3741,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3796,7 +3794,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime", @@ -3812,9 +3810,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3874,7 +3872,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3940,6 +3938,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes 1.11.0", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3973,7 +3989,7 @@ checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4002,11 +4018,11 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.13.0", + "itertools", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -4138,18 +4154,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4160,11 +4176,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4173,9 +4190,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4183,22 +4200,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -4218,9 +4235,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4351,52 +4368,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.113", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -4405,18 +4389,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", -] - -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4427,7 +4400,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4436,15 +4409,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4731,9 +4695,9 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -4788,28 +4752,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4829,7 +4793,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] @@ -4869,14 +4833,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" [[patch.unused]] name = "ratatui-core" diff --git a/Cargo.toml b/Cargo.toml index 6251210..36eb623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,41 +1,32 @@ -[package] -name = "codey" +[workspace] +resolver = "2" +members = [ + "crates/codey", + "crates/codey-server", +] + +[workspace.package] version = "0.1.0-rc.5" edition = "2021" authors = ["Codey Contributors"] -description = "A terminal-based AI coding assistant" license = "MIT" repository = "https://github.com/tcdent/codey" -[lib] -name = "codey" -path = "src/lib.rs" - -[[bin]] -name = "codey" -path = "src/main.rs" - -[dependencies] -# TUI (CLI only) -ratatui = { version = "0.30.0-beta.0", features = ["scrolling-regions"], optional = true } -crossterm = { version = "0.28", features = ["event-stream"], optional = true } +[workspace.dependencies] +# Internal crates +codey = { path = "crates/codey" } # Async runtime tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" -# LLM client (multi-provider) +# LLM client genai = "0.4.4" # HTTP client reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], default-features = false } futures = "0.3" -# Web content extraction (CLI only - reader view) -chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false, optional = true } -readability = { version = "0.3", optional = true } -htmd = { version = "0.1", optional = true } - # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -44,9 +35,6 @@ serde_json = "1" toml = "0.8" dirs = "5" -# CLI arguments (CLI only) -clap = { version = "4", features = ["derive", "env"], optional = true } - # Error handling anyhow = "1" thiserror = "2" @@ -61,7 +49,6 @@ urlencoding = "2" base64 = "0.22" sha2 = "0.10" rand = "0.8" -open = { version = "5", optional = true } # Logging tracing = "0.1" @@ -73,14 +60,28 @@ async-stream = "0.3" dotenvy = "0.15.7" typetag = "0.2.21" -# TUI rendering (CLI only) -ratskin = { version = "0.3", optional = true } -textwrap = { version = "0.16.2", optional = true } +# TUI (CLI only) +ratatui = { version = "0.30.0-beta.0", features = ["scrolling-regions"] } +crossterm = { version = "0.28", features = ["event-stream"] } +ratskin = "0.3" +textwrap = "0.16.2" + +# CLI arguments +clap = { version = "4", features = ["derive", "env"] } + +# IDE integration +nvim-rs = { version = "0.9", features = ["use_tokio"] } +open = "5" -# Neovim RPC (CLI only) -nvim-rs = { version = "0.9", features = ["use_tokio"], optional = true } +# Web content extraction +chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false } +readability = "0.3" +htmd = "0.1" -[dev-dependencies] +# WebSocket +tokio-tungstenite = "0.24" + +# Testing tokio-test = "0.4" tempfile = "3" pretty_assertions = "1" @@ -92,23 +93,3 @@ codegen-units = 1 panic = "abort" strip = false debug = true - -[features] -default = ["cli"] - -# Full CLI with TUI, IDE integration, and web extraction -cli = [ - "ratatui", "crossterm", "clap", "nvim-rs", "open", - "chromiumoxide", "readability", "htmd", - "ratskin", "textwrap" -] - -# Enable performance profiling with JSON export -# Build with: cargo build --release --features profiling -profiling = ["dep:sysinfo"] - -[dependencies.sysinfo] -version = "0.33" -optional = true -default-features = false -features = ["system"] diff --git a/Makefile b/Makefile index 06fbda7..0ac4801 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run release profile clean patch +.PHONY: build build-cli build-server run run-server release profile clean patch # Set to 0 to use upstream crates: make SIMD=0 build SIMD ?= 1 @@ -9,17 +9,31 @@ else PATCH_DEPS := lib/genai/.patched .cargo/config.toml endif +# Build all workspace members build: $(PATCH_DEPS) - cargo build + cargo build --workspace +# Build just the CLI +build-cli: $(PATCH_DEPS) + cargo build -p codey --features cli + +# Build just the server +build-server: $(PATCH_DEPS) + cargo build -p codey-server + +# Run the CLI run: $(PATCH_DEPS) - cargo run + cargo run -p codey --features cli + +# Run the server +run-server: $(PATCH_DEPS) + cargo run -p codey-server release: $(PATCH_DEPS) ifdef CARGO_BUILD_TARGET - cargo build --release --target $(CARGO_BUILD_TARGET) + cargo build --workspace --release --target $(CARGO_BUILD_TARGET) else - cargo build --release + cargo build --workspace --release endif profile: release diff --git a/crates/codey-server/Cargo.toml b/crates/codey-server/Cargo.toml new file mode 100644 index 0000000..900abba --- /dev/null +++ b/crates/codey-server/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "codey-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "WebSocket server for the Codey AI coding assistant" + +[[bin]] +name = "codey-server" +path = "src/main.rs" + +[dependencies] +# Internal crates +codey = { workspace = true, features = ["cli"] } + +# Async runtime +tokio.workspace = true + +# WebSocket +tokio-tungstenite.workspace = true +futures.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# CLI arguments +clap.workspace = true + +# Error handling +anyhow.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Utilities +uuid.workspace = true +dirs.workspace = true +dotenvy.workspace = true diff --git a/crates/codey-server/src/main.rs b/crates/codey-server/src/main.rs new file mode 100644 index 0000000..9d04887 --- /dev/null +++ b/crates/codey-server/src/main.rs @@ -0,0 +1,85 @@ +//! Codey WebSocket Server +//! +//! A WebSocket server that exposes the Codey agent for remote access. + +mod protocol; +mod server; +mod session; + +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +use codey::AgentRuntimeConfig; +use server::{Server, ServerConfig}; + +/// Codey WebSocket Server +#[derive(Parser, Debug)] +#[command(name = "codey-server")] +#[command(author, version, about = "WebSocket server for the Codey AI coding assistant")] +struct Args { + /// Address to listen on + #[arg(short, long, default_value = "127.0.0.1:9999")] + listen: SocketAddr, + + /// Path to system prompt file (optional) + #[arg(short, long)] + system_prompt: Option, + + /// Model to use + #[arg(short, long, default_value = "claude-sonnet-4-20250514")] + model: String, + + /// Log file path + #[arg(long, default_value = "/tmp/codey-server.log")] + log_file: PathBuf, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + // Set up file-based logging + let log_file = std::fs::File::create(&args.log_file)?; + tracing_subscriber::registry() + .with(EnvFilter::new("info,codey=debug")) + .with(fmt::layer().with_writer(log_file).with_ansi(false)) + .init(); + + // Load .env files + let _ = dotenvy::from_filename(".env"); + if let Some(home) = dirs::home_dir() { + let _ = dotenvy::from_path(home.join(".env")); + } + + // Load system prompt + let system_prompt = match args.system_prompt { + Some(path) => std::fs::read_to_string(&path)?, + None => "You are a helpful AI coding assistant. Help the user with their programming tasks.".to_string(), + }; + + // Configure agent + let agent_config = AgentRuntimeConfig { + model: args.model, + ..Default::default() + }; + + let config = ServerConfig { + system_prompt, + agent_config, + }; + + // Print startup message + eprintln!("Codey WebSocket Server"); + eprintln!("Listening on: ws://{}", args.listen); + eprintln!("Log file: {}", args.log_file.display()); + eprintln!(); + eprintln!("Press Ctrl+C to stop"); + + // Run server + let server = Server::new(args.listen, config); + server.run().await +} diff --git a/crates/codey-server/src/protocol.rs b/crates/codey-server/src/protocol.rs new file mode 100644 index 0000000..44ba13b --- /dev/null +++ b/crates/codey-server/src/protocol.rs @@ -0,0 +1,193 @@ +//! WebSocket protocol definitions +//! +//! Defines the message types for client-server communication. + +use codey::{AgentStep, ToolCall, ToolEventMessage, Usage}; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Client → Server Messages +// ============================================================================ + +/// Messages sent from client to server +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum ClientMessage { + /// Send a message to the agent + SendMessage { + content: String, + /// Optional: specify agent ID for multi-agent sessions + #[serde(default)] + agent_id: Option, + }, + + /// Approve or deny a pending tool execution + ToolDecision { + call_id: String, + approved: bool, + }, + + /// Cancel current operation (interrupt streaming, cancel tools) + Cancel, + + /// Request conversation history + GetHistory, + + /// Request current session state (for reconnection) + GetState, + + /// Ping to keep connection alive + Ping, +} + +// ============================================================================ +// Server → Client Messages +// ============================================================================ + +/// Messages sent from server to client +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum ServerMessage { + /// Session established + Connected { + session_id: String, + }, + + /// Streaming text from agent + TextDelta { + agent_id: u32, + content: String, + }, + + /// Streaming thinking/reasoning from agent + ThinkingDelta { + agent_id: u32, + content: String, + }, + + /// Agent requesting tool execution + ToolRequest { + agent_id: u32, + calls: Vec, + }, + + /// Tool awaiting user approval (didn't pass auto-approve filters) + ToolAwaitingApproval { + agent_id: u32, + call_id: String, + name: String, + params: serde_json::Value, + background: bool, + }, + + /// Tool execution started (after approval) + ToolStarted { + agent_id: u32, + call_id: String, + name: String, + }, + + /// Streaming output from tool execution + ToolDelta { + agent_id: u32, + call_id: String, + content: String, + }, + + /// Tool execution completed successfully + ToolCompleted { + agent_id: u32, + call_id: String, + content: String, + }, + + /// Tool execution failed or was denied + ToolError { + agent_id: u32, + call_id: String, + error: String, + }, + + /// Agent finished processing (turn complete) + Finished { + agent_id: u32, + usage: Usage, + }, + + /// Agent is retrying after transient error + Retrying { + agent_id: u32, + attempt: u32, + error: String, + }, + + /// Conversation history (response to GetHistory) + History { + messages: Vec, + }, + + /// Session state (response to GetState) + State { + agents: Vec, + pending_approvals: Vec, + }, + + /// Pong response to Ping + Pong, + + /// Error occurred + Error { + message: String, + /// If true, the session is no longer usable + fatal: bool, + }, +} + +// ============================================================================ +// Supporting Types +// ============================================================================ + +/// Tool call information for protocol +#[derive(Debug, Clone, Serialize)] +pub struct ToolCallInfo { + pub call_id: String, + pub name: String, + pub params: serde_json::Value, + pub background: bool, +} + +impl From<&ToolCall> for ToolCallInfo { + fn from(tc: &ToolCall) -> Self { + Self { + call_id: tc.call_id.clone(), + name: tc.name.clone(), + params: tc.params.clone(), + background: tc.background, + } + } +} + +/// History message for protocol +#[derive(Debug, Clone, Serialize)] +pub struct HistoryMessage { + pub role: String, + pub content: String, + pub timestamp: Option, +} + +/// Agent info for state response +#[derive(Debug, Clone, Serialize)] +pub struct AgentInfo { + pub id: u32, + pub name: Option, + pub is_streaming: bool, +} + +/// Pending approval for state response +#[derive(Debug, Clone, Serialize)] +pub struct PendingApproval { + pub agent_id: u32, + pub call_id: String, + pub name: String, + pub params: serde_json::Value, +} diff --git a/crates/codey-server/src/server.rs b/crates/codey-server/src/server.rs new file mode 100644 index 0000000..409b4c5 --- /dev/null +++ b/crates/codey-server/src/server.rs @@ -0,0 +1,149 @@ +//! WebSocket server implementation +//! +//! Accepts WebSocket connections and spawns sessions for each. + +use std::net::SocketAddr; + +use anyhow::Result; +use futures::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use codey::AgentRuntimeConfig; + +use crate::protocol::{ClientMessage, ServerMessage}; +use crate::session::Session; + +/// Server configuration +#[derive(Clone)] +pub struct ServerConfig { + /// System prompt for agents + pub system_prompt: String, + /// Agent runtime configuration + pub agent_config: AgentRuntimeConfig, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + system_prompt: "You are a helpful AI coding assistant.".to_string(), + agent_config: AgentRuntimeConfig::default(), + } + } +} + +/// WebSocket server +pub struct Server { + addr: SocketAddr, + config: ServerConfig, +} + +impl Server { + /// Create a new server + pub fn new(addr: SocketAddr, config: ServerConfig) -> Self { + Self { addr, config } + } + + /// Run the server + pub async fn run(&self) -> Result<()> { + let listener = TcpListener::bind(&self.addr).await?; + tracing::info!("WebSocket server listening on {}", self.addr); + + while let Ok((stream, peer_addr)) = listener.accept().await { + tracing::info!("New connection from {}", peer_addr); + let config = self.config.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, config).await { + tracing::error!("Connection error from {}: {}", peer_addr, e); + } + tracing::info!("Connection closed: {}", peer_addr); + }); + } + + Ok(()) + } +} + +/// Handle a single WebSocket connection +async fn handle_connection(stream: TcpStream, config: ServerConfig) -> Result<()> { + let ws_stream = accept_async(stream).await?; + let (mut ws_sink, mut ws_source) = ws_stream.split(); + + // Channels for session <-> WebSocket communication + let (tx_to_ws, mut rx_to_ws) = mpsc::unbounded_channel::(); + let (tx_from_ws, rx_from_ws) = mpsc::unbounded_channel::(); + + // Spawn WebSocket writer task + let writer_handle = tokio::spawn(async move { + while let Some(msg) = rx_to_ws.recv().await { + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(e) => { + tracing::error!("Failed to serialize message: {}", e); + continue; + } + }; + if let Err(e) = ws_sink.send(Message::Text(json.into())).await { + tracing::error!("Failed to send WebSocket message: {}", e); + break; + } + } + }); + + // Spawn WebSocket reader task + let reader_tx = tx_from_ws.clone(); + let reader_handle = tokio::spawn(async move { + while let Some(msg) = ws_source.next().await { + match msg { + Ok(Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(client_msg) => { + if reader_tx.send(client_msg).is_err() { + break; + } + } + Err(e) => { + tracing::warn!("Failed to parse client message: {}", e); + } + } + } + Ok(Message::Close(_)) => { + tracing::debug!("Client sent close frame"); + break; + } + Ok(Message::Ping(data)) => { + tracing::trace!("Received ping"); + // Pong is automatically sent by tungstenite + let _ = data; + } + Ok(_) => { + // Ignore binary, pong, etc. + } + Err(e) => { + tracing::error!("WebSocket error: {}", e); + break; + } + } + } + }); + + // Create and run session + let mut session = Session::new( + config.agent_config, + &config.system_prompt, + tx_to_ws, + rx_from_ws, + ); + + tracing::info!("Session {} started", session.id()); + let result = session.run().await; + tracing::info!("Session {} ended", session.id()); + + // Clean up + writer_handle.abort(); + reader_handle.abort(); + + result +} diff --git a/crates/codey-server/src/session.rs b/crates/codey-server/src/session.rs new file mode 100644 index 0000000..00a24be --- /dev/null +++ b/crates/codey-server/src/session.rs @@ -0,0 +1,243 @@ +//! Per-connection session management +//! +//! Each WebSocket connection gets its own Session with an Agent and ToolExecutor. + +use std::collections::HashMap; + +use anyhow::Result; +use tokio::sync::{mpsc, oneshot}; + +use codey::{ + Agent, AgentRuntimeConfig, AgentStep, RequestMode, + ToolCall, ToolDecision, ToolRegistry, +}; + +use crate::protocol::{ClientMessage, ServerMessage, ToolCallInfo}; + +/// Per-connection session state +pub struct Session { + /// Unique session identifier + id: String, + + /// The primary agent + agent: Agent, + + /// Tool registry (for now we use an empty one - tools execute server-side in full impl) + #[allow(dead_code)] + tools: ToolRegistry, + + /// Pending approvals: call_id -> responder channel + pending_approvals: HashMap>, + + /// Channel to send messages to WebSocket writer task + ws_tx: mpsc::UnboundedSender, + + /// Channel to receive messages from WebSocket reader task + ws_rx: mpsc::UnboundedReceiver, +} + +impl Session { + /// Create a new session with the given WebSocket channels + pub fn new( + config: AgentRuntimeConfig, + system_prompt: &str, + ws_tx: mpsc::UnboundedSender, + ws_rx: mpsc::UnboundedReceiver, + ) -> Self { + // For the initial implementation, we use an empty tool registry + // and handle tool execution via AgentStep::ToolRequest + let tools = ToolRegistry::empty(); + let agent = Agent::new(config, system_prompt, None, tools.clone()); + + Self { + id: uuid::Uuid::new_v4().to_string(), + agent, + tools, + pending_approvals: HashMap::new(), + ws_tx, + ws_rx, + } + } + + /// Get the session ID + pub fn id(&self) -> &str { + &self.id + } + + /// Main event loop - mirrors app.rs structure + pub async fn run(&mut self) -> Result<()> { + // Send connected message + self.send(ServerMessage::Connected { + session_id: self.id.clone(), + })?; + + loop { + tokio::select! { + // Priority 1: WebSocket messages from client + msg = self.ws_rx.recv() => { + match msg { + Some(msg) => { + if self.handle_client_message(msg).await? { + break; // Client requested disconnect + } + } + None => { + // Channel closed - client disconnected + tracing::info!("Session {}: client disconnected", self.id); + break; + } + } + } + + // Priority 2: Agent steps (streaming, tool requests) + step = self.agent.next() => { + if let Some(step) = step { + self.handle_agent_step(step).await?; + } + } + } + } + + Ok(()) + } + + /// Send a message to the WebSocket + fn send(&self, msg: ServerMessage) -> Result<()> { + self.ws_tx + .send(msg) + .map_err(|_| anyhow::anyhow!("WebSocket channel closed")) + } + + /// Handle a message from the client + /// Returns true if the session should end + async fn handle_client_message(&mut self, msg: ClientMessage) -> Result { + match msg { + ClientMessage::SendMessage { content, .. } => { + tracing::debug!("Session {}: received message: {}", self.id, content); + self.agent.send_request(&content, RequestMode::Normal); + } + + ClientMessage::ToolDecision { call_id, approved } => { + tracing::debug!( + "Session {}: tool decision for {}: {}", + self.id, + call_id, + if approved { "approved" } else { "denied" } + ); + + if let Some(responder) = self.pending_approvals.remove(&call_id) { + let decision = if approved { + ToolDecision::Approve + } else { + ToolDecision::Deny + }; + let _ = responder.send(decision); + } + } + + ClientMessage::Cancel => { + tracing::debug!("Session {}: cancel requested", self.id); + self.agent.cancel(); + } + + ClientMessage::GetHistory => { + // TODO: implement history retrieval from transcript + self.send(ServerMessage::History { messages: vec![] })?; + } + + ClientMessage::GetState => { + // TODO: implement full state retrieval + let pending: Vec<_> = self.pending_approvals + .keys() + .map(|call_id| crate::protocol::PendingApproval { + agent_id: 0, + call_id: call_id.clone(), + name: String::new(), + params: serde_json::Value::Null, + }) + .collect(); + + self.send(ServerMessage::State { + agents: vec![crate::protocol::AgentInfo { + id: 0, + name: None, + is_streaming: false, // TODO: track this + }], + pending_approvals: pending, + })?; + } + + ClientMessage::Ping => { + self.send(ServerMessage::Pong)?; + } + } + + Ok(false) + } + + /// Handle an agent step (streaming output, tool requests, etc.) + async fn handle_agent_step(&mut self, step: AgentStep) -> Result<()> { + let agent_id = 0; // Primary agent + + match step { + AgentStep::TextDelta(content) => { + self.send(ServerMessage::TextDelta { agent_id, content })?; + } + + AgentStep::ThinkingDelta(content) => { + self.send(ServerMessage::ThinkingDelta { agent_id, content })?; + } + + AgentStep::CompactionDelta(_) => { + // Compaction is internal, don't expose to clients for now + } + + AgentStep::ToolRequest(calls) => { + // Send tool request notification + let call_infos: Vec = calls.iter().map(ToolCallInfo::from).collect(); + + self.send(ServerMessage::ToolRequest { + agent_id, + calls: call_infos.clone(), + })?; + + // For this initial implementation, we send tools as awaiting approval + // and let the client decide. In the full implementation with ToolExecutor, + // we'd check auto-approve filters first. + for call in &calls { + self.send(ServerMessage::ToolAwaitingApproval { + agent_id, + call_id: call.call_id.clone(), + name: call.name.clone(), + params: call.params.clone(), + background: call.background, + })?; + } + + // TODO: In full implementation, enqueue to ToolExecutor + // For now, client must handle tool execution and send results + } + + AgentStep::Finished { usage } => { + self.send(ServerMessage::Finished { agent_id, usage })?; + } + + AgentStep::Retrying { attempt, error } => { + self.send(ServerMessage::Retrying { + agent_id, + attempt, + error, + })?; + } + + AgentStep::Error(message) => { + self.send(ServerMessage::Error { + message, + fatal: false, + })?; + } + } + + Ok(()) + } +} diff --git a/crates/codey/Cargo.toml b/crates/codey/Cargo.toml new file mode 100644 index 0000000..32830a5 --- /dev/null +++ b/crates/codey/Cargo.toml @@ -0,0 +1,96 @@ +[package] +name = "codey" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Core library for the Codey AI coding assistant" + +[lib] +name = "codey" +path = "src/lib.rs" + +[[bin]] +name = "codey" +path = "src/main.rs" +required-features = ["cli"] + +[dependencies] +# Async runtime +tokio.workspace = true +tokio-stream.workspace = true + +# LLM client +genai.workspace = true + +# HTTP client +reqwest.workspace = true +futures.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Config +toml.workspace = true +dirs.workspace = true + +# Error handling +anyhow.workspace = true +thiserror.workspace = true + +# Utilities +fancy-regex.workspace = true +unicode-width.workspace = true +chrono.workspace = true +uuid.workspace = true +url.workspace = true +urlencoding.workspace = true +base64.workspace = true +sha2.workspace = true +rand.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Async utilities +async-trait.workspace = true +async-stream.workspace = true +dotenvy.workspace = true +typetag.workspace = true + +# TUI (optional - for CLI) +ratatui = { workspace = true, optional = true } +crossterm = { workspace = true, optional = true } +ratskin = { workspace = true, optional = true } +textwrap = { workspace = true, optional = true } + +# CLI arguments (optional) +clap = { workspace = true, optional = true } + +# IDE integration (optional) +nvim-rs = { workspace = true, optional = true } +open = { workspace = true, optional = true } + +# Web content extraction (optional) +chromiumoxide = { workspace = true, optional = true } +readability = { workspace = true, optional = true } +htmd = { workspace = true, optional = true } + +[dev-dependencies] +tokio-test.workspace = true +tempfile.workspace = true +pretty_assertions.workspace = true +insta.workspace = true + +[features] +default = [] + +# Full CLI with TUI, tools, IDE integration, and web extraction +cli = [ + "ratatui", "crossterm", "clap", "nvim-rs", "open", + "chromiumoxide", "readability", "htmd", + "ratskin", "textwrap" +] diff --git a/src/app.rs b/crates/codey/src/app.rs similarity index 100% rename from src/app.rs rename to crates/codey/src/app.rs diff --git a/src/auth.rs b/crates/codey/src/auth.rs similarity index 100% rename from src/auth.rs rename to crates/codey/src/auth.rs diff --git a/src/commands.rs b/crates/codey/src/commands.rs similarity index 100% rename from src/commands.rs rename to crates/codey/src/commands.rs diff --git a/src/compaction.rs b/crates/codey/src/compaction.rs similarity index 100% rename from src/compaction.rs rename to crates/codey/src/compaction.rs diff --git a/src/config.rs b/crates/codey/src/config.rs similarity index 100% rename from src/config.rs rename to crates/codey/src/config.rs diff --git a/src/ide/mod.rs b/crates/codey/src/ide/mod.rs similarity index 100% rename from src/ide/mod.rs rename to crates/codey/src/ide/mod.rs diff --git a/src/ide/nvim/lua/close_preview.lua b/crates/codey/src/ide/nvim/lua/close_preview.lua similarity index 100% rename from src/ide/nvim/lua/close_preview.lua rename to crates/codey/src/ide/nvim/lua/close_preview.lua diff --git a/src/ide/nvim/lua/has_unsaved_changes.lua b/crates/codey/src/ide/nvim/lua/has_unsaved_changes.lua similarity index 100% rename from src/ide/nvim/lua/has_unsaved_changes.lua rename to crates/codey/src/ide/nvim/lua/has_unsaved_changes.lua diff --git a/src/ide/nvim/lua/navigate_to.lua b/crates/codey/src/ide/nvim/lua/navigate_to.lua similarity index 100% rename from src/ide/nvim/lua/navigate_to.lua rename to crates/codey/src/ide/nvim/lua/navigate_to.lua diff --git a/src/ide/nvim/lua/reload_buffer.lua b/crates/codey/src/ide/nvim/lua/reload_buffer.lua similarity index 100% rename from src/ide/nvim/lua/reload_buffer.lua rename to crates/codey/src/ide/nvim/lua/reload_buffer.lua diff --git a/src/ide/nvim/lua/selection_tracking.lua b/crates/codey/src/ide/nvim/lua/selection_tracking.lua similarity index 100% rename from src/ide/nvim/lua/selection_tracking.lua rename to crates/codey/src/ide/nvim/lua/selection_tracking.lua diff --git a/src/ide/nvim/lua/show_diff.lua b/crates/codey/src/ide/nvim/lua/show_diff.lua similarity index 100% rename from src/ide/nvim/lua/show_diff.lua rename to crates/codey/src/ide/nvim/lua/show_diff.lua diff --git a/src/ide/nvim/lua/show_file_preview.lua b/crates/codey/src/ide/nvim/lua/show_file_preview.lua similarity index 100% rename from src/ide/nvim/lua/show_file_preview.lua rename to crates/codey/src/ide/nvim/lua/show_file_preview.lua diff --git a/src/ide/nvim/mod.rs b/crates/codey/src/ide/nvim/mod.rs similarity index 100% rename from src/ide/nvim/mod.rs rename to crates/codey/src/ide/nvim/mod.rs diff --git a/src/lib.rs b/crates/codey/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/codey/src/lib.rs diff --git a/src/llm/agent.rs b/crates/codey/src/llm/agent.rs similarity index 100% rename from src/llm/agent.rs rename to crates/codey/src/llm/agent.rs diff --git a/src/llm/background.rs b/crates/codey/src/llm/background.rs similarity index 100% rename from src/llm/background.rs rename to crates/codey/src/llm/background.rs diff --git a/src/llm/mod.rs b/crates/codey/src/llm/mod.rs similarity index 100% rename from src/llm/mod.rs rename to crates/codey/src/llm/mod.rs diff --git a/src/llm/registry.rs b/crates/codey/src/llm/registry.rs similarity index 100% rename from src/llm/registry.rs rename to crates/codey/src/llm/registry.rs diff --git a/src/main.rs b/crates/codey/src/main.rs similarity index 100% rename from src/main.rs rename to crates/codey/src/main.rs diff --git a/src/profiler.rs b/crates/codey/src/profiler.rs similarity index 100% rename from src/profiler.rs rename to crates/codey/src/profiler.rs diff --git a/src/prompts.rs b/crates/codey/src/prompts.rs similarity index 100% rename from src/prompts.rs rename to crates/codey/src/prompts.rs diff --git a/src/tool_filter.rs b/crates/codey/src/tool_filter.rs similarity index 100% rename from src/tool_filter.rs rename to crates/codey/src/tool_filter.rs diff --git a/src/tools/README.md b/crates/codey/src/tools/README.md similarity index 100% rename from src/tools/README.md rename to crates/codey/src/tools/README.md diff --git a/src/tools/exec.rs b/crates/codey/src/tools/exec.rs similarity index 100% rename from src/tools/exec.rs rename to crates/codey/src/tools/exec.rs diff --git a/src/tools/handlers.rs b/crates/codey/src/tools/handlers.rs similarity index 100% rename from src/tools/handlers.rs rename to crates/codey/src/tools/handlers.rs diff --git a/src/tools/impls/background_tasks.rs b/crates/codey/src/tools/impls/background_tasks.rs similarity index 100% rename from src/tools/impls/background_tasks.rs rename to crates/codey/src/tools/impls/background_tasks.rs diff --git a/src/tools/impls/edit_file.rs b/crates/codey/src/tools/impls/edit_file.rs similarity index 100% rename from src/tools/impls/edit_file.rs rename to crates/codey/src/tools/impls/edit_file.rs diff --git a/src/tools/impls/fetch_html.rs b/crates/codey/src/tools/impls/fetch_html.rs similarity index 100% rename from src/tools/impls/fetch_html.rs rename to crates/codey/src/tools/impls/fetch_html.rs diff --git a/src/tools/impls/fetch_url.rs b/crates/codey/src/tools/impls/fetch_url.rs similarity index 100% rename from src/tools/impls/fetch_url.rs rename to crates/codey/src/tools/impls/fetch_url.rs diff --git a/src/tools/impls/mod.rs b/crates/codey/src/tools/impls/mod.rs similarity index 100% rename from src/tools/impls/mod.rs rename to crates/codey/src/tools/impls/mod.rs diff --git a/src/tools/impls/open_file.rs b/crates/codey/src/tools/impls/open_file.rs similarity index 100% rename from src/tools/impls/open_file.rs rename to crates/codey/src/tools/impls/open_file.rs diff --git a/src/tools/impls/read_file.rs b/crates/codey/src/tools/impls/read_file.rs similarity index 100% rename from src/tools/impls/read_file.rs rename to crates/codey/src/tools/impls/read_file.rs diff --git a/src/tools/impls/shell.rs b/crates/codey/src/tools/impls/shell.rs similarity index 100% rename from src/tools/impls/shell.rs rename to crates/codey/src/tools/impls/shell.rs diff --git a/src/tools/impls/spawn_agent.rs b/crates/codey/src/tools/impls/spawn_agent.rs similarity index 100% rename from src/tools/impls/spawn_agent.rs rename to crates/codey/src/tools/impls/spawn_agent.rs diff --git a/src/tools/impls/web_search.rs b/crates/codey/src/tools/impls/web_search.rs similarity index 100% rename from src/tools/impls/web_search.rs rename to crates/codey/src/tools/impls/web_search.rs diff --git a/src/tools/impls/write_file.rs b/crates/codey/src/tools/impls/write_file.rs similarity index 100% rename from src/tools/impls/write_file.rs rename to crates/codey/src/tools/impls/write_file.rs diff --git a/src/tools/io.rs b/crates/codey/src/tools/io.rs similarity index 100% rename from src/tools/io.rs rename to crates/codey/src/tools/io.rs diff --git a/src/tools/mod.rs b/crates/codey/src/tools/mod.rs similarity index 100% rename from src/tools/mod.rs rename to crates/codey/src/tools/mod.rs diff --git a/src/tools/pipeline.rs b/crates/codey/src/tools/pipeline.rs similarity index 100% rename from src/tools/pipeline.rs rename to crates/codey/src/tools/pipeline.rs diff --git a/src/transcript.rs b/crates/codey/src/transcript.rs similarity index 100% rename from src/transcript.rs rename to crates/codey/src/transcript.rs diff --git a/src/ui/chat.rs b/crates/codey/src/ui/chat.rs similarity index 100% rename from src/ui/chat.rs rename to crates/codey/src/ui/chat.rs diff --git a/src/ui/input.rs b/crates/codey/src/ui/input.rs similarity index 100% rename from src/ui/input.rs rename to crates/codey/src/ui/input.rs diff --git a/src/ui/input_tests.rs b/crates/codey/src/ui/input_tests.rs similarity index 100% rename from src/ui/input_tests.rs rename to crates/codey/src/ui/input_tests.rs diff --git a/src/ui/mod.rs b/crates/codey/src/ui/mod.rs similarity index 100% rename from src/ui/mod.rs rename to crates/codey/src/ui/mod.rs diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_after_complex_edits.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_after_complex_edits.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_after_complex_edits.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_after_complex_edits.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_empty_input.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_empty_input.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_empty_input.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_empty_input.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_multiline.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_multiline.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_multiline.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_multiline.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_special_chars.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_special_chars.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_special_chars.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_special_chars.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_with_ide_selection.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_ide_selection.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_with_ide_selection.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_ide_selection.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_with_pasted_attachment.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_pasted_attachment.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_with_pasted_attachment.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_pasted_attachment.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_with_text.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_text.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_with_text.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_with_text.snap diff --git a/src/ui/snapshots/codey__ui__input__tests__snapshot_wrapped_long_text.snap b/crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_wrapped_long_text.snap similarity index 100% rename from src/ui/snapshots/codey__ui__input__tests__snapshot_wrapped_long_text.snap rename to crates/codey/src/ui/snapshots/codey__ui__input__tests__snapshot_wrapped_long_text.snap From a3efc9ec8c7632a4cec6fa0f326f7a5d6ce9293b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:57:48 +0000 Subject: [PATCH 4/6] Update research doc with implementation status --- research/websocket-server-module.md | 114 ++++++++++++++-------------- 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/research/websocket-server-module.md b/research/websocket-server-module.md index aeae046..85ec44a 100644 --- a/research/websocket-server-module.md +++ b/research/websocket-server-module.md @@ -1,5 +1,33 @@ # WebSocket Server Module +## Current Status + +**Phase 1: Serialization** ✅ Complete +- Added `Serialize`/`Deserialize` to `AgentStep`, `Usage`, `RequestMode`, `ToolCall`, `ToolDecision`, `Effect` +- Added `ToolEventMessage` with `to_message()` conversion from `ToolEvent` +- Exported new types from `lib.rs` public API + +**Phase 2: Workspace Restructure** ✅ Complete (simplified) +- Created workspace with `crates/codey` and `crates/codey-server` +- Kept tool implementations in `codey` with `cli` feature (simpler than separate `codey-tools` crate) +- `codey` crate produces both library and `codey` binary +- `codey-server` depends on `codey` with `cli` feature for full tool access + +**Phase 3: codey-server Skeleton** ✅ Complete +- `protocol.rs`: `ClientMessage` and `ServerMessage` enums +- `session.rs`: Per-connection session with event loop +- `server.rs`: WebSocket listener and connection handling +- `main.rs`: CLI entry point + +**Phase 4: Full ToolExecutor Integration** 🔲 Planned +- Currently tools are sent as `ToolAwaitingApproval` for client to handle +- TODO: Integrate `ToolExecutor` for server-side execution +- TODO: Add auto-approve filter support + +**Phase 5: Integration Testing** 🔲 Planned +- TODO: Add test client +- TODO: End-to-end tests + ## Overview This document outlines the plan to add a WebSocket server module (`codey-server`) that exposes the full agent interaction over WebSocket, enabling automation and integration with external clients while keeping the core CLI unaltered. @@ -14,82 +42,50 @@ This document outlines the plan to add a WebSocket server module (`codey-server` ## Architecture -### Workspace Structure +### Workspace Structure (Actual Implementation) ``` codey/ -├── Cargo.toml # workspace root +├── Cargo.toml # workspace root with shared deps ├── crates/ -│ ├── codey/ # core library (agent, executor, config) -│ │ ├── Cargo.toml +│ ├── codey/ # core library + CLI binary +│ │ ├── Cargo.toml # has 'cli' feature for TUI/tools │ │ └── src/ │ │ ├── lib.rs # public API exports -│ │ ├── auth.rs # OAuth handling -│ │ ├── config.rs # AgentRuntimeConfig -│ │ ├── llm/ -│ │ │ ├── mod.rs -│ │ │ ├── agent.rs # Agent, AgentStep, Usage -│ │ │ ├── registry.rs # AgentRegistry (multi-agent) -│ │ │ └── background.rs # Background task coordination -│ │ ├── tools/ -│ │ │ ├── mod.rs # SimpleTool, ToolRegistry, re-exports -│ │ │ ├── exec.rs # ToolExecutor, ToolCall, ToolEvent, ToolEventMessage -│ │ │ ├── pipeline.rs # Effect, Step, ToolPipeline -│ │ │ └── io.rs # I/O helpers -│ │ ├── tool_filter.rs # Auto-approval filters -│ │ ├── transcript.rs # Conversation persistence -│ │ └── prompts.rs # System prompts -│ │ -│ ├── codey-tools/ # tool implementations -│ │ ├── Cargo.toml # depends on codey -│ │ └── src/ -│ │ ├── lib.rs # ToolSet::full(), re-exports -│ │ ├── read_file.rs -│ │ ├── write_file.rs -│ │ ├── edit_file.rs -│ │ ├── shell.rs -│ │ ├── fetch_url.rs -│ │ ├── fetch_html.rs # optional: requires chromiumoxide -│ │ ├── web_search.rs -│ │ ├── open_file.rs -│ │ ├── spawn_agent.rs -│ │ └── background_tasks.rs -│ │ -│ ├── codey-cli/ # TUI binary (existing CLI) -│ │ ├── Cargo.toml # depends on codey + codey-tools + ratatui -│ │ └── src/ -│ │ ├── main.rs +│ │ ├── main.rs # CLI entry point (requires cli feature) │ │ ├── app.rs # TUI event loop -│ │ ├── commands.rs # CLI commands -│ │ ├── compaction.rs # Context compaction -│ │ ├── ui/ -│ │ │ ├── mod.rs -│ │ │ ├── chat.rs # ChatView -│ │ │ └── input.rs # InputBox -│ │ ├── ide/ -│ │ │ ├── mod.rs # Ide trait -│ │ │ └── nvim/ # Neovim integration -│ │ └── handlers.rs # Tool approval UI, effect handlers +│ │ ├── ui/ # TUI components +│ │ ├── llm/ # Agent, AgentStep, Usage +│ │ ├── tools/ # ToolExecutor + implementations +│ │ │ ├── exec.rs # ToolExecutor, ToolEvent, ToolEventMessage +│ │ │ ├── handlers.rs # Effect handlers +│ │ │ └── impls/ # Tool implementations +│ │ └── ... │ │ │ └── codey-server/ # WebSocket server binary -│ ├── Cargo.toml # depends on codey + codey-tools + tokio-tungstenite +│ ├── Cargo.toml # depends on codey with cli feature │ └── src/ -│ ├── main.rs # CLI entry point, daemonization -│ ├── server.rs # WebSocket listener, connection accept -│ ├── session.rs # Per-connection agent session -│ ├── protocol.rs # ClientMessage, ServerMessage -│ └── handlers.rs # Tool approval routing, effect handling +│ ├── main.rs # Server entry point +│ ├── protocol.rs # ClientMessage/ServerMessage +│ ├── server.rs # WebSocket listener +│ └── session.rs # Per-connection session ``` +Note: The original plan included a separate `codey-tools` crate, but the +implementation keeps tools in the `codey` crate behind the `cli` feature +for simplicity. This can be refactored later if needed. + ### Dependency Graph ``` -codey-cli ──────┬──► codey-tools ──► codey (core) - │ -codey-server ───┘ +codey-server ──► codey (with cli feature) + │ + ├── Agent, AgentStep, Usage + ├── ToolExecutor, ToolEvent, ToolEventMessage + └── Tool implementations (read_file, shell, etc.) External clients ──► codey-server (WebSocket) -Library users ──────► codey (core) directly +Library users ──────► codey (no cli feature, just core Agent) ``` ## Implementation Plan From d32596396bf76ab20bc18b903e6f031326cb9438 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 02:11:49 +0000 Subject: [PATCH 5/6] Integrate ToolFilters and ToolExecutor in codey-server (Phase 4) - Export CLI-gated types (Config, ToolFilters, ToolEvent, ToolExecutor) from lib.rs - Load config from ~/.config/codey/config.toml in server main.rs - Compile ToolFilters from config for auto-approve/deny behavior - Session uses Arc shared across connections - ToolExecutor handles server-side tool execution - Tools matching filters auto-approved/denied, others bubble to WebSocket - Add toml dependency to codey-server for config parsing --- Cargo.lock | 1 + crates/codey-server/Cargo.toml | 1 + crates/codey-server/src/main.rs | 60 +++++++--- crates/codey-server/src/server.rs | 10 +- crates/codey-server/src/session.rs | 168 +++++++++++++++++++++++----- crates/codey/src/lib.rs | 8 ++ research/websocket-server-module.md | 9 +- 7 files changed, 211 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e97cfc..a1ace83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,6 +458,7 @@ dependencies = [ "serde_json", "tokio", "tokio-tungstenite", + "toml", "tracing", "tracing-subscriber", "uuid", diff --git a/crates/codey-server/Cargo.toml b/crates/codey-server/Cargo.toml index 900abba..7690b24 100644 --- a/crates/codey-server/Cargo.toml +++ b/crates/codey-server/Cargo.toml @@ -40,3 +40,4 @@ tracing-subscriber.workspace = true uuid.workspace = true dirs.workspace = true dotenvy.workspace = true +toml.workspace = true diff --git a/crates/codey-server/src/main.rs b/crates/codey-server/src/main.rs index 9d04887..253ab4c 100644 --- a/crates/codey-server/src/main.rs +++ b/crates/codey-server/src/main.rs @@ -1,6 +1,7 @@ //! Codey WebSocket Server //! //! A WebSocket server that exposes the Codey agent for remote access. +//! Uses the same config file format as the CLI (~/.config/codey/config.toml). mod protocol; mod server; @@ -8,12 +9,13 @@ mod session; use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -use codey::AgentRuntimeConfig; +use codey::{AgentRuntimeConfig, Config, ToolFilters}; use server::{Server, ServerConfig}; /// Codey WebSocket Server @@ -25,13 +27,17 @@ struct Args { #[arg(short, long, default_value = "127.0.0.1:9999")] listen: SocketAddr, - /// Path to system prompt file (optional) + /// Path to config file (defaults to ~/.config/codey/config.toml) + #[arg(short, long)] + config: Option, + + /// Path to system prompt file (overrides config) #[arg(short, long)] system_prompt: Option, - /// Model to use - #[arg(short, long, default_value = "claude-sonnet-4-20250514")] - model: String, + /// Model to use (overrides config) + #[arg(short, long)] + model: Option, /// Log file path #[arg(long, default_value = "/tmp/codey-server.log")] @@ -55,31 +61,59 @@ async fn main() -> Result<()> { let _ = dotenvy::from_path(home.join(".env")); } - // Load system prompt + // Load configuration + let config = if let Some(path) = &args.config { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + toml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {}", path.display()))? + } else { + Config::load()? + }; + + // Compile tool filters from config + let filters = ToolFilters::compile(&config.tools.filters()) + .context("Failed to compile tool filters")?; + + // Count configured filters for logging + let filter_count = config.tools.filters() + .values() + .filter(|f| !f.allow.is_empty() || !f.deny.is_empty()) + .count(); + + // Load system prompt (CLI arg overrides config) let system_prompt = match args.system_prompt { - Some(path) => std::fs::read_to_string(&path)?, + Some(path) => std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read system prompt: {}", path.display()))?, None => "You are a helpful AI coding assistant. Help the user with their programming tasks.".to_string(), }; - // Configure agent + // Configure agent (CLI arg overrides config) + let model = args.model.unwrap_or_else(|| config.agents.foreground.model.clone()); let agent_config = AgentRuntimeConfig { - model: args.model, - ..Default::default() + model: model.clone(), + max_tokens: config.agents.foreground.max_tokens, + thinking_budget: config.agents.foreground.thinking_budget, + max_retries: config.general.max_retries, + compaction_thinking_budget: config.general.compaction_thinking_budget, }; - let config = ServerConfig { + let server_config = ServerConfig { system_prompt, agent_config, + filters: Arc::new(filters), }; // Print startup message eprintln!("Codey WebSocket Server"); eprintln!("Listening on: ws://{}", args.listen); + eprintln!("Model: {}", model); + eprintln!("Tool filters: {} configured", filter_count); eprintln!("Log file: {}", args.log_file.display()); eprintln!(); eprintln!("Press Ctrl+C to stop"); // Run server - let server = Server::new(args.listen, config); + let server = Server::new(args.listen, server_config); server.run().await } diff --git a/crates/codey-server/src/server.rs b/crates/codey-server/src/server.rs index 409b4c5..65a87b1 100644 --- a/crates/codey-server/src/server.rs +++ b/crates/codey-server/src/server.rs @@ -3,6 +3,7 @@ //! Accepts WebSocket connections and spawns sessions for each. use std::net::SocketAddr; +use std::sync::Arc; use anyhow::Result; use futures::{SinkExt, StreamExt}; @@ -10,7 +11,7 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::mpsc; use tokio_tungstenite::{accept_async, tungstenite::Message}; -use codey::AgentRuntimeConfig; +use codey::{AgentRuntimeConfig, ToolFilters}; use crate::protocol::{ClientMessage, ServerMessage}; use crate::session::Session; @@ -22,6 +23,8 @@ pub struct ServerConfig { pub system_prompt: String, /// Agent runtime configuration pub agent_config: AgentRuntimeConfig, + /// Tool filters for auto-approve/deny (shared across connections) + pub filters: Arc, } impl Default for ServerConfig { @@ -29,6 +32,7 @@ impl Default for ServerConfig { Self { system_prompt: "You are a helpful AI coding assistant.".to_string(), agent_config: AgentRuntimeConfig::default(), + filters: Arc::new(ToolFilters::default()), } } } @@ -129,10 +133,14 @@ async fn handle_connection(stream: TcpStream, config: ServerConfig) -> Result<() } }); + // Pass the shared Arc to the session + let filters = Arc::clone(&config.filters); + // Create and run session let mut session = Session::new( config.agent_config, &config.system_prompt, + filters, tx_to_ws, rx_from_ws, ); diff --git a/crates/codey-server/src/session.rs b/crates/codey-server/src/session.rs index 00a24be..214e1bf 100644 --- a/crates/codey-server/src/session.rs +++ b/crates/codey-server/src/session.rs @@ -3,13 +3,15 @@ //! Each WebSocket connection gets its own Session with an Agent and ToolExecutor. use std::collections::HashMap; +use std::sync::Arc; use anyhow::Result; use tokio::sync::{mpsc, oneshot}; +// These imports require codey's "cli" feature for server-side tool execution use codey::{ Agent, AgentRuntimeConfig, AgentStep, RequestMode, - ToolCall, ToolDecision, ToolRegistry, + ToolDecision, ToolEvent, ToolExecutor, ToolFilters, ToolRegistry, }; use crate::protocol::{ClientMessage, ServerMessage, ToolCallInfo}; @@ -22,9 +24,11 @@ pub struct Session { /// The primary agent agent: Agent, - /// Tool registry (for now we use an empty one - tools execute server-side in full impl) - #[allow(dead_code)] - tools: ToolRegistry, + /// Tool executor for server-side tool execution + tool_executor: ToolExecutor, + + /// Tool filters for auto-approve/deny (shared across sessions) + filters: Arc, /// Pending approvals: call_id -> responder channel pending_approvals: HashMap>, @@ -39,20 +43,22 @@ pub struct Session { impl Session { /// Create a new session with the given WebSocket channels pub fn new( - config: AgentRuntimeConfig, + agent_config: AgentRuntimeConfig, system_prompt: &str, + filters: Arc, ws_tx: mpsc::UnboundedSender, ws_rx: mpsc::UnboundedReceiver, ) -> Self { - // For the initial implementation, we use an empty tool registry - // and handle tool execution via AgentStep::ToolRequest - let tools = ToolRegistry::empty(); - let agent = Agent::new(config, system_prompt, None, tools.clone()); + // Create tool registry with all available tools + let tools = ToolRegistry::new(); + let tool_executor = ToolExecutor::new(tools.clone()); + let agent = Agent::new(agent_config, system_prompt, None, tools); Self { id: uuid::Uuid::new_v4().to_string(), agent, - tools, + tool_executor, + filters, pending_approvals: HashMap::new(), ws_tx, ws_rx, @@ -95,6 +101,13 @@ impl Session { self.handle_agent_step(step).await?; } } + + // Priority 3: Tool executor events + event = self.tool_executor.next() => { + if let Some(event) = event { + self.handle_tool_event(event).await?; + } + } } } @@ -138,6 +151,7 @@ impl Session { ClientMessage::Cancel => { tracing::debug!("Session {}: cancel requested", self.id); self.agent.cancel(); + self.tool_executor.cancel(); } ClientMessage::GetHistory => { @@ -193,29 +207,15 @@ impl Session { } AgentStep::ToolRequest(calls) => { - // Send tool request notification + // Send tool request notification to client (informational) let call_infos: Vec = calls.iter().map(ToolCallInfo::from).collect(); - self.send(ServerMessage::ToolRequest { agent_id, - calls: call_infos.clone(), + calls: call_infos, })?; - // For this initial implementation, we send tools as awaiting approval - // and let the client decide. In the full implementation with ToolExecutor, - // we'd check auto-approve filters first. - for call in &calls { - self.send(ServerMessage::ToolAwaitingApproval { - agent_id, - call_id: call.call_id.clone(), - name: call.name.clone(), - params: call.params.clone(), - background: call.background, - })?; - } - - // TODO: In full implementation, enqueue to ToolExecutor - // For now, client must handle tool execution and send results + // Enqueue tools to executor - it will emit AwaitingApproval events + self.tool_executor.enqueue(calls); } AgentStep::Finished { usage } => { @@ -240,4 +240,116 @@ impl Session { Ok(()) } + + /// Handle a tool executor event + async fn handle_tool_event(&mut self, event: ToolEvent) -> Result<()> { + match event { + ToolEvent::AwaitingApproval { + agent_id, + call_id, + name, + params, + background, + responder, + } => { + // Check auto-approve/deny filters first + if let Some(decision) = self.filters.evaluate(&name, ¶ms) { + tracing::debug!( + "Session {}: auto-{} tool {} ({})", + self.id, + if decision == ToolDecision::Approve { "approve" } else { "deny" }, + name, + call_id + ); + + // Send notification that tool is starting (if approved) + if decision == ToolDecision::Approve { + self.send(ServerMessage::ToolStarted { + agent_id, + call_id: call_id.clone(), + name: name.clone(), + })?; + } + + let _ = responder.send(decision); + } else { + // No filter match - bubble to WebSocket for client decision + tracing::debug!( + "Session {}: awaiting approval for tool {} ({})", + self.id, + name, + call_id + ); + + self.pending_approvals.insert(call_id.clone(), responder); + self.send(ServerMessage::ToolAwaitingApproval { + agent_id, + call_id, + name, + params, + background, + })?; + } + } + + ToolEvent::Delta { agent_id, call_id, content } => { + self.send(ServerMessage::ToolDelta { + agent_id, + call_id, + content, + })?; + } + + ToolEvent::Completed { agent_id, call_id, content } => { + // Submit result back to agent + self.agent.submit_tool_result(&call_id, content.clone()); + + self.send(ServerMessage::ToolCompleted { + agent_id, + call_id, + content, + })?; + } + + ToolEvent::Error { agent_id, call_id, content } => { + // Submit error back to agent + self.agent.submit_tool_result(&call_id, format!("Error: {}", content)); + + self.send(ServerMessage::ToolError { + agent_id, + call_id, + error: content, + })?; + } + + ToolEvent::Delegate { responder, .. } => { + // For now, reject delegated effects (IDE integration, sub-agents) + // These would need special handling over WebSocket + tracing::warn!("Session {}: delegation not supported, rejecting", self.id); + let _ = responder.send(Err("Delegation not supported over WebSocket".to_string())); + } + + ToolEvent::BackgroundStarted { agent_id, call_id, name } => { + self.send(ServerMessage::ToolStarted { + agent_id, + call_id, + name, + })?; + } + + ToolEvent::BackgroundCompleted { agent_id, call_id, .. } => { + // Retrieve result and submit to agent + if let Some((_name, output, _status)) = self.tool_executor.take_result(&call_id) { + self.agent.submit_tool_result(&call_id, output.clone()); + self.send(ServerMessage::ToolCompleted { + agent_id, + call_id, + content: output, + })?; + } + } + } + + Ok(()) + } } diff --git a/crates/codey/src/lib.rs b/crates/codey/src/lib.rs index 990a683..5dfea63 100644 --- a/crates/codey/src/lib.rs +++ b/crates/codey/src/lib.rs @@ -58,3 +58,11 @@ mod tool_filter; pub use config::AgentRuntimeConfig; pub use llm::{Agent, AgentStep, RequestMode, Usage}; pub use tools::{Effect, SimpleTool, ToolCall, ToolDecision, ToolEventMessage, ToolRegistry}; + +// CLI-only exports (server-side tool execution, config, etc.) +#[cfg(feature = "cli")] +pub use config::Config; +#[cfg(feature = "cli")] +pub use tool_filter::ToolFilters; +#[cfg(feature = "cli")] +pub use tools::{ToolEvent, ToolExecutor}; diff --git a/research/websocket-server-module.md b/research/websocket-server-module.md index 85ec44a..5a6e908 100644 --- a/research/websocket-server-module.md +++ b/research/websocket-server-module.md @@ -19,10 +19,11 @@ - `server.rs`: WebSocket listener and connection handling - `main.rs`: CLI entry point -**Phase 4: Full ToolExecutor Integration** 🔲 Planned -- Currently tools are sent as `ToolAwaitingApproval` for client to handle -- TODO: Integrate `ToolExecutor` for server-side execution -- TODO: Add auto-approve filter support +**Phase 4: Full ToolExecutor Integration** ✅ Complete +- `ToolExecutor` integrated for server-side tool execution +- `ToolFilters` loaded from config for auto-approve/deny +- Config file format shared with CLI (~/.config/codey/config.toml) +- Tools matching filters are auto-approved/denied, others bubble to WebSocket **Phase 5: Integration Testing** 🔲 Planned - TODO: Add test client From 2c69433686bd4b69cbef0bf62341fa67e2c82c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 02:18:45 +0000 Subject: [PATCH 6/6] Add Python WebSocket client with Pydantic schemas - clients/python/codey_client/schemas.py: Pydantic models for all protocol types - clients/python/codey_client/client.py: Async WebSocket client with high-level API - clients/python/examples/basic_chat.py: Interactive chat example - clients/INTEGRATION.md: Protocol documentation for implementing clients The Python client provides: - Full type safety with Pydantic models matching Rust protocol types - Async context manager for easy connection lifecycle - High-level chat() and stream_text() methods - Configurable tool approval (auto-approve, callback, or default deny) --- clients/INTEGRATION.md | 433 ++++++++++++++++++++++++ clients/python/README.md | 137 ++++++++ clients/python/codey_client/__init__.py | 97 ++++++ clients/python/codey_client/client.py | 312 +++++++++++++++++ clients/python/codey_client/schemas.py | 298 ++++++++++++++++ clients/python/examples/basic_chat.py | 137 ++++++++ clients/python/pyproject.toml | 59 ++++ 7 files changed, 1473 insertions(+) create mode 100644 clients/INTEGRATION.md create mode 100644 clients/python/README.md create mode 100644 clients/python/codey_client/__init__.py create mode 100644 clients/python/codey_client/client.py create mode 100644 clients/python/codey_client/schemas.py create mode 100644 clients/python/examples/basic_chat.py create mode 100644 clients/python/pyproject.toml diff --git a/clients/INTEGRATION.md b/clients/INTEGRATION.md new file mode 100644 index 0000000..48ed67f --- /dev/null +++ b/clients/INTEGRATION.md @@ -0,0 +1,433 @@ +# Client Integration Guide + +This document provides guidance for developers implementing codey-server clients in any programming language. + +## Overview + +The codey-server exposes a WebSocket API that allows clients to: +- Send messages to an AI coding assistant +- Receive streaming responses (text, thinking, tool execution) +- Approve or deny tool executions +- Query conversation history and session state + +## Connection + +Connect to the server using a standard WebSocket connection: + +``` +ws://127.0.0.1:9999 +``` + +The default port is `9999` but can be configured via the `--listen` flag when starting the server. + +## Protocol + +All messages are JSON objects with a `type` field that indicates the message type. The protocol uses tagged unions (discriminated unions) for type safety. + +## Message Types + +### Client → Server + +#### SendMessage +Send a message to the agent. + +```json +{ + "type": "SendMessage", + "content": "Hello, how are you?", + "agent_id": null +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"SendMessage"` | Yes | Message type discriminator | +| `content` | `string` | Yes | The message content | +| `agent_id` | `int \| null` | No | Optional agent ID for multi-agent sessions | + +#### ToolDecision +Approve or deny a pending tool execution. + +```json +{ + "type": "ToolDecision", + "call_id": "call_abc123", + "approved": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"ToolDecision"` | Yes | Message type discriminator | +| `call_id` | `string` | Yes | The call_id from ToolAwaitingApproval | +| `approved` | `bool` | Yes | `true` to approve, `false` to deny | + +#### Cancel +Cancel the current operation (interrupt streaming, cancel running tools). + +```json +{ + "type": "Cancel" +} +``` + +#### GetHistory +Request conversation history. + +```json +{ + "type": "GetHistory" +} +``` + +Response will be a `History` message. + +#### GetState +Request current session state (useful for reconnection). + +```json +{ + "type": "GetState" +} +``` + +Response will be a `State` message. + +#### Ping +Keep connection alive. + +```json +{ + "type": "Ping" +} +``` + +Response will be a `Pong` message. + +--- + +### Server → Client + +#### Connected +Sent immediately after connection is established. + +```json +{ + "type": "Connected", + "session_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | `string` | Unique identifier for this session | + +#### TextDelta +Streaming text content from the agent. + +```json +{ + "type": "TextDelta", + "agent_id": 0, + "content": "Hello! I'm doing well, thank you for asking." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | `int` | ID of the agent producing this text | +| `content` | `string` | Text content delta (append to previous) | + +#### ThinkingDelta +Streaming thinking/reasoning from the agent (extended thinking mode). + +```json +{ + "type": "ThinkingDelta", + "agent_id": 0, + "content": "Let me consider the user's question..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | `int` | ID of the agent | +| `content` | `string` | Thinking content delta | + +#### ToolRequest +Agent is requesting tool execution. This is informational; the actual approval flow happens via `ToolAwaitingApproval`. + +```json +{ + "type": "ToolRequest", + "agent_id": 0, + "calls": [ + { + "call_id": "call_abc123", + "name": "Read", + "params": {"file_path": "/path/to/file.txt"}, + "background": false + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | `int` | ID of the agent requesting tools | +| `calls` | `ToolCallInfo[]` | List of tool calls requested | + +#### ToolAwaitingApproval +A tool execution requires user approval (didn't pass server-side auto-approve filters). + +```json +{ + "type": "ToolAwaitingApproval", + "agent_id": 0, + "call_id": "call_abc123", + "name": "Bash", + "params": {"command": "ls -la"}, + "background": false +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | `int` | ID of the agent | +| `call_id` | `string` | Unique identifier for this tool call | +| `name` | `string` | Name of the tool | +| `params` | `object` | Parameters passed to the tool | +| `background` | `bool` | Whether this tool runs in the background | + +**Action Required:** Send a `ToolDecision` message to approve or deny. + +#### ToolStarted +Tool execution has started (after approval or auto-approval). + +```json +{ + "type": "ToolStarted", + "agent_id": 0, + "call_id": "call_abc123", + "name": "Read" +} +``` + +#### ToolDelta +Streaming output from tool execution. + +```json +{ + "type": "ToolDelta", + "agent_id": 0, + "call_id": "call_abc123", + "content": "File contents..." +} +``` + +#### ToolCompleted +Tool execution completed successfully. + +```json +{ + "type": "ToolCompleted", + "agent_id": 0, + "call_id": "call_abc123", + "content": "Full file contents here..." +} +``` + +#### ToolError +Tool execution failed or was denied. + +```json +{ + "type": "ToolError", + "agent_id": 0, + "call_id": "call_abc123", + "error": "File not found" +} +``` + +#### Finished +Agent has finished processing the current turn. + +```json +{ + "type": "Finished", + "agent_id": 0, + "usage": { + "output_tokens": 150, + "context_tokens": 4500, + "cache_creation_tokens": 0, + "cache_read_tokens": 3200 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `usage.output_tokens` | `int` | Cumulative output tokens across session | +| `usage.context_tokens` | `int` | Current context window size | +| `usage.cache_creation_tokens` | `int` | Cache creation tokens in last request | +| `usage.cache_read_tokens` | `int` | Cache read tokens in last request | + +#### Retrying +Agent is retrying after a transient error. + +```json +{ + "type": "Retrying", + "agent_id": 0, + "attempt": 1, + "error": "Rate limit exceeded" +} +``` + +#### History +Response to `GetHistory` request. + +```json +{ + "type": "History", + "messages": [ + { + "role": "user", + "content": "Hello!", + "timestamp": "2024-01-15T10:30:00Z" + }, + { + "role": "assistant", + "content": "Hi there!", + "timestamp": "2024-01-15T10:30:01Z" + } + ] +} +``` + +#### State +Response to `GetState` request. + +```json +{ + "type": "State", + "agents": [ + { + "id": 0, + "name": null, + "is_streaming": false + } + ], + "pending_approvals": [ + { + "agent_id": 0, + "call_id": "call_abc123", + "name": "Bash", + "params": {"command": "ls"} + } + ] +} +``` + +#### Pong +Response to `Ping` request. + +```json +{ + "type": "Pong" +} +``` + +#### Error +An error occurred. + +```json +{ + "type": "Error", + "message": "Something went wrong", + "fatal": false +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `message` | `string` | Error description | +| `fatal` | `bool` | If `true`, the session is no longer usable | + +--- + +## Typical Message Flow + +### Simple Chat + +``` +Client Server + | | + |-- SendMessage --------------->| + | | + |<-- TextDelta ----------------| (multiple) + |<-- TextDelta ----------------| + |<-- TextDelta ----------------| + |<-- Finished -----------------| + | | +``` + +### Chat with Tool Execution + +``` +Client Server + | | + |-- SendMessage --------------->| + | | + |<-- TextDelta ----------------| + |<-- ToolRequest --------------| (informational) + |<-- ToolAwaitingApproval -----| (needs decision) + | | + |-- ToolDecision (approve) ---->| + | | + |<-- ToolStarted --------------| + |<-- ToolDelta ----------------| (streaming output) + |<-- ToolCompleted ------------| + |<-- TextDelta ----------------| (agent continues) + |<-- Finished -----------------| + | | +``` + +--- + +## Implementation Checklist + +When implementing a client, ensure you handle: + +- [ ] **Connection lifecycle**: Connect, maintain, reconnect on failure +- [ ] **Message parsing**: Parse JSON with type discriminator +- [ ] **Streaming text**: Accumulate `TextDelta` messages +- [ ] **Tool approval**: Respond to `ToolAwaitingApproval` with `ToolDecision` +- [ ] **Turn completion**: Wait for `Finished` before accepting new input +- [ ] **Error handling**: Handle both `Error` messages and WebSocket errors +- [ ] **Keepalive**: Send `Ping` periodically to maintain connection + +## Reference Implementations + +- **Python**: [`clients/python/`](./python/) - Full async client with Pydantic schemas + +--- + +## Server Configuration + +The server loads configuration from `~/.config/codey/config.toml`. Tool filter rules defined there determine which tools are auto-approved or auto-denied. Tools not matching any filter are sent to the client via `ToolAwaitingApproval`. + +Example filter configuration: +```toml +[tools.Read] +allow = [".*"] # Auto-approve all Read calls + +[tools.Bash] +deny = ["rm -rf.*", "sudo.*"] # Auto-deny dangerous commands +``` + +--- + +## Versioning + +The protocol is versioned with the codey-server. Breaking changes will be noted in release notes. The `Connected` message may include version information in future releases. diff --git a/clients/python/README.md b/clients/python/README.md new file mode 100644 index 0000000..105a13d --- /dev/null +++ b/clients/python/README.md @@ -0,0 +1,137 @@ +# Codey Python Client + +A Python WebSocket client for interacting with `codey-server`. + +## Installation + +```bash +# From this directory +pip install -e . + +# Or with dev dependencies +pip install -e ".[dev]" +``` + +## Quick Start + +```python +import asyncio +from codey_client import connect + +async def main(): + # Connect to codey-server + async with connect("ws://localhost:9999", auto_approve=True) as client: + print(f"Connected: {client.session_id}") + + # Stream a response + async for text in client.stream_text("What is 2 + 2?"): + print(text, end="", flush=True) + print() + +asyncio.run(main()) +``` + +## Usage + +### Basic Chat + +```python +from codey_client import connect, TextDelta, Finished + +async with connect() as client: + async for msg in client.chat("Hello!"): + if isinstance(msg, TextDelta): + print(msg.content, end="") + elif isinstance(msg, Finished): + print(f"\n\nTokens: {msg.usage.output_tokens}") +``` + +### Tool Approval + +By default, tools that don't match server-side filters require approval: + +```python +from codey_client import connect, ToolAwaitingApproval + +def approve_handler(tool: ToolAwaitingApproval) -> bool: + """Custom approval logic.""" + # Approve read-only tools, deny others + return tool.name in ["Read", "Glob", "Grep"] + +async with connect(on_approval_request=approve_handler) as client: + async for msg in client.chat("List files in the current directory"): + ... +``` + +Or auto-approve everything (use with caution): + +```python +async with connect(auto_approve=True) as client: + ... +``` + +### Low-Level API + +```python +client = CodeyClient("ws://localhost:9999") +await client.connect() + +# Send a message +await client.send_message("Hello!") + +# Receive messages manually +while True: + msg = await client.receive(timeout=30.0) + if msg is None: + break + print(msg) + +await client.disconnect() +``` + +## Message Types + +All message types are Pydantic models with full type hints. + +### Client → Server + +- `SendMessage` - Send a message to the agent +- `ToolDecision` - Approve or deny a tool +- `Cancel` - Cancel current operation +- `GetHistory` - Request conversation history +- `GetState` - Request session state +- `Ping` - Keep connection alive + +### Server → Client + +- `Connected` - Session established +- `TextDelta` - Streaming text from agent +- `ThinkingDelta` - Streaming thinking/reasoning +- `ToolRequest` - Agent requesting tools +- `ToolAwaitingApproval` - Tool needs approval +- `ToolStarted` - Tool execution started +- `ToolDelta` - Streaming tool output +- `ToolCompleted` - Tool completed +- `ToolError` - Tool failed +- `Finished` - Turn complete with usage stats +- `Retrying` - Agent retrying after error +- `History` - Conversation history +- `State` - Session state +- `Pong` - Response to ping +- `Error` - Error occurred + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run type checking +mypy codey_client + +# Run linting +ruff check codey_client + +# Run tests +pytest +``` diff --git a/clients/python/codey_client/__init__.py b/clients/python/codey_client/__init__.py new file mode 100644 index 0000000..54a86a1 --- /dev/null +++ b/clients/python/codey_client/__init__.py @@ -0,0 +1,97 @@ +""" +Codey Python Client + +A WebSocket client for interacting with codey-server. + +Example: + ```python + import asyncio + from codey_client import connect + + async def main(): + async with connect("ws://localhost:9999", auto_approve=True) as client: + async for text in client.stream_text("Hello!"): + print(text, end="", flush=True) + print() + + asyncio.run(main()) + ``` +""" + +from .client import CodeyClient, connect +from .schemas import ( + # Supporting types + AgentInfo, + HistoryMessage, + PendingApproval, + ToolCallInfo, + Usage, + # Client messages + Cancel, + ClientMessage, + GetHistory, + GetState, + Ping, + SendMessage, + ToolDecision, + # Server messages + Connected, + Error, + Finished, + History, + Pong, + Retrying, + ServerMessage, + State, + TextDelta, + ThinkingDelta, + ToolAwaitingApproval, + ToolCompleted, + ToolDelta, + ToolError, + ToolRequest, + ToolStarted, + # Utilities + parse_server_message, +) + +__version__ = "0.1.0" + +__all__ = [ + # Client + "CodeyClient", + "connect", + # Supporting types + "Usage", + "ToolCallInfo", + "HistoryMessage", + "AgentInfo", + "PendingApproval", + # Client messages + "ClientMessage", + "SendMessage", + "ToolDecision", + "Cancel", + "GetHistory", + "GetState", + "Ping", + # Server messages + "ServerMessage", + "Connected", + "TextDelta", + "ThinkingDelta", + "ToolRequest", + "ToolAwaitingApproval", + "ToolStarted", + "ToolDelta", + "ToolCompleted", + "ToolError", + "Finished", + "Retrying", + "History", + "State", + "Pong", + "Error", + # Utilities + "parse_server_message", +] diff --git a/clients/python/codey_client/client.py b/clients/python/codey_client/client.py new file mode 100644 index 0000000..55e10aa --- /dev/null +++ b/clients/python/codey_client/client.py @@ -0,0 +1,312 @@ +""" +Codey WebSocket client implementation. + +Provides an async client for interacting with the codey-server. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any + +import websockets +from websockets.asyncio.client import ClientConnection + +from .schemas import ( + Cancel, + ClientMessage, + Connected, + Error, + Finished, + GetHistory, + GetState, + Ping, + Pong, + SendMessage, + ServerMessage, + TextDelta, + ThinkingDelta, + ToolAwaitingApproval, + ToolCompleted, + ToolDecision, + ToolDelta, + ToolError, + ToolRequest, + ToolStarted, + parse_server_message, +) + + +class CodeyClient: + """Async WebSocket client for codey-server. + + Example: + ```python + async with CodeyClient("ws://localhost:9999") as client: + async for msg in client.chat("Hello, how are you?"): + if isinstance(msg, TextDelta): + print(msg.content, end="", flush=True) + ``` + """ + + def __init__( + self, + url: str = "ws://127.0.0.1:9999", + auto_approve: bool = False, + on_approval_request: Callable[[ToolAwaitingApproval], bool] | None = None, + ): + """Initialize the client. + + Args: + url: WebSocket URL of the codey-server. + auto_approve: If True, automatically approve all tool requests. + on_approval_request: Callback for tool approval requests. Return True + to approve, False to deny. If not provided and auto_approve is False, + tools will be denied by default. + """ + self.url = url + self.auto_approve = auto_approve + self.on_approval_request = on_approval_request + self._ws: ClientConnection | None = None + self._session_id: str | None = None + self._receive_task: asyncio.Task[None] | None = None + self._message_queue: asyncio.Queue[ServerMessage] = asyncio.Queue() + + @property + def session_id(self) -> str | None: + """The current session ID, or None if not connected.""" + return self._session_id + + @property + def is_connected(self) -> bool: + """Whether the client is currently connected.""" + return self._ws is not None and self._ws.state.name == "OPEN" + + async def connect(self) -> None: + """Connect to the codey-server. + + Raises: + ConnectionError: If connection fails. + """ + try: + self._ws = await websockets.connect(self.url) + except Exception as e: + raise ConnectionError(f"Failed to connect to {self.url}: {e}") from e + + # Start background receive task + self._receive_task = asyncio.create_task(self._receive_loop()) + + # Wait for Connected message + msg = await self._message_queue.get() + if isinstance(msg, Connected): + self._session_id = msg.session_id + elif isinstance(msg, Error): + raise ConnectionError(f"Server error: {msg.message}") + else: + raise ConnectionError(f"Unexpected message: {msg}") + + async def disconnect(self) -> None: + """Disconnect from the server.""" + if self._receive_task: + self._receive_task.cancel() + try: + await self._receive_task + except asyncio.CancelledError: + pass + self._receive_task = None + + if self._ws: + await self._ws.close() + self._ws = None + + self._session_id = None + + async def __aenter__(self) -> CodeyClient: + """Async context manager entry.""" + await self.connect() + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.disconnect() + + async def _receive_loop(self) -> None: + """Background task to receive messages from the server.""" + assert self._ws is not None + try: + async for raw_msg in self._ws: + if isinstance(raw_msg, bytes): + raw_msg = raw_msg.decode("utf-8") + data = json.loads(raw_msg) + msg = parse_server_message(data) + await self._message_queue.put(msg) + except websockets.ConnectionClosed: + # Put an error message to signal connection closed + await self._message_queue.put( + Error(message="Connection closed", fatal=True) + ) + + async def _send(self, msg: ClientMessage) -> None: + """Send a message to the server.""" + if not self._ws: + raise ConnectionError("Not connected") + await self._ws.send(msg.model_dump_json()) + + async def send_message( + self, content: str, agent_id: int | None = None + ) -> None: + """Send a message to the agent. + + Args: + content: The message content. + agent_id: Optional agent ID for multi-agent sessions. + """ + await self._send(SendMessage(content=content, agent_id=agent_id)) + + async def approve_tool(self, call_id: str) -> None: + """Approve a pending tool execution. + + Args: + call_id: The call_id of the tool to approve. + """ + await self._send(ToolDecision(call_id=call_id, approved=True)) + + async def deny_tool(self, call_id: str) -> None: + """Deny a pending tool execution. + + Args: + call_id: The call_id of the tool to deny. + """ + await self._send(ToolDecision(call_id=call_id, approved=False)) + + async def cancel(self) -> None: + """Cancel the current operation.""" + await self._send(Cancel()) + + async def get_history(self) -> None: + """Request conversation history. Response will be in the message stream.""" + await self._send(GetHistory()) + + async def get_state(self) -> None: + """Request current session state. Response will be in the message stream.""" + await self._send(GetState()) + + async def ping(self) -> None: + """Send a ping to keep the connection alive.""" + await self._send(Ping()) + + async def receive(self, timeout: float | None = None) -> ServerMessage | None: + """Receive the next message from the server. + + Args: + timeout: Optional timeout in seconds. If None, wait indefinitely. + + Returns: + The next server message, or None if timeout reached. + """ + try: + if timeout is not None: + return await asyncio.wait_for( + self._message_queue.get(), timeout=timeout + ) + return await self._message_queue.get() + except asyncio.TimeoutError: + return None + + async def chat( + self, message: str, agent_id: int | None = None + ) -> AsyncIterator[ServerMessage]: + """Send a message and yield all responses until the turn is complete. + + This is the main high-level API for chatting with the agent. It handles + tool approval requests based on the client's configuration. + + Args: + message: The message to send. + agent_id: Optional agent ID for multi-agent sessions. + + Yields: + Server messages (TextDelta, ThinkingDelta, ToolStarted, etc.) + until a Finished or fatal Error is received. + """ + await self.send_message(message, agent_id) + + while True: + msg = await self.receive() + if msg is None: + break + + # Handle tool approval requests + if isinstance(msg, ToolAwaitingApproval): + if self.auto_approve: + await self.approve_tool(msg.call_id) + elif self.on_approval_request: + approved = self.on_approval_request(msg) + if approved: + await self.approve_tool(msg.call_id) + else: + await self.deny_tool(msg.call_id) + else: + # Default: deny + await self.deny_tool(msg.call_id) + + yield msg + + # Check for turn completion + if isinstance(msg, Finished): + break + if isinstance(msg, Error) and msg.fatal: + break + + async def stream_text( + self, message: str, agent_id: int | None = None + ) -> AsyncIterator[str]: + """Send a message and yield only the text content. + + This is a convenience method that filters the chat stream to only + yield text deltas, making it easy to print or accumulate the response. + + Args: + message: The message to send. + agent_id: Optional agent ID for multi-agent sessions. + + Yields: + Text content strings from TextDelta messages. + """ + async for msg in self.chat(message, agent_id): + if isinstance(msg, TextDelta): + yield msg.content + + +@asynccontextmanager +async def connect( + url: str = "ws://127.0.0.1:9999", + auto_approve: bool = False, + on_approval_request: Callable[[ToolAwaitingApproval], bool] | None = None, +) -> AsyncIterator[CodeyClient]: + """Connect to codey-server as an async context manager. + + Example: + ```python + async with connect("ws://localhost:9999", auto_approve=True) as client: + async for text in client.stream_text("What files are in the current directory?"): + print(text, end="", flush=True) + ``` + + Args: + url: WebSocket URL of the codey-server. + auto_approve: If True, automatically approve all tool requests. + on_approval_request: Callback for tool approval requests. + + Yields: + A connected CodeyClient instance. + """ + client = CodeyClient(url, auto_approve, on_approval_request) + await client.connect() + try: + yield client + finally: + await client.disconnect() diff --git a/clients/python/codey_client/schemas.py b/clients/python/codey_client/schemas.py new file mode 100644 index 0000000..32310bc --- /dev/null +++ b/clients/python/codey_client/schemas.py @@ -0,0 +1,298 @@ +""" +Pydantic schemas for the Codey WebSocket protocol. + +These schemas mirror the Rust types defined in crates/codey-server/src/protocol.rs +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +# ============================================================================ +# Supporting Types +# ============================================================================ + + +class Usage(BaseModel): + """Token usage statistics from the agent.""" + + output_tokens: int = Field(description="Cumulative output tokens across the session") + context_tokens: int = Field(description="Current context window size") + cache_creation_tokens: int = Field(description="Cache creation tokens in last request") + cache_read_tokens: int = Field(description="Cache read tokens in last request") + + +class ToolCallInfo(BaseModel): + """Information about a tool call.""" + + call_id: str = Field(description="Unique identifier for this tool call") + name: str = Field(description="Name of the tool being called") + params: dict[str, Any] = Field(description="Parameters passed to the tool") + background: bool = Field(description="Whether this tool runs in the background") + + +class HistoryMessage(BaseModel): + """A message in the conversation history.""" + + role: str = Field(description="Message role: 'user', 'assistant', or 'tool'") + content: str = Field(description="Message content") + timestamp: str | None = Field(default=None, description="ISO 8601 timestamp") + + +class AgentInfo(BaseModel): + """Information about an agent in the session.""" + + id: int = Field(description="Agent ID") + name: str | None = Field(default=None, description="Optional agent name") + is_streaming: bool = Field(description="Whether the agent is currently streaming") + + +class PendingApproval(BaseModel): + """A tool call awaiting user approval.""" + + agent_id: int = Field(description="ID of the agent that requested the tool") + call_id: str = Field(description="Unique identifier for this tool call") + name: str = Field(description="Name of the tool") + params: dict[str, Any] = Field(description="Parameters passed to the tool") + + +# ============================================================================ +# Client → Server Messages +# ============================================================================ + + +class SendMessage(BaseModel): + """Send a message to the agent.""" + + type: Literal["SendMessage"] = "SendMessage" + content: str = Field(description="The message content to send") + agent_id: int | None = Field( + default=None, description="Optional agent ID for multi-agent sessions" + ) + + +class ToolDecision(BaseModel): + """Approve or deny a pending tool execution.""" + + type: Literal["ToolDecision"] = "ToolDecision" + call_id: str = Field(description="The call_id of the tool to approve/deny") + approved: bool = Field(description="True to approve, False to deny") + + +class Cancel(BaseModel): + """Cancel current operation.""" + + type: Literal["Cancel"] = "Cancel" + + +class GetHistory(BaseModel): + """Request conversation history.""" + + type: Literal["GetHistory"] = "GetHistory" + + +class GetState(BaseModel): + """Request current session state.""" + + type: Literal["GetState"] = "GetState" + + +class Ping(BaseModel): + """Ping to keep connection alive.""" + + type: Literal["Ping"] = "Ping" + + +# Union type for all client messages +ClientMessage = SendMessage | ToolDecision | Cancel | GetHistory | GetState | Ping + + +# ============================================================================ +# Server → Client Messages +# ============================================================================ + + +class Connected(BaseModel): + """Session established.""" + + type: Literal["Connected"] = "Connected" + session_id: str = Field(description="Unique session identifier") + + +class TextDelta(BaseModel): + """Streaming text from agent.""" + + type: Literal["TextDelta"] = "TextDelta" + agent_id: int = Field(description="ID of the agent producing this text") + content: str = Field(description="Text content delta") + + +class ThinkingDelta(BaseModel): + """Streaming thinking/reasoning from agent.""" + + type: Literal["ThinkingDelta"] = "ThinkingDelta" + agent_id: int = Field(description="ID of the agent") + content: str = Field(description="Thinking content delta") + + +class ToolRequest(BaseModel): + """Agent requesting tool execution.""" + + type: Literal["ToolRequest"] = "ToolRequest" + agent_id: int = Field(description="ID of the agent requesting tools") + calls: list[ToolCallInfo] = Field(description="List of tool calls requested") + + +class ToolAwaitingApproval(BaseModel): + """Tool awaiting user approval.""" + + type: Literal["ToolAwaitingApproval"] = "ToolAwaitingApproval" + agent_id: int = Field(description="ID of the agent") + call_id: str = Field(description="Unique identifier for this tool call") + name: str = Field(description="Name of the tool") + params: dict[str, Any] = Field(description="Parameters passed to the tool") + background: bool = Field(description="Whether this tool runs in the background") + + +class ToolStarted(BaseModel): + """Tool execution started.""" + + type: Literal["ToolStarted"] = "ToolStarted" + agent_id: int = Field(description="ID of the agent") + call_id: str = Field(description="Unique identifier for this tool call") + name: str = Field(description="Name of the tool") + + +class ToolDelta(BaseModel): + """Streaming output from tool execution.""" + + type: Literal["ToolDelta"] = "ToolDelta" + agent_id: int = Field(description="ID of the agent") + call_id: str = Field(description="Unique identifier for this tool call") + content: str = Field(description="Output content delta") + + +class ToolCompleted(BaseModel): + """Tool execution completed successfully.""" + + type: Literal["ToolCompleted"] = "ToolCompleted" + agent_id: int = Field(description="ID of the agent") + call_id: str = Field(description="Unique identifier for this tool call") + content: str = Field(description="Final output content") + + +class ToolError(BaseModel): + """Tool execution failed or was denied.""" + + type: Literal["ToolError"] = "ToolError" + agent_id: int = Field(description="ID of the agent") + call_id: str = Field(description="Unique identifier for this tool call") + error: str = Field(description="Error message") + + +class Finished(BaseModel): + """Agent finished processing (turn complete).""" + + type: Literal["Finished"] = "Finished" + agent_id: int = Field(description="ID of the agent") + usage: Usage = Field(description="Token usage statistics") + + +class Retrying(BaseModel): + """Agent is retrying after transient error.""" + + type: Literal["Retrying"] = "Retrying" + agent_id: int = Field(description="ID of the agent") + attempt: int = Field(description="Retry attempt number") + error: str = Field(description="Error that caused the retry") + + +class History(BaseModel): + """Conversation history response.""" + + type: Literal["History"] = "History" + messages: list[HistoryMessage] = Field(description="List of history messages") + + +class State(BaseModel): + """Session state response.""" + + type: Literal["State"] = "State" + agents: list[AgentInfo] = Field(description="List of agents in the session") + pending_approvals: list[PendingApproval] = Field( + description="List of tool calls awaiting approval" + ) + + +class Pong(BaseModel): + """Pong response to Ping.""" + + type: Literal["Pong"] = "Pong" + + +class Error(BaseModel): + """Error occurred.""" + + type: Literal["Error"] = "Error" + message: str = Field(description="Error message") + fatal: bool = Field(description="If true, the session is no longer usable") + + +# Union type for all server messages +ServerMessage = ( + Connected + | TextDelta + | ThinkingDelta + | ToolRequest + | ToolAwaitingApproval + | ToolStarted + | ToolDelta + | ToolCompleted + | ToolError + | Finished + | Retrying + | History + | State + | Pong + | Error +) + +# Type discriminator for parsing server messages +SERVER_MESSAGE_TYPES: dict[str, type[BaseModel]] = { + "Connected": Connected, + "TextDelta": TextDelta, + "ThinkingDelta": ThinkingDelta, + "ToolRequest": ToolRequest, + "ToolAwaitingApproval": ToolAwaitingApproval, + "ToolStarted": ToolStarted, + "ToolDelta": ToolDelta, + "ToolCompleted": ToolCompleted, + "ToolError": ToolError, + "Finished": Finished, + "Retrying": Retrying, + "History": History, + "State": State, + "Pong": Pong, + "Error": Error, +} + + +def parse_server_message(data: dict[str, Any]) -> ServerMessage: + """Parse a server message from a dictionary. + + Args: + data: Dictionary containing the message data with a 'type' field. + + Returns: + The parsed server message. + + Raises: + ValueError: If the message type is unknown. + """ + msg_type = data.get("type") + if msg_type not in SERVER_MESSAGE_TYPES: + raise ValueError(f"Unknown server message type: {msg_type}") + return SERVER_MESSAGE_TYPES[msg_type].model_validate(data) diff --git a/clients/python/examples/basic_chat.py b/clients/python/examples/basic_chat.py new file mode 100644 index 0000000..29398be --- /dev/null +++ b/clients/python/examples/basic_chat.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Basic chat example for codey-client. + +Usage: + # Start the server first: + codey-server --listen 127.0.0.1:9999 + + # Then run this script: + python examples/basic_chat.py + + # Or with auto-approve: + python examples/basic_chat.py --auto-approve +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys + +from codey_client import ( + CodeyClient, + Error, + Finished, + Retrying, + TextDelta, + ThinkingDelta, + ToolAwaitingApproval, + ToolCompleted, + ToolError, + ToolStarted, +) + + +def approval_prompt(tool: ToolAwaitingApproval) -> bool: + """Prompt the user to approve or deny a tool execution.""" + print(f"\n[Tool Request] {tool.name}") + print(f" Parameters: {tool.params}") + while True: + response = input(" Approve? [y/n]: ").strip().lower() + if response in ("y", "yes"): + return True + if response in ("n", "no"): + return False + print(" Please enter 'y' or 'n'") + + +async def chat_loop(client: CodeyClient) -> None: + """Run an interactive chat loop.""" + print("Connected to codey-server!") + print("Type your message and press Enter. Type 'quit' to exit.\n") + + while True: + try: + user_input = input("You: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + break + + if not user_input: + continue + if user_input.lower() in ("quit", "exit", "q"): + print("Goodbye!") + break + + print("Assistant: ", end="", flush=True) + + try: + async for msg in client.chat(user_input): + if isinstance(msg, TextDelta): + print(msg.content, end="", flush=True) + + elif isinstance(msg, ThinkingDelta): + # Optionally show thinking (usually hidden) + pass + + elif isinstance(msg, ToolStarted): + print(f"\n[Executing: {msg.name}]", flush=True) + + elif isinstance(msg, ToolCompleted): + # Tool output (often long, truncate for display) + output = msg.content[:200] + "..." if len(msg.content) > 200 else msg.content + print(f"[Completed: {output}]", flush=True) + print("Assistant: ", end="", flush=True) + + elif isinstance(msg, ToolError): + print(f"\n[Tool Error: {msg.error}]", flush=True) + + elif isinstance(msg, Retrying): + print(f"\n[Retrying: attempt {msg.attempt}, {msg.error}]", flush=True) + + elif isinstance(msg, Finished): + print(f"\n[Tokens: {msg.usage.output_tokens}]\n") + + elif isinstance(msg, Error): + print(f"\n[Error: {msg.message}]") + if msg.fatal: + print("Fatal error, disconnecting.") + return + + except Exception as e: + print(f"\nError: {e}") + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Chat with codey-server") + parser.add_argument( + "--url", + default="ws://127.0.0.1:9999", + help="WebSocket URL (default: ws://127.0.0.1:9999)", + ) + parser.add_argument( + "--auto-approve", + action="store_true", + help="Automatically approve all tool requests", + ) + args = parser.parse_args() + + # Set up approval callback unless auto-approve is enabled + on_approval = None if args.auto_approve else approval_prompt + + try: + async with CodeyClient( + url=args.url, + auto_approve=args.auto_approve, + on_approval_request=on_approval, + ) as client: + await chat_loop(client) + except ConnectionError as e: + print(f"Connection failed: {e}", file=sys.stderr) + print("Make sure codey-server is running.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 0000000..022ec6b --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "codey-client" +version = "0.1.0" +description = "WebSocket client for codey-server" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +authors = [ + { name = "Codey Contributors" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "pydantic>=2.0", + "websockets>=13.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "ruff>=0.8", + "mypy>=1.13", +] + +[project.urls] +Homepage = "https://github.com/tcdent/codey" +Repository = "https://github.com/tcdent/codey" + +[tool.hatch.build.targets.wheel] +packages = ["codey_client"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function"