diff --git a/agents.md b/agents.md index f59296d..d79dccd 100644 --- a/agents.md +++ b/agents.md @@ -1,3 +1,7 @@ # Agents Upgrade Plan -All tracked items completed. +- feat: implement resumable command queue, automatic thought summaries, and queued execution controls to match Codex CLI session management and Claude Code’s scripted workflows +- feat: add first-class prompt, agent, and sub-agent configuration via `.owlen/agents` plus reusable prompt libraries, mirroring Codex custom prompts and Claude’s configurable agents +- feat: deliver official VS Code extension and browser workspace so Owlen runs alongside Codex’s IDE plugin and Claude Code’s app-based surfaces +- feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Code’s artifact outputs +- feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation diff --git a/crates/owlen-cli/src/commands/mod.rs b/crates/owlen-cli/src/commands/mod.rs index 3bcf200..daaab95 100644 --- a/crates/owlen-cli/src/commands/mod.rs +++ b/crates/owlen-cli/src/commands/mod.rs @@ -2,4 +2,5 @@ pub mod cloud; pub mod providers; +pub mod security; pub mod tools; diff --git a/crates/owlen-cli/src/commands/security.rs b/crates/owlen-cli/src/commands/security.rs new file mode 100644 index 0000000..06f8d4e --- /dev/null +++ b/crates/owlen-cli/src/commands/security.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use clap::{Subcommand, ValueEnum}; +use owlen_core::config::ApprovalMode; +use owlen_tui::config as tui_config; + +/// Security-related configuration commands. +#[derive(Debug, Subcommand)] +pub enum SecurityCommand { + /// Display the current approval mode. + Show, + /// Set the approval mode (auto, read-only, plan-first). + Approval { + /// Approval policy to apply to new sessions. + #[arg(value_enum)] + mode: ApprovalModeArg, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ApprovalModeArg { + Auto, + #[clap(name = "read-only")] + ReadOnly, + #[clap(name = "plan-first")] + PlanFirst, +} + +impl From for ApprovalMode { + fn from(value: ApprovalModeArg) -> Self { + match value { + ApprovalModeArg::Auto => ApprovalMode::Auto, + ApprovalModeArg::ReadOnly => ApprovalMode::ReadOnly, + ApprovalModeArg::PlanFirst => ApprovalMode::PlanFirst, + } + } +} + +pub fn run_security_command(command: SecurityCommand) -> Result<()> { + match command { + SecurityCommand::Show => show_approval_mode(), + SecurityCommand::Approval { mode } => set_approval_mode(mode.into()), + } +} + +fn show_approval_mode() -> Result<()> { + let config = tui_config::try_load_config().unwrap_or_default(); + println!( + "Current approval mode: {}", + config.security.approval_mode.as_str() + ); + Ok(()) +} + +fn set_approval_mode(mode: ApprovalMode) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + config.security.approval_mode = mode; + config.validate()?; + tui_config::save_config(&config)?; + println!("Set approval mode to {}.", mode.as_str()); + Ok(()) +} diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index ff03503..03f5f9b 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand}; use commands::{ cloud::{CloudCommand, run_cloud_command}, providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, + security::{SecurityCommand, run_security_command}, tools::{ToolsCommand, run_tools_command}, }; use mcp::{McpCommand, run_mcp_command}; @@ -60,6 +61,9 @@ enum OwlenCommand { /// Manage MCP tool presets #[command(subcommand)] Tools(ToolsCommand), + /// Configure security and approval policies + #[command(subcommand)] + Security(SecurityCommand), /// Show manual steps for updating Owlen to the latest revision Upgrade, } @@ -86,6 +90,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> { OwlenCommand::Models(args) => run_models_command(args).await, OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), + OwlenCommand::Security(sec_cmd) => run_security_command(sec_cmd), OwlenCommand::Upgrade => { println!( "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 97066aa..f24a052 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1735,6 +1735,25 @@ impl Default for PrivacySettings { } /// Security settings that constrain tool execution +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalMode { + #[default] + Auto, + ReadOnly, + PlanFirst, +} + +impl ApprovalMode { + pub fn as_str(self) -> &'static str { + match self { + ApprovalMode::Auto => "auto", + ApprovalMode::ReadOnly => "read-only", + ApprovalMode::PlanFirst => "plan-first", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecuritySettings { #[serde(default = "SecuritySettings::default_enable_sandboxing")] @@ -1745,6 +1764,8 @@ pub struct SecuritySettings { pub max_memory_mb: u64, #[serde(default = "SecuritySettings::default_allowed_tools")] pub allowed_tools: Vec, + #[serde(default)] + pub approval_mode: ApprovalMode, } impl SecuritySettings { @@ -1769,6 +1790,10 @@ impl SecuritySettings { "file_delete".to_string(), ] } + + pub fn approval_mode(&self) -> ApprovalMode { + self.approval_mode + } } impl Default for SecuritySettings { @@ -1778,6 +1803,7 @@ impl Default for SecuritySettings { sandbox_timeout_seconds: Self::default_timeout(), max_memory_mb: Self::default_max_memory(), allowed_tools: Self::default_allowed_tools(), + approval_mode: ApprovalMode::default(), } } } diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 0c3cd90..42d3d25 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -1,5 +1,5 @@ use crate::config::{ - ChatSettings, CompressionStrategy, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, + ApprovalMode, ChatSettings, CompressionStrategy, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpResourceConfig, McpServerConfig, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, }; @@ -535,6 +535,9 @@ pub struct SessionController { credential_manager: Option>, ui: Arc, enable_code_tools: bool, + approval_mode: ApprovalMode, + plan_confirmed: bool, + plan_instruction_inserted: bool, current_mode: Mode, missing_oauth_servers: Vec, event_tx: Option>, @@ -565,6 +568,12 @@ async fn build_tools( let active_provider_id = config_guard.general.default_provider.clone(); + let approval_mode = config_guard.security.approval_mode; + let read_only = matches!(approval_mode, ApprovalMode::ReadOnly); + if read_only { + debug!("Approval mode 'read-only' active: suppressing write/delete/code-exec tools."); + } + let web_search_settings = if config_guard .security .allowed_tools @@ -603,6 +612,7 @@ async fn build_tools( } if enable_code_tools + && !read_only && config_guard .security .allowed_tools @@ -622,6 +632,7 @@ async fn build_tools( .allowed_tools .iter() .any(|t| t == "file_write") + && !read_only { registry.register(ResourcesWriteTool)?; } @@ -630,6 +641,7 @@ async fn build_tools( .allowed_tools .iter() .any(|t| t == "file_delete") + && !read_only { registry.register(ResourcesDeleteTool)?; } @@ -798,6 +810,17 @@ impl SessionController { Arc::new(Mutex::new(ConsentManager::new())) }; + let approval_mode = config_guard.security.approval_mode; + let requested_code_tools = enable_code_tools; + let enable_code_tools = if matches!(approval_mode, ApprovalMode::ReadOnly) { + if requested_code_tools { + warn!("Read-only approval mode disables code tools."); + } + false + } else { + requested_code_tools + }; + let conversation = ConversationManager::with_history_capacity( model, config_guard.storage.max_saved_sessions, @@ -860,7 +883,7 @@ impl SessionController { }; let usage_ledger = Arc::new(TokioMutex::new(usage_ledger_instance)); - Ok(Self { + let mut controller = Self { provider, conversation, model_manager, @@ -878,6 +901,9 @@ impl SessionController { credential_manager, ui, enable_code_tools, + approval_mode, + plan_confirmed: true, + plan_instruction_inserted: false, current_mode: initial_mode, missing_oauth_servers, event_tx, @@ -885,7 +911,11 @@ impl SessionController { stream_states: HashMap::new(), usage_ledger, last_compression: None, - }) + }; + + controller.reset_plan_state(); + + Ok(controller) } pub fn conversation(&self) -> &Conversation { @@ -1669,6 +1699,11 @@ impl SessionController { } pub async fn set_code_tools_enabled(&mut self, enabled: bool) -> Result<()> { + if matches!(self.approval_mode, ApprovalMode::ReadOnly) && enabled { + warn!("Read-only approval mode prevents enabling code tools; ignoring request."); + self.enable_code_tools = false; + return Ok(()); + } if self.enable_code_tools == enabled { return Ok(()); } @@ -1677,11 +1712,45 @@ impl SessionController { self.rebuild_tools().await } + fn reset_plan_state(&mut self) { + self.plan_instruction_inserted = false; + match self.approval_mode { + ApprovalMode::PlanFirst => { + self.plan_confirmed = false; + self.ensure_plan_prompt(); + } + _ => { + self.plan_confirmed = true; + } + } + } + + fn ensure_plan_prompt(&mut self) { + if !matches!(self.approval_mode, ApprovalMode::PlanFirst) { + return; + } + if self.plan_instruction_inserted { + return; + } + let prompt = "You are operating in plan-first approval mode. Outline a concise plan before using any tools or modifying files, and wait for the user to send '#approve' before proceeding."; + self.conversation.push_system_message(prompt); + self.plan_instruction_inserted = true; + } + pub async fn set_operating_mode(&mut self, mode: Mode) -> Result<()> { + if matches!(self.approval_mode, ApprovalMode::ReadOnly) && matches!(mode, Mode::Code) { + warn!( + "Read-only approval mode prevents switching to code mode; remaining in chat mode." + ); + self.current_mode = Mode::Chat; + self.set_code_tools_enabled(false).await?; + return Ok(()); + } + self.current_mode = mode; let enable_code_tools = matches!(mode, Mode::Code); self.set_code_tools_enabled(enable_code_tools).await?; - self.mcp_client.set_mode(mode).await + self.mcp_client.set_mode(self.current_mode).await } pub async fn list_dir(&self, path: &str) -> Result> { @@ -1887,6 +1956,36 @@ impl SessionController { ) -> Result { let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; parameters.stream = streaming; + + if matches!(self.approval_mode, ApprovalMode::PlanFirst) { + self.ensure_plan_prompt(); + let trimmed = content.trim(); + let normalized = trimmed.to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "#approve" | ":approve" | "/approve" | "approve" | "approve plan" + ) { + self.conversation.push_user_message(trimmed.to_string()); + let _ = self.maybe_auto_compress().await?; + if !self.plan_confirmed { + self.plan_confirmed = true; + self.conversation + .push_system_message("Plan approved by the operator. Tool access enabled."); + } + + let ack = Message::assistant( + "Plan approved. Ready to continue with execution.".to_string(), + ); + self.conversation.push_message(ack.clone()); + return Ok(SessionOutcome::Complete(ChatResponse { + message: ack, + usage: None, + is_streaming: false, + is_final: true, + })); + } + } + self.conversation.push_user_message(content); let _ = self.maybe_auto_compress().await?; self.send_request_with_current_conversation(parameters) @@ -1900,10 +1999,18 @@ impl SessionController { let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; parameters.stream = streaming; + if matches!(self.approval_mode, ApprovalMode::PlanFirst) { + self.ensure_plan_prompt(); + } + let active_model = self.conversation.active().model.clone(); let registry_tools = self.tool_registry.all(); let mut include_tools = !registry_tools.is_empty(); + if matches!(self.approval_mode, ApprovalMode::PlanFirst) && !self.plan_confirmed { + include_tools = false; + } + if include_tools { let cached_support = self.model_manager.select(&active_model).await; let supports_tools = match cached_support { @@ -2192,11 +2299,13 @@ impl SessionController { pub fn start_new_conversation(&mut self, model: Option, name: Option) { self.conversation.start_new(model, name); self.stream_states.clear(); + self.reset_plan_state(); } pub fn clear(&mut self) { self.conversation.clear(); self.stream_states.clear(); + self.reset_plan_state(); } pub async fn generate_conversation_description(&self) -> Result {