use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use rho_providers::{model::ReasoningLevelSet, reasoning::ReasoningLevel};
use crate::{agent::PromptPolicy, permission::PermissionMode};
pub(crate) const SYSTEM_PROMPT_FILE_NAME: &str = "system-prompt.txt";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ClaudePermissionMode {
Plan,
BypassPermissions,
DontAsk,
}
impl ClaudePermissionMode {
pub(crate) const fn as_cli_flag(self) -> &'static str {
match self {
Self::Plan => "plan",
Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
}
}
}
#[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: ClaudePermissionMode,
pub(crate) cwd: PathBuf,
pub(crate) max_turns: u64,
pub(crate) effort: Option<&'static str>,
pub(crate) session_persistence: SessionPersistence,
pub(crate) input_format: ClaudeInputFormat,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ClaudeInputFormat {
Text,
StreamJson,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SessionPersistence {
Keep,
Discard,
}
#[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 argv_flag(&self) -> Option<&'static str> {
match self {
Self::Omit => None,
Self::Replace(_) => Some("--system-prompt"),
Self::Extend(_) => Some("--append-system-prompt"),
}
}
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 Auto or Supervised permission mode: \
claude -p cannot prompt for approval. Switch to Plan or Bypass, 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<ClaudePermissionMode, ClaudeSpawnError> {
match mode {
PermissionMode::Bypass => Ok(ClaudePermissionMode::BypassPermissions),
PermissionMode::Plan => Ok(ClaudePermissionMode::Plan),
PermissionMode::Auto | 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) static CLAUDE_EFFORT_LEVELS: LazyLock<ReasoningLevelSet> = LazyLock::new(|| {
ReasoningLevelSet::new(
ReasoningLevel::ALL
.into_iter()
.filter(|level| claude_effort_flag(*level).is_some())
.collect(),
)
});
pub(crate) fn build_spawn_plan(request: &ClaudeSpawnRequest) -> ClaudeSpawnPlan {
let permission_mode = request.permission_mode.as_cli_flag();
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 matches!(request.input_format, ClaudeInputFormat::StreamJson) {
args.push("--input-format".into());
args.push("stream-json".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());
match request.session_persistence {
SessionPersistence::Discard => args.push("--no-session-persistence".into()),
SessionPersistence::Keep => {}
}
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());
}
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)
}
pub(crate) fn inline_prompt_args(plan: &ClaudeSpawnPlan) -> Vec<OsString> {
let mut args: Vec<OsString> = plan.args.iter().map(OsString::from).collect();
let Some(flag) = plan.system_prompt.argv_flag() else {
return args;
};
let text = plan
.system_prompt
.text()
.expect("argv flag implies prompt text");
args.push(OsString::from(flag));
args.push(OsString::from(text));
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;