use std::ffi::OsString;
use std::path::{Path, PathBuf};
use rho_providers::reasoning::ReasoningLevel;
use crate::{agent::PromptPolicy, permission::PermissionMode};
pub(crate) const SYSTEM_PROMPT_FILE_NAME: &str = "system-prompt.txt";
#[derive(Clone, Debug)]
pub(crate) struct ClaudeSpawnRequest {
pub(crate) system_prompt: PromptPolicy,
pub(crate) model: Option<String>,
pub(crate) tools: Vec<String>,
pub(crate) inherit_claude_config: bool,
pub(crate) permission_mode: PermissionMode,
pub(crate) cwd: PathBuf,
pub(crate) max_turns: u64,
pub(crate) effort: Option<&'static str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SystemPromptPlan {
Omit,
Replace(String),
Extend(String),
}
impl SystemPromptPlan {
pub(crate) fn file_flag(&self) -> Option<&'static str> {
match self {
Self::Omit => None,
Self::Replace(_) => Some("--system-prompt-file"),
Self::Extend(_) => Some("--append-system-prompt-file"),
}
}
pub(crate) fn text(&self) -> Option<&str> {
match self {
Self::Omit => None,
Self::Replace(text) | Self::Extend(text) => Some(text.as_str()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ClaudeSpawnPlan {
pub(crate) args: Vec<String>,
pub(crate) cwd: PathBuf,
pub(crate) system_prompt: SystemPromptPlan,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum ClaudeSpawnError {
#[error(
"claude-cli agents cannot run in Supervised permission mode: \
claude -p cannot prompt for approval. Switch to Plan or Auto, or change the agent."
)]
SupervisedUnsupported,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClaudeSpawnMaterializeError {
#[error("claude code: could not write system prompt file `{}`: {source}", crate::paths::display(.path))]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub(crate) fn map_permission_mode(mode: PermissionMode) -> Result<&'static str, ClaudeSpawnError> {
match mode {
PermissionMode::Plan => Ok("plan"),
PermissionMode::Auto => Ok("dontAsk"),
PermissionMode::Supervised => Err(ClaudeSpawnError::SupervisedUnsupported),
}
}
pub(crate) fn claude_effort_flag(level: ReasoningLevel) -> Option<&'static str> {
match level {
ReasoningLevel::Off | ReasoningLevel::Minimal => None,
ReasoningLevel::Low => Some("low"),
ReasoningLevel::Medium => Some("medium"),
ReasoningLevel::High => Some("high"),
ReasoningLevel::Xhigh => Some("xhigh"),
ReasoningLevel::Max => Some("max"),
}
}
pub(crate) fn build_spawn_plan(
request: &ClaudeSpawnRequest,
) -> Result<ClaudeSpawnPlan, ClaudeSpawnError> {
let permission_mode = map_permission_mode(request.permission_mode)?;
let system_prompt = system_prompt_plan(&request.system_prompt);
let setting_sources = if request.inherit_claude_config {
"user,project,local"
} else {
"project"
};
let tool_base_names = tool_base_names(&request.tools);
let allowed_tool_entries = allowed_tool_entries(&request.tools);
let mut args = vec![
"-p".into(),
"--output-format".into(),
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--permission-mode".into(),
permission_mode.into(),
"--disallowedTools".into(),
"Task".into(),
"--setting-sources".into(),
setting_sources.into(),
"--strict-mcp-config".into(),
];
if let Some(model) = &request.model {
args.push("--model".into());
args.push(model.clone());
}
if let Some(effort) = request.effort {
args.push("--effort".into());
args.push(effort.into());
}
args.push("--max-turns".into());
args.push(request.max_turns.to_string());
args.push("--tools".into());
if tool_base_names.is_empty() {
args.push(String::new());
} else {
args.push(tool_base_names.join(","));
}
if !allowed_tool_entries.is_empty() {
args.push("--allowedTools".into());
args.extend(allowed_tool_entries.iter().cloned());
}
Ok(ClaudeSpawnPlan {
args,
cwd: request.cwd.clone(),
system_prompt,
})
}
pub(crate) fn finalize_spawn_args(
plan: &ClaudeSpawnPlan,
output_file: &Path,
) -> Result<Vec<OsString>, ClaudeSpawnMaterializeError> {
let mut args: Vec<OsString> = plan.args.iter().map(OsString::from).collect();
let Some(flag) = plan.system_prompt.file_flag() else {
return Ok(args);
};
let text = plan
.system_prompt
.text()
.expect("file flag implies prompt text");
let path = system_prompt_path(output_file);
crate::config_writer::write_bytes_atomically(&path, text.as_bytes()).map_err(|source| {
ClaudeSpawnMaterializeError::Write {
path: path.clone(),
source,
}
})?;
args.push(OsString::from(flag));
args.push(path.into_os_string());
Ok(args)
}
fn system_prompt_plan(prompt: &PromptPolicy) -> SystemPromptPlan {
match prompt {
PromptPolicy::Replace(text) => SystemPromptPlan::Replace(text.clone()),
PromptPolicy::Extend(text) if text.is_empty() => SystemPromptPlan::Omit,
PromptPolicy::Extend(text) => SystemPromptPlan::Extend(text.clone()),
}
}
fn tool_base_names(tools: &[String]) -> Vec<String> {
let mut names = Vec::new();
for tool in tools {
let base = tool_base_name(tool);
if base.eq_ignore_ascii_case("Task") {
continue;
}
if !names.iter().any(|existing| existing == base) {
names.push(base.to_string());
}
}
names
}
fn allowed_tool_entries(tools: &[String]) -> Vec<String> {
tools
.iter()
.filter(|tool| !tool_base_name(tool).eq_ignore_ascii_case("Task"))
.cloned()
.collect()
}
fn tool_base_name(tool: &str) -> &str {
tool.split_once('(').map_or(tool, |(base, _)| base)
}
pub(crate) fn looks_like_max_turns_unsupported(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("max-turns")
&& (lower.contains("unknown")
|| lower.contains("unexpected")
|| lower.contains("unrecognized")
|| lower.contains("invalid"))
}
pub(crate) fn log_path(output_file: &Path) -> PathBuf {
output_file.with_file_name(crate::subagent::LOG_FILE_NAME)
}
pub(crate) fn system_prompt_path(output_file: &Path) -> PathBuf {
output_file.with_file_name(SYSTEM_PROMPT_FILE_NAME)
}
#[cfg(test)]
#[path = "spawn_tests.rs"]
mod tests;