use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use rho_providers::{model::ReasoningLevelSet, reasoning::ReasoningLevel};
use rho_sdk::{CapabilityKind, PolicyDecision};
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 Allow edits unless the child stays \
on its declared tools: Claude dontAsk also auto-approves read-only Bash and \
PreToolUse hooks. Rho therefore refuses when inherit_claude_config is true, \
when a tool uses a specifier (for example Bash(git *)), or when any declared \
tool is not proven to stay inside that Rho approval class. Unknown Claude, \
plugin, and MCP names fail closed. Switch to Plan or Bypass, use only proven \
no-prompt tools, or disable inherited Claude config."
)]
DontAskUnbound,
#[error(
"claude-cli agents cannot run in Supervised permission mode: \
claude -p cannot prompt interactively for approval. Switch to Auto, Allow edits, Plan, or Bypass, or change the agent. \
Auto and Allow edits only work when every tool is proven not to bypass that \
Rho approval class and inherit_claude_config is false."
)]
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,
tools: &[String],
inherit_claude_config: bool,
) -> Result<ClaudePermissionMode, ClaudeSpawnError> {
match mode {
PermissionMode::Plan => Ok(ClaudePermissionMode::Plan),
PermissionMode::Bypass => Ok(ClaudePermissionMode::BypassPermissions),
PermissionMode::Auto | PermissionMode::AllowEdits => {
if dont_ask_preserves_bound_set(mode, tools, inherit_claude_config) {
Ok(ClaudePermissionMode::DontAsk)
} else {
Err(ClaudeSpawnError::DontAskUnbound)
}
}
PermissionMode::Supervised => Err(ClaudeSpawnError::SupervisedUnsupported),
}
}
fn dont_ask_preserves_bound_set(
mode: PermissionMode,
tools: &[String],
inherit_claude_config: bool,
) -> bool {
!inherit_claude_config
&& tools
.iter()
.all(|tool| dont_ask_tool_preserves_bound_set(mode, tool))
}
fn dont_ask_tool_preserves_bound_set(mode: PermissionMode, tool: &str) -> bool {
let base = tool_base_name(tool);
if base.eq_ignore_ascii_case("Task") {
return true;
}
base == tool && dont_ask_bare_tool_preserves_approval_boundary(mode, base)
}
fn dont_ask_bare_tool_preserves_approval_boundary(mode: PermissionMode, base: &str) -> bool {
let Some(kind) = claude_tool_capability_kind(base) else {
return false;
};
matches!(mode.decision_for(kind), PolicyDecision::Allow)
}
fn claude_tool_capability_kind(base: &str) -> Option<CapabilityKind> {
Some(match base.to_ascii_lowercase().as_str() {
"read" | "glob" | "grep" | "lsp" => CapabilityKind::Read,
"webfetch" | "websearch" => CapabilityKind::Network,
"edit" | "write" | "notebookedit" => CapabilityKind::Write,
"bash" | "powershell" | "monitor" | "skill" => CapabilityKind::Process,
_ => return None,
})
}
const FROZEN_IDENTITY_FLAGS: &[&str] = &["--model", "--effort", "--max-turns"];
pub(crate) fn apply_frozen_identity_args(
mut generated: Vec<String>,
frozen: &[String],
) -> Vec<String> {
for flag in FROZEN_IDENTITY_FLAGS {
if let Some(value) = single_flag_value(frozen, flag) {
set_single_flag_value(&mut generated, flag, value);
}
}
generated
}
fn single_flag_value(args: &[String], flag: &str) -> Option<String> {
args.windows(2)
.find(|pair| pair[0] == flag)
.map(|pair| pair[1].clone())
}
fn set_single_flag_value(args: &mut Vec<String>, flag: &str, value: String) {
if let Some(index) = args.iter().position(|arg| arg == flag) {
if index + 1 < args.len() {
args[index + 1] = value;
return;
}
}
args.push((*flag).to_string());
args.push(value);
}
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 matches!(request.permission_mode, ClaudePermissionMode::DontAsk) {
""
} else 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;