//! The runtime boundary: typed execution modes and options. Crates that provide the //! real runtime plug in behind [`Runtime::run`] at merge time. use std::path::PathBuf; use crate::args::{Args, AutonomousConfig, Mode}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { Interactive, Print, Json, Rpc, Acp, Daemon, } impl AppMode { /// Resolve the execution mode from parsed args and stdin TTY /// state. #[must_use] pub fn resolve(parsed: &Args, stdin_is_tty: bool) -> AppMode { match parsed.mode { Some(Mode::Daemon) => AppMode::Daemon, Some(Mode::Rpc) => AppMode::Rpc, Some(Mode::Acp) => AppMode::Acp, Some(Mode::Json) => AppMode::Json, _ => { if parsed.print || !stdin_is_tty { AppMode::Print } else { AppMode::Interactive } } } } #[must_use] pub fn as_str(&self) -> &'static str { match self { AppMode::Interactive => "interactive", AppMode::Print => "print", AppMode::Json => "json", AppMode::Rpc => "rpc", AppMode::Acp => "acp", AppMode::Daemon => "daemon", } } #[must_use] pub fn print_output_mode(&self) -> Mode { match self { AppMode::Json => Mode::Json, _ => Mode::Text, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct InitialGoal { pub objective: String, pub token_budget: Option, } /// The typed per-session runtime configuration: the API boundary the /// pa-core/pa-ai crates consume at merge time. #[derive(Debug, Clone, Default)] #[allow(clippy::struct_excessive_bools)] // the mirrored `AgentSessionRuntimeConfig` API shape is deliberate pub struct RuntimeConfig { pub cwd: PathBuf, pub agent_dir: PathBuf, pub session_dir: Option, pub provider: Option, pub model: Option, pub api_key: Option, pub system_prompt: Option, pub append_system_prompt: Vec, pub thinking: Option, pub models: Option>, pub skills: Vec, pub no_skills: bool, pub prompt_templates: Vec, pub no_prompt_templates: bool, pub themes: Vec, pub no_themes: bool, pub no_context_files: bool, pub autonomous: Option, pub initial_goal: Option, } /// Session selection options that stay client-side. #[derive(Debug, Clone, Default)] #[allow(clippy::struct_excessive_bools)] // the selection's flag set is the deliberate client-side surface pub struct SessionOptions { /// `--continue`/`-c`: the launch surfaces the newest saved session for the cwd /// through the agents view (preselected, never a blind reopen). pub continue_recent: bool, pub resume_bare: bool, pub resume: Option, pub fork: Option, pub no_session: bool, pub session_dir: Option, /// True when `--cwd` selected the working directory; resumed /// sessions then use that directory instead of the header cwd. pub cwd_from_flag: bool, } #[derive(Debug, Clone)] pub struct RunOptions { pub app_mode: AppMode, pub config: RuntimeConfig, pub session: SessionOptions, pub messages: Vec, pub file_args: Vec, pub daemon_socket: Option, pub list_models: Option>, /// The combined first prompt (stdin + @file text + first message). pub initial_message: Option, /// The `@file` image attachments for the initial prompt; only the /// non-interactive prompt path sends them (the interactive arm is unwired). pub initial_images: Vec, pub verbose: bool, pub offline: bool, pub agents_view_requested: bool, pub attach_agent: Option, } /// A missing runtime subsystem, reported as a typed error instead of faked /// output. The enum makes the merge-time work explicit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MissingSubsystem { SessionEngine, ModelRegistry, PackageManager, } impl MissingSubsystem { #[must_use] pub fn subsystem_name(&self) -> &'static str { match self { MissingSubsystem::SessionEngine => "the session engine (pa-core)", MissingSubsystem::ModelRegistry => "the model registry (pa-ai)", MissingSubsystem::PackageManager => "the capability package manager (pa-core)", } } #[must_use] pub fn error_message(&self) -> String { format!( "this invocation needs {}, which is not linked into the binary yet", self.subsystem_name() ) } } impl std::fmt::Display for MissingSubsystem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.subsystem_name()) } } impl std::error::Error for MissingSubsystem {} /// The runtime boundary. `run` executes the requested mode; the current build has /// only [`UnavailableRuntime`] (typed [`MissingSubsystem`] errors). pub trait Runtime { /// Execute the requested mode. /// /// # Errors /// Returns a [`MissingSubsystem`] error naming the subsystem the /// requested mode needs when it is not linked into the binary. fn run(&self, options: &RunOptions) -> Result; } /// The merge-time placeholder runtime: it never fakes output, it fails loudly /// with the precise missing subsystem for the requested mode. #[derive(Debug, Clone, Copy, Default)] pub struct UnavailableRuntime; impl Runtime for UnavailableRuntime { fn run(&self, options: &RunOptions) -> Result { if options.list_models.is_some() { return Err(MissingSubsystem::ModelRegistry); } let _ = options.app_mode; Err(MissingSubsystem::SessionEngine) } } /// Build the runtime config from parsed args, mirroring `runtimeConfigFromArgs`. pub fn runtime_config_from_args( parsed: &Args, cwd: PathBuf, agent_dir: PathBuf, session_dir: Option, ) -> RuntimeConfig { // Only npm:/git:/github:/http(s):/ssh: sources are not local; // everything else resolves against the session cwd. let is_local_path = |value: &str| { let trimmed = value.trim(); !(trimmed.starts_with("npm:") || trimmed.starts_with("git:") || trimmed.starts_with("github:") || trimmed.starts_with("http:") || trimmed.starts_with("https:") || trimmed.starts_with("ssh:")) }; let resolve_cli_path = |paths: &[String]| -> Vec { paths .iter() .map(|value| { if is_local_path(value) { if std::path::Path::new(value).is_absolute() { PathBuf::from(value) } else { cwd.join(value) } } else { PathBuf::from(value) } }) .collect() }; let skills = resolve_cli_path(&parsed.skills); let prompt_templates = resolve_cli_path(&parsed.prompt_templates); let themes = resolve_cli_path(&parsed.themes); RuntimeConfig { cwd, agent_dir, session_dir, provider: parsed.provider.clone(), model: parsed.model.clone(), api_key: parsed.api_key.clone(), system_prompt: parsed.system_prompt.clone(), append_system_prompt: parsed.append_system_prompt.clone(), thinking: parsed.thinking, models: parsed.models.clone(), skills, no_skills: parsed.no_skills, prompt_templates, no_prompt_templates: parsed.no_prompt_templates, themes, no_themes: parsed.no_themes, no_context_files: parsed.no_context_files, autonomous: AutonomousConfig::from_args(parsed), initial_goal: parsed.goal.as_ref().map(|objective| InitialGoal { objective: objective.clone(), token_budget: parsed.goal_token_budget, }), } }