use std::{path::PathBuf, process::Stdio};
use rho_sdk::{model::ModelUsage, CancellationToken};
use tokio::sync::watch;
use crate::agent::{OneShotPhase, OneShotUpdate, PromptPolicy};
use super::{
auth::{self, ClaudeAuthError},
child::OwnedChild,
drain::{self, DrainEnd},
executable,
spawn::{self, ClaudePermissionMode, ClaudeSpawnRequest, SessionPersistence},
stream::{StreamEffect, TerminalResult},
terminal::{assess_terminal, TerminalOutcome},
};
pub(crate) const CANCELLATION_ERROR: &str = "claude code: cancelled";
pub(crate) const ONE_SHOT_PERMISSION_MODE: ClaudePermissionMode = ClaudePermissionMode::DontAsk;
pub(crate) struct ClaudeOneShotRequest {
pub(crate) system_prompt: &'static str,
pub(crate) input: String,
pub(crate) model: Option<String>,
pub(crate) effort: Option<&'static str>,
pub(crate) cwd: PathBuf,
pub(crate) cancellation: CancellationToken,
}
pub(crate) struct ClaudeOneShotResult {
pub(crate) text: String,
pub(crate) usage: ModelUsage,
}
pub(crate) async fn run_one_shot(
request: ClaudeOneShotRequest,
updates: Option<watch::Sender<OneShotUpdate>>,
) -> Result<ClaudeOneShotResult, String> {
let mut stream = OneShotStream::new(updates);
stream.publish(OneShotPhase::WaitingForProvider);
match auth::query().await {
Ok(status) if status.logged_in => {}
Ok(_) => return Err("claude code: not signed in - run /login claude-code".into()),
Err(ClaudeAuthError::BinaryMissing) => {
return Err(ClaudeAuthError::BinaryMissing.to_string())
}
Err(error) => return Err(format!("claude code: auth preflight failed: {error}")),
}
let executable = executable::resolve().map_err(|error| error.to_string())?;
let plan = spawn::build_spawn_plan(&one_shot_spawn_request(&request));
let mut command = executable
.try_command(spawn::inline_prompt_args(&plan))
.map_err(|error| error.to_string())?;
command
.current_dir(&plan.cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = OwnedChild::spawn(command).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ClaudeAuthError::BinaryMissing.to_string()
} else {
format!(
"claude code: failed to spawn `{}`: {error}",
executable.display()
)
}
})?;
let mut text = String::new();
let drained = {
let mut on_effect = |effect| apply_effect(effect, &mut text, &mut stream);
drain::drain_child(
&mut child,
drain::DrainInput::Text {
prompt: request.input.clone(),
},
&request.cancellation,
&mut on_effect,
)
.await
};
if !matches!(drained.end, DrainEnd::Exited(Ok(_))) {
child.terminate().await;
}
match drained.end {
DrainEnd::Cancelled => Err(CANCELLATION_ERROR.into()),
DrainEnd::StdinFailed(error) | DrainEnd::StreamFailed(error) => Err(error),
DrainEnd::Exited(Err(error)) => {
Err(format!("claude code: failed waiting for child: {error}"))
}
DrainEnd::Exited(Ok(status)) => finish(text, drained.terminal, &drained.stderr, status),
}
}
fn one_shot_spawn_request(request: &ClaudeOneShotRequest) -> ClaudeSpawnRequest {
ClaudeSpawnRequest {
system_prompt: PromptPolicy::Replace(request.system_prompt.to_string()),
model: request.model.clone(),
tools: Vec::new(),
inherit_claude_config: false,
permission_mode: ONE_SHOT_PERMISSION_MODE,
cwd: request.cwd.clone(),
max_turns: 1,
effort: request.effort,
session_persistence: SessionPersistence::Discard,
input_format: spawn::ClaudeInputFormat::Text,
}
}
fn finish(
text: String,
terminal: Option<TerminalResult>,
stderr: &str,
status: std::process::ExitStatus,
) -> Result<ClaudeOneShotResult, String> {
let terminal = match assess_terminal(terminal, status, stderr) {
TerminalOutcome::Success(terminal) => terminal,
TerminalOutcome::Failure { detail, .. } => return Err(detail),
};
let answer = terminal
.result_text
.filter(|value| !value.trim().is_empty())
.unwrap_or(text);
let mut usage = terminal.usage.unwrap_or_default();
if let Some(cost) = terminal.total_cost_usd.filter(|cost| *cost > 0.0) {
usage.cost_usd_micros = Some((cost * 1_000_000.0).round() as u64);
}
Ok(ClaudeOneShotResult {
text: answer.trim().to_string(),
usage,
})
}
fn apply_effect(effect: StreamEffect, text: &mut String, stream: &mut OneShotStream) {
match effect {
StreamEffect::Status(patch) => {
if let Some(appended) = patch.append_text {
text.push_str(&appended);
stream.publish_text(OneShotPhase::Responding, text);
} else if patch.last_activity.as_deref() == Some("reasoning") {
stream.publish(OneShotPhase::Thinking);
}
}
StreamEffect::Terminal(_) | StreamEffect::Attachment(_) | StreamEffect::RateLimit(_) => {}
}
}
struct OneShotStream {
updates: Option<watch::Sender<OneShotUpdate>>,
text: String,
}
impl OneShotStream {
fn new(updates: Option<watch::Sender<OneShotUpdate>>) -> Self {
Self {
updates,
text: String::new(),
}
}
fn publish(&mut self, phase: OneShotPhase) {
let text = self.text.clone();
self.send(phase, &text);
}
fn publish_text(&mut self, phase: OneShotPhase, text: &str) {
self.text = text.to_string();
self.send(phase, text);
}
fn send(&self, phase: OneShotPhase, text: &str) {
if let Some(updates) = &self.updates {
let _ = updates.send(OneShotUpdate::new(phase, text));
}
}
}
#[cfg(test)]
#[path = "one_shot_tests.rs"]
mod tests;