Merge spike/claude-code-integration: PTY-based Claude Code with multi-agent support
Spike proved: spawning claude -p in a PTY from Rust gets Max subscription billing. Multi-agent concurrency confirmed with session resumption. Includes AgentPool REST API, claude-code provider, and spike documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> # Conflicts: # .ignore
This commit is contained in:
307
server/src/agents.rs
Normal file
307
server/src/agents.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Manages multiple concurrent Claude Code agent sessions.
|
||||
///
|
||||
/// Each agent is identified by a string name (e.g., "coder-1", "coder-2").
|
||||
/// Agents run `claude -p` in a PTY for Max subscription billing.
|
||||
/// Sessions can be resumed for multi-turn conversations.
|
||||
pub struct AgentPool {
|
||||
agents: Mutex<HashMap<String, AgentState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct AgentInfo {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub cwd: String,
|
||||
pub session_id: Option<String>,
|
||||
pub status: AgentStatus,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentStatus {
|
||||
Idle,
|
||||
Running,
|
||||
}
|
||||
|
||||
struct AgentState {
|
||||
role: String,
|
||||
cwd: String,
|
||||
session_id: Option<String>,
|
||||
message_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAgentRequest {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AgentResponse {
|
||||
pub agent: String,
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub api_key_source: Option<String>,
|
||||
pub rate_limit_type: Option<String>,
|
||||
pub cost_usd: Option<f64>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl AgentPool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_agent(&self, req: CreateAgentRequest) -> Result<AgentInfo, String> {
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if agents.contains_key(&req.name) {
|
||||
return Err(format!("Agent '{}' already exists", req.name));
|
||||
}
|
||||
|
||||
let state = AgentState {
|
||||
role: req.role.clone(),
|
||||
cwd: req.cwd.clone(),
|
||||
session_id: None,
|
||||
message_count: 0,
|
||||
};
|
||||
|
||||
let info = AgentInfo {
|
||||
name: req.name.clone(),
|
||||
role: req.role,
|
||||
cwd: req.cwd,
|
||||
session_id: None,
|
||||
status: AgentStatus::Idle,
|
||||
message_count: 0,
|
||||
};
|
||||
|
||||
agents.insert(req.name, state);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn list_agents(&self) -> Result<Vec<AgentInfo>, String> {
|
||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
Ok(agents
|
||||
.iter()
|
||||
.map(|(name, state)| AgentInfo {
|
||||
name: name.clone(),
|
||||
role: state.role.clone(),
|
||||
cwd: state.cwd.clone(),
|
||||
session_id: state.session_id.clone(),
|
||||
status: AgentStatus::Idle,
|
||||
message_count: state.message_count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Send a message to an agent and wait for the complete response.
|
||||
/// This spawns a `claude -p` process in a PTY, optionally resuming
|
||||
/// a previous session for multi-turn conversations.
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
agent_name: &str,
|
||||
message: &str,
|
||||
) -> Result<AgentResponse, String> {
|
||||
let (cwd, role, session_id) = {
|
||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
let state = agents
|
||||
.get(agent_name)
|
||||
.ok_or_else(|| format!("Agent '{}' not found", agent_name))?;
|
||||
(
|
||||
state.cwd.clone(),
|
||||
state.role.clone(),
|
||||
state.session_id.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let agent = agent_name.to_string();
|
||||
let msg = message.to_string();
|
||||
let role_clone = role.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
run_agent_pty(&agent, &msg, &cwd, &role_clone, session_id.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Agent task panicked: {e}"))??;
|
||||
|
||||
// Update session_id for next message
|
||||
if let Some(ref sid) = result.session_id {
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(state) = agents.get_mut(agent_name) {
|
||||
state.session_id = Some(sid.clone());
|
||||
state.message_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_agent_pty(
|
||||
agent_name: &str,
|
||||
message: &str,
|
||||
cwd: &str,
|
||||
role: &str,
|
||||
resume_session: Option<&str>,
|
||||
) -> Result<AgentResponse, String> {
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 50,
|
||||
cols: 200,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("Failed to open PTY: {e}"))?;
|
||||
|
||||
let mut cmd = CommandBuilder::new("claude");
|
||||
cmd.arg("-p");
|
||||
cmd.arg(message);
|
||||
cmd.arg("--output-format");
|
||||
cmd.arg("stream-json");
|
||||
cmd.arg("--verbose");
|
||||
|
||||
// Append role as system prompt context
|
||||
cmd.arg("--append-system-prompt");
|
||||
cmd.arg(format!(
|
||||
"You are agent '{}' with role: {}. Work autonomously on the task given.",
|
||||
agent_name, role
|
||||
));
|
||||
|
||||
// Resume previous session if available
|
||||
if let Some(session_id) = resume_session {
|
||||
cmd.arg("--resume");
|
||||
cmd.arg(session_id);
|
||||
}
|
||||
|
||||
cmd.cwd(cwd);
|
||||
cmd.env("NO_COLOR", "1");
|
||||
|
||||
eprintln!(
|
||||
"[agent:{}] Spawning claude -p (session: {:?})",
|
||||
agent_name,
|
||||
resume_session.unwrap_or("new")
|
||||
);
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn claude for agent {agent_name}: {e}"))?;
|
||||
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
|
||||
drop(pair.master);
|
||||
|
||||
let buf_reader = BufReader::new(reader);
|
||||
let mut response = AgentResponse {
|
||||
agent: agent_name.to_string(),
|
||||
text: String::new(),
|
||||
session_id: None,
|
||||
model: None,
|
||||
api_key_source: None,
|
||||
rate_limit_type: None,
|
||||
cost_usd: None,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
|
||||
for line in buf_reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(trimmed) {
|
||||
Ok(j) => j,
|
||||
Err(_) => continue, // skip non-JSON (terminal escapes)
|
||||
};
|
||||
|
||||
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"system" => {
|
||||
response.session_id = json
|
||||
.get("session_id")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
response.model = json
|
||||
.get("model")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
response.api_key_source = json
|
||||
.get("apiKeySource")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
"rate_limit_event" => {
|
||||
if let Some(info) = json.get("rate_limit_info") {
|
||||
response.rate_limit_type = info
|
||||
.get("rateLimitType")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(message) = json.get("message") {
|
||||
if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
|
||||
for block in content {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
response.text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"result" => {
|
||||
response.cost_usd = json.get("total_cost_usd").and_then(|c| c.as_f64());
|
||||
response.duration_ms = json.get("duration_ms").and_then(|d| d.as_u64());
|
||||
if let Some(usage) = json.get("usage") {
|
||||
response.input_tokens =
|
||||
usage.get("input_tokens").and_then(|t| t.as_u64());
|
||||
response.output_tokens =
|
||||
usage.get("output_tokens").and_then(|t| t.as_u64());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
|
||||
eprintln!(
|
||||
"[agent:{}] Done. Session: {:?}, tokens: {:?}/{:?}",
|
||||
agent_name, response.session_id, response.input_tokens, response.output_tokens
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
127
server/src/http/agents.rs
Normal file
127
server/src/http/agents.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum AgentsTags {
|
||||
Agents,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct CreateAgentPayload {
|
||||
name: String,
|
||||
role: String,
|
||||
cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct SendMessagePayload {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentInfoResponse {
|
||||
name: String,
|
||||
role: String,
|
||||
cwd: String,
|
||||
session_id: Option<String>,
|
||||
status: String,
|
||||
message_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentMessageResponse {
|
||||
agent: String,
|
||||
text: String,
|
||||
session_id: Option<String>,
|
||||
model: Option<String>,
|
||||
api_key_source: Option<String>,
|
||||
rate_limit_type: Option<String>,
|
||||
cost_usd: Option<f64>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct AgentsApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "AgentsTags::Agents")]
|
||||
impl AgentsApi {
|
||||
/// Create a new agent with a name, role, and working directory.
|
||||
#[oai(path = "/agents", method = "post")]
|
||||
async fn create_agent(
|
||||
&self,
|
||||
payload: Json<CreateAgentPayload>,
|
||||
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
||||
let req = crate::agents::CreateAgentRequest {
|
||||
name: payload.0.name,
|
||||
role: payload.0.role,
|
||||
cwd: payload.0.cwd,
|
||||
};
|
||||
|
||||
let info = self.ctx.agents.create_agent(req).map_err(bad_request)?;
|
||||
|
||||
Ok(Json(AgentInfoResponse {
|
||||
name: info.name,
|
||||
role: info.role,
|
||||
cwd: info.cwd,
|
||||
session_id: info.session_id,
|
||||
status: "idle".to_string(),
|
||||
message_count: info.message_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all registered agents.
|
||||
#[oai(path = "/agents", method = "get")]
|
||||
async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> {
|
||||
let agents = self.ctx.agents.list_agents().map_err(bad_request)?;
|
||||
|
||||
Ok(Json(
|
||||
agents
|
||||
.into_iter()
|
||||
.map(|info| AgentInfoResponse {
|
||||
name: info.name,
|
||||
role: info.role,
|
||||
cwd: info.cwd,
|
||||
session_id: info.session_id,
|
||||
status: match info.status {
|
||||
crate::agents::AgentStatus::Idle => "idle".to_string(),
|
||||
crate::agents::AgentStatus::Running => "running".to_string(),
|
||||
},
|
||||
message_count: info.message_count,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Send a message to an agent and wait for its response.
|
||||
#[oai(path = "/agents/:name/message", method = "post")]
|
||||
async fn send_message(
|
||||
&self,
|
||||
name: poem_openapi::param::Path<String>,
|
||||
payload: Json<SendMessagePayload>,
|
||||
) -> OpenApiResult<Json<AgentMessageResponse>> {
|
||||
let result = self
|
||||
.ctx
|
||||
.agents
|
||||
.send_message(&name.0, &payload.0.message)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
|
||||
Ok(Json(AgentMessageResponse {
|
||||
agent: result.agent,
|
||||
text: result.text,
|
||||
session_id: result.session_id,
|
||||
model: result.model,
|
||||
api_key_source: result.api_key_source,
|
||||
rate_limit_type: result.rate_limit_type,
|
||||
cost_usd: result.cost_usd,
|
||||
input_tokens: result.input_tokens,
|
||||
output_tokens: result.output_tokens,
|
||||
duration_ms: result.duration_ms,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agents::AgentPool;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
@@ -9,6 +10,7 @@ pub struct AppContext {
|
||||
pub state: Arc<SessionState>,
|
||||
pub store: Arc<JsonFileStore>,
|
||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||
pub agents: Arc<AgentPool>,
|
||||
}
|
||||
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod agents;
|
||||
pub mod anthropic;
|
||||
pub mod assets;
|
||||
pub mod chat;
|
||||
@@ -10,6 +11,7 @@ pub mod workflow;
|
||||
pub mod project;
|
||||
pub mod ws;
|
||||
|
||||
use agents::AgentsApi;
|
||||
use anthropic::AnthropicApi;
|
||||
use chat::ChatApi;
|
||||
use context::AppContext;
|
||||
@@ -45,6 +47,7 @@ type ApiTuple = (
|
||||
IoApi,
|
||||
ChatApi,
|
||||
WorkflowApi,
|
||||
AgentsApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
@@ -58,6 +61,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx: ctx.clone() },
|
||||
AgentsApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
@@ -69,7 +73,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx },
|
||||
WorkflowApi { ctx: ctx.clone() },
|
||||
AgentsApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
|
||||
@@ -189,12 +189,55 @@ where
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
|
||||
let is_claude = config.model.starts_with("claude-");
|
||||
eprintln!("[chat] provider={} model={}", config.provider, config.model);
|
||||
let is_claude_code = config.provider == "claude-code";
|
||||
let is_claude = !is_claude_code && config.model.starts_with("claude-");
|
||||
|
||||
if !is_claude && config.provider.as_str() != "ollama" {
|
||||
if !is_claude_code && !is_claude && config.provider.as_str() != "ollama" {
|
||||
return Err(format!("Unsupported provider: {}", config.provider));
|
||||
}
|
||||
|
||||
// Claude Code provider: bypasses our tool loop entirely.
|
||||
// Claude Code has its own agent loop, tools, and context management.
|
||||
// We just pipe the user message in and stream raw output back.
|
||||
if is_claude_code {
|
||||
use crate::llm::providers::claude_code::ClaudeCodeProvider;
|
||||
|
||||
let user_message = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == Role::User)
|
||||
.map(|m| m.content.clone())
|
||||
.ok_or_else(|| "No user message found".to_string())?;
|
||||
|
||||
let project_root = state
|
||||
.get_project_root()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
|
||||
let provider = ClaudeCodeProvider::new();
|
||||
let response = provider
|
||||
.chat_stream(
|
||||
&user_message,
|
||||
&project_root.to_string_lossy(),
|
||||
&mut cancel_rx,
|
||||
|token| on_token(token),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Claude Code Error: {e}"))?;
|
||||
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: response.content.unwrap_or_default(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
|
||||
let mut result = messages.clone();
|
||||
result.push(assistant_msg);
|
||||
on_update(&result);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let tool_defs = get_tool_definitions();
|
||||
let tools = if config.enable_tools.unwrap_or(true) {
|
||||
tool_defs.as_slice()
|
||||
|
||||
299
server/src/llm/providers/claude_code.rs
Normal file
299
server/src/llm/providers/claude_code.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::llm::types::CompletionResponse;
|
||||
|
||||
/// Manages a Claude Code session via a pseudo-terminal.
|
||||
///
|
||||
/// Spawns `claude -p` in a PTY so isatty() returns true (which may
|
||||
/// influence billing), while using `--output-format stream-json` to
|
||||
/// get clean, structured NDJSON output instead of TUI escape sequences.
|
||||
pub struct ClaudeCodeProvider;
|
||||
|
||||
impl ClaudeCodeProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub async fn chat_stream<F>(
|
||||
&self,
|
||||
user_message: &str,
|
||||
project_root: &str,
|
||||
cancel_rx: &mut watch::Receiver<bool>,
|
||||
mut on_token: F,
|
||||
) -> Result<CompletionResponse, String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
let message = user_message.to_string();
|
||||
let cwd = project_root.to_string();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let cancelled_clone = cancelled.clone();
|
||||
|
||||
let mut cancel_watch = cancel_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
while cancel_watch.changed().await.is_ok() {
|
||||
if *cancel_watch.borrow() {
|
||||
cancelled_clone.store(true, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (token_tx, mut token_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
|
||||
let pty_handle = tokio::task::spawn_blocking(move || {
|
||||
run_pty_session(&message, &cwd, cancelled, token_tx)
|
||||
});
|
||||
|
||||
let mut full_output = String::new();
|
||||
while let Some(token) = token_rx.recv().await {
|
||||
full_output.push_str(&token);
|
||||
on_token(&token);
|
||||
}
|
||||
|
||||
pty_handle
|
||||
.await
|
||||
.map_err(|e| format!("PTY task panicked: {e}"))??;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: Some(full_output),
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `claude -p` with stream-json output inside a PTY.
|
||||
///
|
||||
/// The PTY makes isatty() return true. The `-p` flag gives us
|
||||
/// single-shot non-interactive mode with structured output.
|
||||
fn run_pty_session(
|
||||
user_message: &str,
|
||||
cwd: &str,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||
) -> Result<(), String> {
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 50,
|
||||
cols: 200,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("Failed to open PTY: {e}"))?;
|
||||
|
||||
let mut cmd = CommandBuilder::new("claude");
|
||||
cmd.arg("-p");
|
||||
cmd.arg(user_message);
|
||||
cmd.arg("--output-format");
|
||||
cmd.arg("stream-json");
|
||||
cmd.arg("--verbose");
|
||||
cmd.cwd(cwd);
|
||||
// Keep TERM reasonable but disable color
|
||||
cmd.env("NO_COLOR", "1");
|
||||
|
||||
eprintln!("[pty-debug] Spawning: claude -p \"{}\" --output-format stream-json --verbose", user_message);
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
eprintln!("[pty-debug] Process spawned, pid: {:?}", child.process_id());
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
|
||||
// We don't need to write anything — -p mode takes prompt as arg
|
||||
drop(pair.master);
|
||||
|
||||
// Read NDJSON lines from stdout
|
||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let buf_reader = BufReader::new(reader);
|
||||
eprintln!("[pty-debug] Reader thread started");
|
||||
for line in buf_reader.lines() {
|
||||
match line {
|
||||
Ok(l) => {
|
||||
eprintln!("[pty-debug] raw line: {}", l);
|
||||
if line_tx.send(Some(l)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[pty-debug] read error: {e}");
|
||||
let _ = line_tx.send(None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("[pty-debug] Reader thread done");
|
||||
let _ = line_tx.send(None);
|
||||
});
|
||||
|
||||
let mut got_result = false;
|
||||
|
||||
loop {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
let _ = child.kill();
|
||||
return Err("Cancelled".to_string());
|
||||
}
|
||||
|
||||
match line_rx.recv_timeout(std::time::Duration::from_millis(500)) {
|
||||
Ok(Some(line)) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]);
|
||||
|
||||
// Try to parse as JSON
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(event_type) = json.get("type").and_then(|t| t.as_str()) {
|
||||
match event_type {
|
||||
// Streaming deltas (when --include-partial-messages is used)
|
||||
"stream_event" => {
|
||||
if let Some(event) = json.get("event") {
|
||||
handle_stream_event(event, &token_tx);
|
||||
}
|
||||
}
|
||||
// Complete assistant message
|
||||
"assistant" => {
|
||||
if let Some(message) = json.get("message") {
|
||||
if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
|
||||
for block in content {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
let _ = token_tx.send(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final result with usage stats
|
||||
"result" => {
|
||||
if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) {
|
||||
let _ = token_tx.send(format!("\n\n---\n_Cost: ${cost:.4}_\n"));
|
||||
}
|
||||
if let Some(usage) = json.get("usage") {
|
||||
let input = usage.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
let output = usage.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
let cached = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
let _ = token_tx.send(format!("_Tokens: {input} in / {output} out / {cached} cached_\n"));
|
||||
}
|
||||
got_result = true;
|
||||
}
|
||||
// System init — log billing info
|
||||
"system" => {
|
||||
let api_source = json.get("apiKeySource").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||
let model = json.get("model").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||
let _ = token_tx.send(format!("_[{model} | apiKey: {api_source}]_\n\n"));
|
||||
}
|
||||
// Rate limit info
|
||||
"rate_limit_event" => {
|
||||
if let Some(info) = json.get("rate_limit_info") {
|
||||
let status = info.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||
let limit_type = info.get("rateLimitType").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||
let _ = token_tx.send(format!("_[rate limit: {status} ({limit_type})]_\n\n"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore non-JSON lines (terminal escape sequences)
|
||||
|
||||
if got_result {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => break, // EOF
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Check if child has exited
|
||||
if let Ok(Some(_status)) = child.try_wait() {
|
||||
// Drain remaining lines
|
||||
while let Ok(Some(line)) = line_rx.try_recv() {
|
||||
let trimmed = line.trim();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(event) = json
|
||||
.get("type")
|
||||
.filter(|t| t.as_str() == Some("stream_event"))
|
||||
.and_then(|_| json.get("event"))
|
||||
{
|
||||
handle_stream_event(event, &token_tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
// Don't set got_result here — just let the process finish naturally
|
||||
let _ = got_result;
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract text from a stream event and send to the token channel.
|
||||
fn handle_stream_event(
|
||||
event: &serde_json::Value,
|
||||
token_tx: &tokio::sync::mpsc::UnboundedSender<String>,
|
||||
) {
|
||||
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
// Text content streaming
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = event.get("delta") {
|
||||
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match delta_type {
|
||||
"text_delta" => {
|
||||
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
|
||||
let _ = token_tx.send(text.to_string());
|
||||
}
|
||||
}
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
|
||||
let _ = token_tx.send(format!("[thinking] {thinking}"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Message complete — log usage info
|
||||
"message_delta" => {
|
||||
if let Some(usage) = event.get("usage") {
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0);
|
||||
let _ = token_tx.send(format!("\n[tokens: {output_tokens} output]\n"));
|
||||
}
|
||||
}
|
||||
// Log errors
|
||||
"error" => {
|
||||
if let Some(error) = event.get("error") {
|
||||
let msg = error
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
let _ = token_tx.send(format!("\n[error: {msg}]\n"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod anthropic;
|
||||
pub mod claude_code;
|
||||
pub mod ollama;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agents;
|
||||
mod http;
|
||||
mod io;
|
||||
mod llm;
|
||||
@@ -5,6 +6,7 @@ mod state;
|
||||
mod store;
|
||||
mod workflow;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::http::build_routes;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::state::SessionState;
|
||||
@@ -22,11 +24,13 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||
);
|
||||
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
||||
let agents = Arc::new(AgentPool::new());
|
||||
|
||||
let ctx = AppContext {
|
||||
state: app_state,
|
||||
store,
|
||||
workflow,
|
||||
agents,
|
||||
};
|
||||
|
||||
let app = build_routes(ctx);
|
||||
|
||||
Reference in New Issue
Block a user