mirror of
https://github.com/katanemo/plano.git
synced 2026-06-23 15:38:07 +02:00
feat(claude-cli): add local Claude Code CLI provider bridge
Spawn the local `claude` binary as a subprocess and expose it as an
Anthropic Messages-compatible provider. Hosted in brightstaff
(`CLAUDE_CLI_LISTEN_ADDR`), with session reuse, idle TTL, and watchdog.
User-facing surface is `model_providers: [{ model: claude-cli/* }]` —
the Python CLI auto-fills name/provider_interface/base_url/access_key
and the launcher (native + supervisord) enables the bridge listener
only when at least one claude-cli provider is present.
This commit is contained in:
parent
b71a555f19
commit
9fdfeb7cbf
26 changed files with 2847 additions and 2 deletions
22
crates/brightstaff/src/handlers/claude_cli/mod.rs
Normal file
22
crates/brightstaff/src/handlers/claude_cli/mod.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Bridge that exposes the local `claude` CLI as an Anthropic Messages API
|
||||
//! endpoint on a localhost port, allowing it to be used as just another
|
||||
//! `model_provider` in Plano.
|
||||
//!
|
||||
//! Wire-up:
|
||||
//! - `process` — spawns and manages the `claude -p --output-format stream-json
|
||||
//! --input-format stream-json` subprocess.
|
||||
//! - `session` — keys long-lived processes by session id (header or hash) and
|
||||
//! enforces idle TTL / cap.
|
||||
//! - `server` — hyper listener that speaks `POST /v1/messages` and bridges
|
||||
//! between Anthropic SSE and the CLI's NDJSON.
|
||||
//!
|
||||
//! Translation between the two wire formats lives in
|
||||
//! `hermesllm::apis::claude_cli`; this module only owns runtime concerns.
|
||||
|
||||
pub mod process;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
|
||||
pub use process::{ClaudeCliConfig, ClaudeProcess, ProcessError};
|
||||
pub use server::run_listener;
|
||||
pub use session::{SessionManager, SessionManagerConfig, SESSION_HEADER};
|
||||
330
crates/brightstaff/src/handlers/claude_cli/process.rs
Normal file
330
crates/brightstaff/src/handlers/claude_cli/process.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
//! Manages the lifetime of one `claude -p` child process for a single
|
||||
//! conversation session. Spawning, env scrubbing, NDJSON line reading and the
|
||||
//! per-line watchdog all live here. Translation between Anthropic Messages
|
||||
//! and stream-json lives in `hermesllm::apis::claude_cli`.
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hermesllm::apis::claude_cli::{parse_ndjson_line, ClaudeCliEvent, ClaudeCliInputEvent};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{mpsc, Mutex, OwnedMutexGuard};
|
||||
use tokio::time::{self, Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Tunables for one `ClaudeProcess`. Defaults match the OpenClaw reference
|
||||
/// configuration: `bypassPermissions`, ~120 s watchdog window, ~10 min idle TTL.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaudeCliConfig {
|
||||
/// Path or name of the `claude` binary (looked up via `$PATH`).
|
||||
pub binary: String,
|
||||
/// Value passed to `--permission-mode`. The CLI accepts `default`,
|
||||
/// `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`.
|
||||
pub permission_mode: String,
|
||||
/// Idle session TTL — after this many seconds without a request the
|
||||
/// session manager kills the child.
|
||||
pub session_ttl: Duration,
|
||||
/// Per-line watchdog: if no NDJSON line arrives for this long during a
|
||||
/// turn, kill the child. Reset on every line (not every byte).
|
||||
pub watchdog: Duration,
|
||||
}
|
||||
|
||||
impl Default for ClaudeCliConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
binary: "claude".to_string(),
|
||||
permission_mode: "bypassPermissions".to_string(),
|
||||
session_ttl: Duration::from_secs(600),
|
||||
watchdog: Duration::from_secs(120),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced while interacting with the child process.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProcessError {
|
||||
#[error("failed to spawn `{binary}`: {source}")]
|
||||
Spawn {
|
||||
binary: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to write to claude stdin: {0}")]
|
||||
StdinWrite(#[source] std::io::Error),
|
||||
#[error("claude process exited unexpectedly")]
|
||||
ExitedEarly,
|
||||
#[error("claude watchdog fired after {0:?} of silence")]
|
||||
WatchdogTimeout(Duration),
|
||||
#[error("failed to serialize stdin payload: {0}")]
|
||||
Serialize(#[from] serde_json::Error),
|
||||
#[error("turn already in progress for this session")]
|
||||
TurnInProgress,
|
||||
}
|
||||
|
||||
/// Strip down to the model alias / id the CLI's `--model` flag accepts.
|
||||
/// Models registered via the wildcard `claude-cli/*` arrive prefixed with
|
||||
/// `claude-cli/` (or just bare, e.g. `sonnet`); both forms are normalized
|
||||
/// here.
|
||||
pub fn normalize_model_arg(model: &str) -> &str {
|
||||
model.strip_prefix("claude-cli/").unwrap_or(model)
|
||||
}
|
||||
|
||||
/// Environment variables that must be removed before exec'ing `claude` so the
|
||||
/// child uses its own login keychain rather than picking up server-side
|
||||
/// credentials. The list mirrors the OpenClaw scrub list.
|
||||
const SCRUB_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "CLAUDE_CODE_", "OTEL_"];
|
||||
|
||||
fn scrubbed_env_for_spawn() -> Vec<(String, String)> {
|
||||
std::env::vars()
|
||||
.filter(|(k, _)| !SCRUB_ENV_PREFIXES.iter().any(|p| k.starts_with(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One running `claude -p` subprocess plus the channels we use to talk to it.
|
||||
/// Each `ClaudeProcess` is owned by exactly one session.
|
||||
pub struct ClaudeProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
stdin: Mutex<Option<ChildStdin>>,
|
||||
/// The receiver of `ClaudeCliEvent`s parsed from the child's stdout.
|
||||
/// Wrapped in `Arc<Mutex>` so a `TurnStream` can hold an owned guard for
|
||||
/// the duration of one turn (which serializes turns within a session).
|
||||
event_rx: Arc<Mutex<mpsc::Receiver<ClaudeCliEvent>>>,
|
||||
config: ClaudeCliConfig,
|
||||
/// Last time a request was served on this session — used by the session
|
||||
/// manager to enforce the idle TTL.
|
||||
last_used: Mutex<Instant>,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl ClaudeProcess {
|
||||
/// Spawn a new child for `session_id`. The first turn for a new session
|
||||
/// should be the user's Anthropic request body — see
|
||||
/// [`ClaudeProcess::send_user_turn`] for that.
|
||||
pub async fn spawn(
|
||||
session_id: String,
|
||||
model: &str,
|
||||
system_prompt: Option<&str>,
|
||||
cwd: Option<&std::path::Path>,
|
||||
config: ClaudeCliConfig,
|
||||
) -> Result<Arc<Self>, ProcessError> {
|
||||
let mut cmd = Command::new(&config.binary);
|
||||
cmd.arg("-p")
|
||||
.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("--input-format")
|
||||
.arg("stream-json")
|
||||
.arg("--verbose")
|
||||
.arg("--include-partial-messages")
|
||||
.arg("--permission-mode")
|
||||
.arg(&config.permission_mode)
|
||||
.arg("--model")
|
||||
.arg(normalize_model_arg(model))
|
||||
.arg("--session-id")
|
||||
.arg(&session_id)
|
||||
.arg("--no-session-persistence");
|
||||
|
||||
if let Some(prompt) = system_prompt {
|
||||
// Append (don't replace) so Claude Code's built-in system prompt
|
||||
// — which carries tool definitions — is preserved.
|
||||
cmd.arg("--append-system-prompt").arg(prompt);
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.env_clear();
|
||||
for (k, v) in scrubbed_env_for_spawn() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| ProcessError::Spawn {
|
||||
binary: config.binary.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or(ProcessError::ExitedEarly)?;
|
||||
let stdout = child.stdout.take().ok_or(ProcessError::ExitedEarly)?;
|
||||
let stderr = child.stderr.take().ok_or(ProcessError::ExitedEarly)?;
|
||||
|
||||
// Bounded channel — backpressure if the consumer is slow, but large
|
||||
// enough that bursts of small text deltas do not block stdout drain.
|
||||
let (tx, rx) = mpsc::channel::<ClaudeCliEvent>(256);
|
||||
|
||||
let session_for_log = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
loop {
|
||||
match reader.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(parsed) = parse_ndjson_line(&line) {
|
||||
match parsed {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
session = %session_for_log,
|
||||
error = %err,
|
||||
line = %line,
|
||||
"failed to parse claude NDJSON line"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(session = %session_for_log, "claude stdout closed");
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
session = %session_for_log,
|
||||
error = %err,
|
||||
"claude stdout read error"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session_for_stderr = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if !line.trim().is_empty() {
|
||||
warn!(session = %session_for_stderr, line = %line, "claude stderr");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
session = %session_id,
|
||||
model = %normalize_model_arg(model),
|
||||
"spawned claude-cli"
|
||||
);
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
child: Mutex::new(Some(child)),
|
||||
stdin: Mutex::new(Some(stdin)),
|
||||
event_rx: Arc::new(Mutex::new(rx)),
|
||||
config,
|
||||
last_used: Mutex::new(Instant::now()),
|
||||
session_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write the user-turn JSONL events to the child's stdin and return a
|
||||
/// stream that yields parsed CLI events for this turn until the terminal
|
||||
/// `result` event (or watchdog) ends it.
|
||||
///
|
||||
/// Holds an exclusive lock on the event receiver for the duration of the
|
||||
/// turn, so concurrent calls return [`ProcessError::TurnInProgress`].
|
||||
pub async fn send_user_turn(
|
||||
&self,
|
||||
events: &[ClaudeCliInputEvent],
|
||||
) -> Result<TurnStream, ProcessError> {
|
||||
*self.last_used.lock().await = Instant::now();
|
||||
|
||||
// Claim the event receiver for the lifetime of this turn.
|
||||
let rx_guard = Arc::clone(&self.event_rx)
|
||||
.try_lock_owned()
|
||||
.map_err(|_| ProcessError::TurnInProgress)?;
|
||||
|
||||
let mut stdin_guard = self.stdin.lock().await;
|
||||
let stdin = stdin_guard.as_mut().ok_or(ProcessError::ExitedEarly)?;
|
||||
for ev in events {
|
||||
let mut bytes = serde_json::to_vec(ev)?;
|
||||
bytes.push(b'\n');
|
||||
stdin
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.map_err(ProcessError::StdinWrite)?;
|
||||
}
|
||||
stdin.flush().await.map_err(ProcessError::StdinWrite)?;
|
||||
|
||||
Ok(TurnStream {
|
||||
rx: rx_guard,
|
||||
watchdog: self.config.watchdog,
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Most-recent activity timestamp; used by the session manager's reaper.
|
||||
pub async fn last_used(&self) -> Instant {
|
||||
*self.last_used.lock().await
|
||||
}
|
||||
|
||||
/// Forcefully terminate the child. Safe to call multiple times.
|
||||
pub async fn shutdown(&self) {
|
||||
if let Some(mut child) = self.child.lock().await.take() {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
// Dropping stdin signals the child if it survived `start_kill`.
|
||||
let _ = self.stdin.lock().await.take();
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot stream of CLI events for a single user turn. Yields events until
|
||||
/// the terminal `result` event is observed (or the watchdog fires). Drops the
|
||||
/// owned receiver lock when finished, allowing the next turn to start.
|
||||
pub struct TurnStream {
|
||||
rx: OwnedMutexGuard<mpsc::Receiver<ClaudeCliEvent>>,
|
||||
watchdog: Duration,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl TurnStream {
|
||||
/// Pull the next CLI event from the child, applying the per-line
|
||||
/// watchdog. Returns `Ok(None)` when the turn's terminal `result` event
|
||||
/// has been delivered.
|
||||
pub async fn next(&mut self) -> Result<Option<ClaudeCliEvent>, ProcessError> {
|
||||
if self.done {
|
||||
return Ok(None);
|
||||
}
|
||||
match time::timeout(self.watchdog, self.rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if matches!(ev, ClaudeCliEvent::Result { .. }) {
|
||||
self.done = true;
|
||||
}
|
||||
Ok(Some(ev))
|
||||
}
|
||||
Ok(None) => {
|
||||
self.done = true;
|
||||
Err(ProcessError::ExitedEarly)
|
||||
}
|
||||
Err(_) => {
|
||||
self.done = true;
|
||||
Err(ProcessError::WatchdogTimeout(self.watchdog))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_model_arg_strips_prefix() {
|
||||
assert_eq!(normalize_model_arg("claude-cli/sonnet"), "sonnet");
|
||||
assert_eq!(
|
||||
normalize_model_arg("claude-cli/claude-opus-4-7"),
|
||||
"claude-opus-4-7"
|
||||
);
|
||||
assert_eq!(normalize_model_arg("sonnet"), "sonnet");
|
||||
}
|
||||
|
||||
// Note: cannot mutate process env in unit tests safely since tests run
|
||||
// in parallel; spawn integration tests cover env behavior end-to-end via
|
||||
// the fake_claude.sh fixture.
|
||||
}
|
||||
335
crates/brightstaff/src/handlers/claude_cli/server.rs
Normal file
335
crates/brightstaff/src/handlers/claude_cli/server.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//! HTTP server fronting the claude-cli bridge. Speaks Anthropic Messages API
|
||||
//! (`POST /v1/messages`) on a localhost port; everything inside this module
|
||||
//! delegates to `hermesllm::apis::claude_cli` for translation and to
|
||||
//! `super::session::SessionManager` for subprocess lifecycle.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::stream;
|
||||
use hermesllm::apis::anthropic::MessagesRequest;
|
||||
use hermesllm::apis::claude_cli::{
|
||||
cli_error_to_anthropic_error_body, cli_event_to_messages_stream_event,
|
||||
collect_to_messages_response, extract_system_prompt, messages_request_to_stdin_payload,
|
||||
synthetic_message_start, ClaudeCliEvent,
|
||||
};
|
||||
use http_body_util::combinators::BoxBody;
|
||||
use http_body_util::{BodyExt, Full, StreamBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::header::{self, HeaderValue};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::session::{SessionManager, SESSION_HEADER};
|
||||
|
||||
/// Spawn the claude-cli bridge listener. The returned `JoinHandle` resolves
|
||||
/// when the listener loop exits (either via the provided shutdown signal or a
|
||||
/// fatal accept error). On shutdown the manager drains all active sessions.
|
||||
pub async fn run_listener<F>(
|
||||
addr: SocketAddr,
|
||||
manager: Arc<SessionManager>,
|
||||
shutdown: F,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
info!(%addr, "claude-cli bridge listening");
|
||||
|
||||
let manager_for_shutdown = Arc::clone(&manager);
|
||||
tokio::pin!(shutdown);
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept = listener.accept() => {
|
||||
let (stream, peer) = match accept {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "claude-cli accept error");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
debug!(peer = ?peer, "claude-cli accepted connection");
|
||||
let manager = Arc::clone(&manager);
|
||||
let io = TokioIo::new(stream);
|
||||
tokio::task::spawn(async move {
|
||||
let svc = service_fn(move |req| {
|
||||
let manager = Arc::clone(&manager);
|
||||
async move { handle(req, manager).await }
|
||||
});
|
||||
if let Err(err) = http1::Builder::new().serve_connection(io, svc).await {
|
||||
warn!(error = ?err, "claude-cli connection error");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = &mut shutdown => {
|
||||
info!("claude-cli bridge shutting down");
|
||||
manager_for_shutdown.shutdown_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
req: Request<Incoming>,
|
||||
manager: Arc<SessionManager>,
|
||||
) -> Result<Response<BoxBody<Bytes, Infallible>>, hyper::Error> {
|
||||
let path = req.uri().path();
|
||||
let method = req.method();
|
||||
if method == Method::GET && path == "/healthz" {
|
||||
return Ok(text_response(StatusCode::OK, "ok"));
|
||||
}
|
||||
if method != Method::POST || path != "/v1/messages" {
|
||||
return Ok(text_response(StatusCode::NOT_FOUND, "not found"));
|
||||
}
|
||||
|
||||
// Pull out the optional session header up front so we can drop the
|
||||
// request after consuming the body.
|
||||
let session_header = req
|
||||
.headers()
|
||||
.get(SESSION_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_bytes = match req.collect().await {
|
||||
Ok(c) => c.to_bytes(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to read claude-cli request body");
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "failed to read body"));
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: MessagesRequest = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to parse Anthropic MessagesRequest");
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid Anthropic MessagesRequest: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let session_id = SessionManager::resolve_session_id(session_header.as_deref(), &parsed);
|
||||
let system_prompt = extract_system_prompt(&parsed);
|
||||
|
||||
let process = match manager
|
||||
.get_or_spawn(&session_id, &parsed.model, system_prompt.as_deref(), None)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
error!(session = %session_id, error = %err, "failed to spawn claude-cli");
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
&format!("failed to spawn claude-cli: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let stdin_payload = match messages_request_to_stdin_payload(&parsed, Some(&session_id)) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to build claude-cli stdin payload");
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("failed to build claude-cli stdin payload: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let streaming = parsed.stream.unwrap_or(false);
|
||||
let model = parsed.model.clone();
|
||||
|
||||
let mut turn = match process.send_user_turn(&stdin_payload).await {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
error!(session = %session_id, error = %err, "failed to send user turn");
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
&format!("failed to send user turn: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if streaming {
|
||||
Ok(stream_response(turn, model, session_id))
|
||||
} else {
|
||||
// Drain the entire turn before answering.
|
||||
let mut events: Vec<ClaudeCliEvent> = Vec::new();
|
||||
loop {
|
||||
match turn.next().await {
|
||||
Ok(Some(ev)) => events.push(ev),
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
warn!(session = %session_id, error = %err, "claude-cli turn failed");
|
||||
let body = cli_error_to_anthropic_error_body(&err.to_string());
|
||||
return Ok(json_response(StatusCode::BAD_GATEWAY, &body));
|
||||
}
|
||||
}
|
||||
}
|
||||
match collect_to_messages_response(&model, events) {
|
||||
Ok(resp) => Ok(json_response(StatusCode::OK, &resp)),
|
||||
Err(err) => {
|
||||
let body = cli_error_to_anthropic_error_body(&err.to_string());
|
||||
Ok(json_response(StatusCode::BAD_GATEWAY, &body))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_response(
|
||||
mut turn: super::process::TurnStream,
|
||||
model: String,
|
||||
session_id: String,
|
||||
) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
let (tx, rx) = mpsc::channel::<Result<Frame<Bytes>, Infallible>>(64);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Some short turns skip MessageStart; emit a synthetic one so the
|
||||
// client always sees a complete stream.
|
||||
let mut emitted_message_start = false;
|
||||
|
||||
loop {
|
||||
let ev = match turn.next().await {
|
||||
Ok(Some(ev)) => ev,
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
warn!(session = %session_id, error = %err, "claude-cli streaming turn failed");
|
||||
let body = cli_error_to_anthropic_error_body(&err.to_string());
|
||||
let frame =
|
||||
Frame::data(format_sse("error", &serde_json::to_string(&body).unwrap()));
|
||||
let _ = tx.send(Ok(frame)).await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if !emitted_message_start {
|
||||
if let ClaudeCliEvent::StreamEvent {
|
||||
event: hermesllm::apis::anthropic::MessagesStreamEvent::MessageStart { .. },
|
||||
} = &ev
|
||||
{
|
||||
emitted_message_start = true;
|
||||
} else if matches!(&ev, ClaudeCliEvent::Result { .. }) {
|
||||
// No actual content was streamed; synthesize a
|
||||
// MessageStart so the SSE stream is well-formed.
|
||||
let synthetic = synthetic_message_start(&model, Some(&session_id));
|
||||
if let Some(frame) = sse_frame_for_event(&synthetic) {
|
||||
let _ = tx.send(Ok(frame)).await;
|
||||
}
|
||||
emitted_message_start = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(translated) = cli_event_to_messages_stream_event(&ev) {
|
||||
if let Some(frame) = sse_frame_for_event(&translated) {
|
||||
if tx.send(Ok(frame)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let ClaudeCliEvent::Result {
|
||||
is_error, result, ..
|
||||
} = &ev
|
||||
{
|
||||
if *is_error {
|
||||
let msg = result
|
||||
.clone()
|
||||
.unwrap_or_else(|| "claude-cli returned an error".to_string());
|
||||
let body = cli_error_to_anthropic_error_body(&msg);
|
||||
let frame =
|
||||
Frame::data(format_sse("error", &serde_json::to_string(&body).unwrap()));
|
||||
let _ = tx.send(Ok(frame)).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let body = StreamBody::new(ReceiverStream::new(rx));
|
||||
let mut resp = Response::new(body.boxed());
|
||||
*resp.status_mut() = StatusCode::OK;
|
||||
resp.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
resp.headers_mut()
|
||||
.insert("X-Accel-Buffering", HeaderValue::from_static("no"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn sse_frame_for_event(
|
||||
event: &hermesllm::apis::anthropic::MessagesStreamEvent,
|
||||
) -> Option<Frame<Bytes>> {
|
||||
use hermesllm::apis::anthropic::MessagesStreamEvent;
|
||||
let event_name = match event {
|
||||
MessagesStreamEvent::MessageStart { .. } => "message_start",
|
||||
MessagesStreamEvent::ContentBlockStart { .. } => "content_block_start",
|
||||
MessagesStreamEvent::ContentBlockDelta { .. } => "content_block_delta",
|
||||
MessagesStreamEvent::ContentBlockStop { .. } => "content_block_stop",
|
||||
MessagesStreamEvent::MessageDelta { .. } => "message_delta",
|
||||
MessagesStreamEvent::MessageStop => "message_stop",
|
||||
MessagesStreamEvent::Ping => "ping",
|
||||
};
|
||||
let data = serde_json::to_string(event).ok()?;
|
||||
Some(Frame::data(format_sse(event_name, &data)))
|
||||
}
|
||||
|
||||
fn format_sse(event: &str, data: &str) -> Bytes {
|
||||
Bytes::from(format!("event: {event}\ndata: {data}\n\n"))
|
||||
}
|
||||
|
||||
fn json_response<T: serde::Serialize>(
|
||||
status: StatusCode,
|
||||
body: &T,
|
||||
) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
|
||||
let body = Full::new(Bytes::from(bytes))
|
||||
.map_err(|e| match e {})
|
||||
.boxed();
|
||||
let mut resp = Response::new(body);
|
||||
*resp.status_mut() = status;
|
||||
resp.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, message: &str) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
let body = cli_error_to_anthropic_error_body(message);
|
||||
json_response(status, &body)
|
||||
}
|
||||
|
||||
fn text_response(
|
||||
status: StatusCode,
|
||||
message: &'static str,
|
||||
) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
let body = Full::new(Bytes::from_static(message.as_bytes()))
|
||||
.map_err(|e| match e {})
|
||||
.boxed();
|
||||
let mut resp = Response::new(body);
|
||||
*resp.status_mut() = status;
|
||||
resp.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
|
||||
resp
|
||||
}
|
||||
|
||||
// Ensure a no-op import so that `stream` (re-exported from futures) is
|
||||
// considered used in case future expansion needs it. Avoids accidental
|
||||
// deletion when running `cargo fix`.
|
||||
#[allow(dead_code)]
|
||||
fn _touch_stream_module() {
|
||||
let _: stream::Empty<u32> = stream::empty();
|
||||
}
|
||||
341
crates/brightstaff/src/handlers/claude_cli/session.rs
Normal file
341
crates/brightstaff/src/handlers/claude_cli/session.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
//! Session manager for the claude-cli bridge. Maps a stable session id (taken
|
||||
//! from a client-provided header or hashed from the conversation prefix) to a
|
||||
//! long-lived `ClaudeProcess`. Enforces an idle TTL and a hard cap on the
|
||||
//! number of concurrent sessions.
|
||||
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hermesllm::apis::anthropic::{
|
||||
MessagesContentBlock, MessagesMessageContent, MessagesRequest, MessagesRole,
|
||||
MessagesSystemPrompt,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::process::{ClaudeCliConfig, ClaudeProcess, ProcessError};
|
||||
|
||||
/// Optional client header that pins a request to a specific session id.
|
||||
pub const SESSION_HEADER: &str = "x-arch-claude-cli-session";
|
||||
|
||||
/// Default cap. The bridge is local and per-developer; this is a guard
|
||||
/// against runaway memory if a client bug churns through unique session ids.
|
||||
pub const DEFAULT_MAX_SESSIONS: usize = 64;
|
||||
|
||||
/// Tunables for the session manager.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionManagerConfig {
|
||||
pub max_sessions: usize,
|
||||
pub process: ClaudeCliConfig,
|
||||
}
|
||||
|
||||
impl Default for SessionManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_sessions: DEFAULT_MAX_SESSIONS,
|
||||
process: ClaudeCliConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds active `ClaudeProcess` handles keyed by session id.
|
||||
pub struct SessionManager {
|
||||
inner: Mutex<HashMap<String, Arc<ClaudeProcess>>>,
|
||||
config: SessionManagerConfig,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(config: SessionManagerConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick (or fabricate) the session id for a given request.
|
||||
///
|
||||
/// Strategy (in order):
|
||||
/// 1. Honor the `x-arch-claude-cli-session` header if it's a non-empty
|
||||
/// valid UUID-shaped string.
|
||||
/// 2. Otherwise hash `(model, system_prompt_text, first_user_message_text)`
|
||||
/// and produce a deterministic UUID-shaped id so retries of the same
|
||||
/// conversation reuse the same process.
|
||||
pub fn resolve_session_id(client_header: Option<&str>, req: &MessagesRequest) -> String {
|
||||
if let Some(raw) = client_header {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
// Accept any opaque token; the CLI requires UUID format, so
|
||||
// we hash unknown shapes into one.
|
||||
if uuid::Uuid::parse_str(trimmed).is_ok() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
return uuid_from_seed(trimmed);
|
||||
}
|
||||
}
|
||||
let mut hasher = DefaultHasher::new();
|
||||
req.model.hash(&mut hasher);
|
||||
if let Some(system) = &req.system {
|
||||
system_text(system).hash(&mut hasher);
|
||||
}
|
||||
if let Some(first) = first_user_message_text(req) {
|
||||
first.hash(&mut hasher);
|
||||
}
|
||||
uuid_from_seed(&hasher.finish().to_string())
|
||||
}
|
||||
|
||||
/// Get the existing session's process or spawn a new one.
|
||||
pub async fn get_or_spawn(
|
||||
&self,
|
||||
session_id: &str,
|
||||
model: &str,
|
||||
system_prompt: Option<&str>,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) -> Result<Arc<ClaudeProcess>, ProcessError> {
|
||||
// Reap idle sessions on the read path so we don't need a separate
|
||||
// background task for the common one-developer-one-laptop deployment.
|
||||
self.evict_idle().await;
|
||||
|
||||
{
|
||||
let map = self.inner.lock().await;
|
||||
if let Some(existing) = map.get(session_id) {
|
||||
debug!(session = %session_id, "reusing claude-cli session");
|
||||
return Ok(Arc::clone(existing));
|
||||
}
|
||||
}
|
||||
|
||||
let mut map = self.inner.lock().await;
|
||||
if let Some(existing) = map.get(session_id) {
|
||||
return Ok(Arc::clone(existing));
|
||||
}
|
||||
|
||||
if map.len() >= self.config.max_sessions {
|
||||
// Evict the least-recently-used session to keep the cap honest.
|
||||
if let Some(victim_key) = lru_session_id(&map).await {
|
||||
if let Some(victim) = map.remove(&victim_key) {
|
||||
info!(session = %victim_key, "evicting LRU claude-cli session to make room");
|
||||
drop(map);
|
||||
victim.shutdown().await;
|
||||
map = self.inner.lock().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let process = ClaudeProcess::spawn(
|
||||
session_id.to_string(),
|
||||
model,
|
||||
system_prompt,
|
||||
cwd,
|
||||
self.config.process.clone(),
|
||||
)
|
||||
.await?;
|
||||
map.insert(session_id.to_string(), Arc::clone(&process));
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
/// Drop and kill all sessions. Called on graceful shutdown.
|
||||
pub async fn shutdown_all(&self) {
|
||||
let mut map = self.inner.lock().await;
|
||||
let drained: Vec<_> = map.drain().collect();
|
||||
drop(map);
|
||||
info!(count = drained.len(), "draining claude-cli sessions");
|
||||
for (_, proc) in drained {
|
||||
proc.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn evict_idle(&self) {
|
||||
let ttl = self.config.process.session_ttl;
|
||||
if ttl.is_zero() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut to_kill: Vec<(String, Arc<ClaudeProcess>)> = Vec::new();
|
||||
{
|
||||
let map = self.inner.lock().await;
|
||||
for (k, v) in map.iter() {
|
||||
if now.duration_since(v.last_used().await) > ttl {
|
||||
to_kill.push((k.clone(), Arc::clone(v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if to_kill.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut map = self.inner.lock().await;
|
||||
for (k, _) in &to_kill {
|
||||
map.remove(k);
|
||||
}
|
||||
drop(map);
|
||||
for (k, proc) in to_kill {
|
||||
info!(session = %k, "evicting idle claude-cli session");
|
||||
proc.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn lru_session_id(map: &HashMap<String, Arc<ClaudeProcess>>) -> Option<String> {
|
||||
let mut oldest: Option<(String, Instant)> = None;
|
||||
for (k, v) in map.iter() {
|
||||
let used = v.last_used().await;
|
||||
match &oldest {
|
||||
Some((_, t)) if *t < used => {}
|
||||
_ => oldest = Some((k.clone(), used)),
|
||||
}
|
||||
}
|
||||
oldest.map(|(k, _)| k)
|
||||
}
|
||||
|
||||
fn first_user_message_text(req: &MessagesRequest) -> Option<String> {
|
||||
for msg in &req.messages {
|
||||
if msg.role != MessagesRole::User {
|
||||
continue;
|
||||
}
|
||||
return Some(match &msg.content {
|
||||
MessagesMessageContent::Single(s) => s.clone(),
|
||||
MessagesMessageContent::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
MessagesContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn system_text(system: &MessagesSystemPrompt) -> String {
|
||||
match system {
|
||||
MessagesSystemPrompt::Single(s) => s.clone(),
|
||||
MessagesSystemPrompt::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
MessagesContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic v5-style UUID derived from an arbitrary seed string. The
|
||||
/// `claude` CLI requires `--session-id` to be a valid UUID; we use the DNS
|
||||
/// namespace constant as a stable salt so the same conversation always maps
|
||||
/// to the same id without us pulling in the v5 feature of the `uuid` crate.
|
||||
fn uuid_from_seed(seed: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
seed.hash(&mut hasher);
|
||||
let h1 = hasher.finish();
|
||||
let mut hasher2 = DefaultHasher::new();
|
||||
h1.hash(&mut hasher2);
|
||||
seed.hash(&mut hasher2);
|
||||
let h2 = hasher2.finish();
|
||||
let bytes = [
|
||||
(h1 >> 56) as u8,
|
||||
(h1 >> 48) as u8,
|
||||
(h1 >> 40) as u8,
|
||||
(h1 >> 32) as u8,
|
||||
(h1 >> 24) as u8,
|
||||
(h1 >> 16) as u8,
|
||||
(h1 >> 8) as u8,
|
||||
h1 as u8,
|
||||
(h2 >> 56) as u8,
|
||||
(h2 >> 48) as u8,
|
||||
(h2 >> 40) as u8,
|
||||
(h2 >> 32) as u8,
|
||||
(h2 >> 24) as u8,
|
||||
(h2 >> 16) as u8,
|
||||
(h2 >> 8) as u8,
|
||||
h2 as u8,
|
||||
];
|
||||
uuid::Builder::from_random_bytes(bytes)
|
||||
.into_uuid()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// `Duration::is_zero` shim — `Duration` exposes `is_zero` only on stable
|
||||
/// 1.53+, but our MSRV already covers that. Re-exporting keeps call sites
|
||||
/// terse if we ever need to swap implementations.
|
||||
#[allow(dead_code)]
|
||||
fn is_zero(d: Duration) -> bool {
|
||||
d.is_zero()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hermesllm::apis::anthropic::MessagesMessage;
|
||||
|
||||
fn req(model: &str, user: &str, system: Option<&str>) -> MessagesRequest {
|
||||
MessagesRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![MessagesMessage {
|
||||
role: MessagesRole::User,
|
||||
content: MessagesMessageContent::Single(user.to_string()),
|
||||
}],
|
||||
max_tokens: 1024,
|
||||
container: None,
|
||||
mcp_servers: None,
|
||||
system: system.map(|s| MessagesSystemPrompt::Single(s.to_string())),
|
||||
metadata: None,
|
||||
service_tier: None,
|
||||
thinking: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
stream: Some(true),
|
||||
stop_sequences: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_uuid_is_used_as_is() {
|
||||
let id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let r = req("sonnet", "hi", None);
|
||||
assert_eq!(SessionManager::resolve_session_id(Some(id), &r), id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_non_uuid_is_normalized_to_uuid() {
|
||||
let r = req("sonnet", "hi", None);
|
||||
let id = SessionManager::resolve_session_id(Some("my-token"), &r);
|
||||
assert!(uuid::Uuid::parse_str(&id).is_ok());
|
||||
let id2 = SessionManager::resolve_session_id(Some("my-token"), &r);
|
||||
assert_eq!(id, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_header_falls_back_to_hash() {
|
||||
let r = req("sonnet", "hi", Some("you are helpful"));
|
||||
let id = SessionManager::resolve_session_id(Some(""), &r);
|
||||
assert!(uuid::Uuid::parse_str(&id).is_ok());
|
||||
let id2 = SessionManager::resolve_session_id(None, &r);
|
||||
assert_eq!(id, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable_across_repeats_and_distinct_across_inputs() {
|
||||
let r1 = req("sonnet", "hello", None);
|
||||
let r2 = req("sonnet", "hello", None);
|
||||
let r3 = req("sonnet", "different", None);
|
||||
let r4 = req("opus", "hello", None);
|
||||
assert_eq!(
|
||||
SessionManager::resolve_session_id(None, &r1),
|
||||
SessionManager::resolve_session_id(None, &r2)
|
||||
);
|
||||
assert_ne!(
|
||||
SessionManager::resolve_session_id(None, &r1),
|
||||
SessionManager::resolve_session_id(None, &r3)
|
||||
);
|
||||
assert_ne!(
|
||||
SessionManager::resolve_session_id(None, &r1),
|
||||
SessionManager::resolve_session_id(None, &r4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod agents;
|
||||
pub mod claude_cli;
|
||||
pub mod debug;
|
||||
pub mod function_calling;
|
||||
pub mod llm;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
|||
|
||||
use brightstaff::app_state::AppState;
|
||||
use brightstaff::handlers::agents::orchestrator::agent_chat;
|
||||
use brightstaff::handlers::claude_cli::{
|
||||
self, ClaudeCliConfig, SessionManager, SessionManagerConfig,
|
||||
};
|
||||
use brightstaff::handlers::debug;
|
||||
use brightstaff::handlers::empty;
|
||||
use brightstaff::handlers::function_calling::function_calling_chat_handler;
|
||||
|
|
@ -37,6 +40,7 @@ use opentelemetry::trace::FutureExt;
|
|||
use opentelemetry_http::HeaderExtractor;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{env, fs};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
|
|
@ -575,6 +579,57 @@ async fn run_server(state: Arc<AppState>) -> Result<(), Box<dyn std::error::Erro
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// claude-cli bridge wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the [`SessionManagerConfig`] from environment variables. Returns
|
||||
/// `None` when `CLAUDE_CLI_LISTEN_ADDR` is unset, signaling that the bridge
|
||||
/// should not start at all (zero-cost when no claude-cli provider exists).
|
||||
fn claude_cli_config_from_env() -> Option<(std::net::SocketAddr, SessionManagerConfig)> {
|
||||
let addr_str = env::var("CLAUDE_CLI_LISTEN_ADDR").ok()?;
|
||||
let addr: std::net::SocketAddr = match addr_str.parse() {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
value = %addr_str,
|
||||
error = %err,
|
||||
"invalid CLAUDE_CLI_LISTEN_ADDR — claude-cli bridge disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let binary = env::var("CLAUDE_CLI_BIN").unwrap_or_else(|_| "claude".to_string());
|
||||
let permission_mode =
|
||||
env::var("CLAUDE_CLI_PERMISSION_MODE").unwrap_or_else(|_| "bypassPermissions".to_string());
|
||||
let session_ttl = env::var("CLAUDE_CLI_SESSION_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_else(|| Duration::from_secs(600));
|
||||
let watchdog = env::var("CLAUDE_CLI_WATCHDOG_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_else(|| Duration::from_secs(120));
|
||||
let max_sessions = env::var("CLAUDE_CLI_MAX_SESSIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(claude_cli::session::DEFAULT_MAX_SESSIONS);
|
||||
Some((
|
||||
addr,
|
||||
SessionManagerConfig {
|
||||
max_sessions,
|
||||
process: ClaudeCliConfig {
|
||||
binary,
|
||||
permission_mode,
|
||||
session_ttl,
|
||||
watchdog,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -586,5 +641,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
bs_metrics::init();
|
||||
info!("loaded plano_config.yaml");
|
||||
let state = Arc::new(init_app_state(&config).await?);
|
||||
run_server(state).await
|
||||
|
||||
// Optional claude-cli bridge listener. Started iff CLAUDE_CLI_LISTEN_ADDR
|
||||
// is set in the environment (the Python CLI sets this when it detects a
|
||||
// `model: claude-cli/*` provider entry).
|
||||
let bridge_handle = if let Some((addr, cfg)) = claude_cli_config_from_env() {
|
||||
let manager = SessionManager::new(cfg);
|
||||
let shutdown = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
Some(tokio::spawn(async move {
|
||||
if let Err(err) = claude_cli::run_listener(addr, manager, shutdown).await {
|
||||
warn!(error = ?err, "claude-cli bridge listener exited with error");
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = run_server(state).await;
|
||||
|
||||
if let Some(handle) = bridge_handle {
|
||||
// Ctrl-C already triggered the bridge's own shutdown; join briefly to
|
||||
// give in-flight session drains a chance to finish.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue