diff --git a/crates/runner-app/src/bootstrap.rs b/crates/runner-app/src/bootstrap.rs index 5742aad8..ac96ee50 100644 --- a/crates/runner-app/src/bootstrap.rs +++ b/crates/runner-app/src/bootstrap.rs @@ -1,8 +1,10 @@ use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use anyhow::{bail, Context as _, Result}; -use runner_backend::{db, event_bus, events, mcp, repo, session, shell_path, windows, AppCore}; +use runner_backend::{ + db, event_bus, events, mcp, repo, runtime_status, session, shell_path, windows, AppCore, +}; pub const APP_IDENTIFIER: &str = "com.wycstudios.runner"; @@ -50,22 +52,49 @@ pub fn boot_core(paths: &NativePaths) -> Result { let pool = Arc::new( db::open_pool(&paths.app_data_dir.join("runner.db")).context("open Runner database")?, ); - let login_shell_env = shell_path::resolve_login_shell_env().env; + let login_shell_lkg = match db::login_shell_env_lkg(&pool) { + Ok(snapshot) => snapshot, + Err(error) => { + eprintln!("runtime discovery LKG read failed: {error}"); + None + } + }; + let runtime_shell_env = Arc::new(RwLock::new( + login_shell_lkg + .as_ref() + .map(|snapshot| snapshot.env.clone()) + .unwrap_or_default(), + )); + let runtime_discovery = Arc::new(RwLock::new(shell_path::DiscoveryState::startup( + login_shell_lkg + .as_ref() + .map(|snapshot| snapshot.shell.clone()), + login_shell_lkg + .as_ref() + .map(|snapshot| snapshot.captured_at.clone()), + ))); let runtime: Arc = Arc::new(session::pty_runtime::PtyRuntime::new()); - let sessions = session::SessionManager::new(login_shell_env, runtime); + let sessions = session::SessionManager::new( + Arc::clone(&runtime_shell_env), + Arc::clone(&runtime_discovery), + runtime, + ); let window_registry = Arc::new(windows::WindowRegistry::new()); window_registry.register("main"); + let event_channel = events::EventChannel::new(); let core = AppCore { db: Arc::clone(&pool), app_data_dir: paths.app_data_dir.clone(), sessions, + runtime_shell_env: Arc::clone(&runtime_shell_env), + runtime_discovery: Arc::clone(&runtime_discovery), buses: event_bus::BusRegistry::new(), routers: runner_backend::router::RouterRegistry::new(), mcp: Arc::new(mcp::McpHandle::new()), windows: window_registry, - events: events::EventChannel::new(), + events: event_channel.clone(), app_version: env!("CARGO_PKG_VERSION").to_string(), }; @@ -73,6 +102,12 @@ pub fn boot_core(paths: &NativePaths) -> Result { .context("clean up stale PTY sessions")?; session::pty_runtime::cleanup_orphan_processes_on_startup(&pool) .context("clean up orphan PTY processes")?; + runtime_status::start_background_discovery( + event_channel, + Arc::clone(&pool), + runtime_shell_env, + runtime_discovery, + ); Ok(core) } @@ -143,10 +178,19 @@ mod tests { } let runtime: Arc = Arc::new(session::pty_runtime::PtyRuntime::new()); + let runtime_shell_env = Arc::new(RwLock::new(shell_path::LoginShellEnv::default())); + let runtime_discovery = + Arc::new(RwLock::new(shell_path::DiscoveryState::startup(None, None))); let core = AppCore { db: Arc::clone(&pool), app_data_dir: PathBuf::new(), - sessions: session::SessionManager::new(shell_path::LoginShellEnv::default(), runtime), + sessions: session::SessionManager::new( + Arc::clone(&runtime_shell_env), + Arc::clone(&runtime_discovery), + runtime, + ), + runtime_shell_env, + runtime_discovery, buses: event_bus::BusRegistry::new(), routers: runner_backend::router::RouterRegistry::new(), mcp: Arc::new(mcp::McpHandle::new()), diff --git a/crates/runner-app/src/chat.rs b/crates/runner-app/src/chat.rs index c2c4ccfd..a59fd61e 100644 --- a/crates/runner-app/src/chat.rs +++ b/crates/runner-app/src/chat.rs @@ -316,6 +316,8 @@ impl NativeRoot { None, None, None, + None, + None, Some(initial_size.0), Some(initial_size.1), )?; diff --git a/crates/runner-app/tests/session_manager_integration.rs b/crates/runner-app/tests/session_manager_integration.rs index 53586680..13e51095 100644 --- a/crates/runner-app/tests/session_manager_integration.rs +++ b/crates/runner-app/tests/session_manager_integration.rs @@ -53,6 +53,8 @@ fn direct_chat_flows_from_app_core_session_manager_into_terminal_grid() { None, None, None, + None, + None, Some(80), Some(24), ) @@ -104,6 +106,8 @@ fn terminal_ime_commit_forwards_utf8_through_session_manager() { None, None, None, + None, + None, Some(80), Some(24), ) @@ -157,6 +161,8 @@ fn bridge_keeps_multiple_tab_sessions_attached_with_independent_geometry() { None, None, None, + None, + None, Some(80), Some(24), ) @@ -167,6 +173,8 @@ fn bridge_keeps_multiple_tab_sessions_attached_with_independent_geometry() { None, None, None, + None, + None, Some(120), Some(40), ) diff --git a/crates/runner-backend/src/cli_install.rs b/crates/runner-backend/src/cli_install.rs index 97c332a7..0ffb42dd 100644 --- a/crates/runner-backend/src/cli_install.rs +++ b/crates/runner-backend/src/cli_install.rs @@ -47,7 +47,7 @@ const AGENT_DEST_BIN_NAME: &str = if cfg!(windows) { "runner" }; -/// Name of the MCP proxy binary registered with Claude Code / Codex. +/// Name of the MCP proxy binary registered with Claude Code, Codex, Qoder, and TRAE. pub const MCP_DEST_BIN_NAME: &str = if cfg!(windows) { "runner-mcp.exe" } else { diff --git a/crates/runner-backend/src/lib.rs b/crates/runner-backend/src/lib.rs index e806645b..1e76b15b 100644 --- a/crates/runner-backend/src/lib.rs +++ b/crates/runner-backend/src/lib.rs @@ -17,6 +17,7 @@ pub mod model; pub mod ops; pub mod repo; pub mod router; +pub mod runtime_status; pub mod session; pub mod shell_path; pub mod windows; @@ -41,6 +42,11 @@ pub struct AppCore { /// start, shared across all frontends and the per-session /// forwarder threads it spawns. pub sessions: Arc, + /// Swappable login-shell snapshot shared by runtime discovery and + /// every spawn path. Successful background probes replace it in place. + pub runtime_shell_env: runtime_status::SharedShellEnv, + /// Current login-shell probe state and diagnostics for agent availability. + pub runtime_discovery: runtime_status::SharedDiscoveryState, /// Live per-mission event-bus watchers. Mounted by `mission_start` once /// the opening events are durable; unmounted by `mission_stop` and on /// any rollback path. diff --git a/crates/runner-backend/src/mcp/tools/session.rs b/crates/runner-backend/src/mcp/tools/session.rs index 0a0475f7..df50ca20 100644 --- a/crates/runner-backend/src/mcp/tools/session.rs +++ b/crates/runner-backend/src/mcp/tools/session.rs @@ -12,13 +12,21 @@ use crate::ops::session; pub struct StartDirectSessionArgs { /// Runner template ID. pub runner_id: String, - /// Optional runtime override (registry name, e.g. "codex" or - /// "claude-code"). Omit to use the runner's own runtime. When it + /// Optional runtime override (registry name, e.g. "codex", + /// "claude-code", "qoder", or "trae"). Omit to use the runner's own runtime. When it /// differs, the chat spawns that engine with registry defaults /// while the runner's persona (system prompt, working dir, env) /// carries over. #[serde(default)] pub runtime: Option, + /// Optional model for the overridden runtime. Only meaningful with + /// `runtime`; omit for the engine's own default. + #[serde(default)] + pub model: Option, + /// Optional reasoning-effort for the overridden runtime. Only + /// meaningful with `runtime`; omit for the engine's own default. + #[serde(default)] + pub effort: Option, /// Optional project membership. Its cwd is used when cwd is omitted. #[serde(default)] pub project_id: Option, @@ -48,6 +56,8 @@ impl RunnerMcpHandler { &app_state, args.runner_id, args.runtime, + args.model, + args.effort, args.project_id, args.cwd, None, diff --git a/crates/runner-backend/src/mcp/tools/slot.rs b/crates/runner-backend/src/mcp/tools/slot.rs index 8266bea5..3141f2a3 100644 --- a/crates/runner-backend/src/mcp/tools/slot.rs +++ b/crates/runner-backend/src/mcp/tools/slot.rs @@ -68,13 +68,16 @@ impl RunnerMcpHandler { &input.runner_id, &input.slot_handle, input.runtime_override.as_deref(), + input.model_override.as_deref(), ) .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; self.state.events.emit("slot/changed", &()); Ok(CallToolResult::success(vec![Content::json(&slot)?])) } - #[tool(description = "Update a slot by ID. Omitted fields are preserved.")] + #[tool( + description = "Update a slot by ID, including independent runtime, model, and effort overrides. Omitted fields are preserved." + )] pub async fn slot_update( &self, Parameters(UpdateSlotArgs { slot_id, input }): Parameters, diff --git a/crates/runner-backend/src/ops/mcp.rs b/crates/runner-backend/src/ops/mcp.rs index a42a3186..fd9211fd 100644 --- a/crates/runner-backend/src/ops/mcp.rs +++ b/crates/runner-backend/src/ops/mcp.rs @@ -1,15 +1,18 @@ +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - use crate::error::{Error, Result}; use crate::AppCore; +use serde::{Deserialize, Serialize}; +use serde_json::json; #[derive(Debug, Serialize)] pub struct McpConfigSnippet { pub claude_code: String, pub codex: String, + pub qoder: String, + pub trae: String, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -19,6 +22,8 @@ pub struct McpIntegrationStatus { pub socket_path: String, pub claude_code: McpClientStatus, pub codex: McpClientStatus, + pub qoder: McpClientStatus, + pub trae: McpClientStatus, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -54,6 +59,8 @@ impl McpClientStatus { enum Client { ClaudeCode, Codex, + Qoder, + Trae, } impl Client { @@ -61,8 +68,10 @@ impl Client { match raw { "claude_code" => Ok(Self::ClaudeCode), "codex" => Ok(Self::Codex), + "qoder" => Ok(Self::Qoder), + "trae" => Ok(Self::Trae), other => Err(Error::msg(format!( - "unknown MCP client: {other:?} (expected claude_code or codex)" + "unknown MCP client: {other:?} (expected claude_code, codex, qoder, or trae)" ))), } } @@ -74,12 +83,22 @@ fn home_dir() -> Result { .ok_or_else(|| Error::msg("HOME env var not set")) } +const CODEX_CONFIG_RELATIVE_PATH: &str = ".codex/config.toml"; + fn claude_code_path() -> Result { Ok(home_dir()?.join(".claude.json")) } -fn codex_path() -> Result { - Ok(home_dir()?.join(".codex").join("config.toml")) +pub(crate) fn codex_path() -> Result { + Ok(home_dir()?.join(CODEX_CONFIG_RELATIVE_PATH)) +} + +fn qoder_path() -> Result { + Ok(home_dir()?.join(".qoder").join("settings.json")) +} + +fn trae_path() -> Result { + Ok(home_dir()?.join(".trae").join("traecli.toml")) } fn mcp_binary_path(state: &AppCore) -> String { @@ -111,7 +130,7 @@ fn args_match_current(args: &[String]) -> bool { args.is_empty() } -fn claude_code_entry(binary_path: &str) -> serde_json::Value { +fn json_mcp_entry(binary_path: &str) -> serde_json::Value { json!({ "type": "stdio", "command": binary_path @@ -162,6 +181,10 @@ pub(crate) fn claude_code_status_at(path: &Path, binary_path: &str) -> Result Result { + claude_code_status_at(path, binary_path) +} + pub(crate) fn claude_code_write_at(path: &Path, enabled: bool, binary_path: &str) -> Result<()> { let mut val: serde_json::Value = if path.exists() { let raw = std::fs::read_to_string(path) @@ -194,7 +217,7 @@ pub(crate) fn claude_code_write_at(path: &Path, enabled: bool, binary_path: &str })?; if enabled { - servers.insert("runner".to_string(), claude_code_entry(binary_path)); + servers.insert("runner".to_string(), json_mcp_entry(binary_path)); } else { servers.remove("runner"); } @@ -206,6 +229,14 @@ pub(crate) fn claude_code_write_at(path: &Path, enabled: bool, binary_path: &str Ok(()) } +pub(crate) fn qoder_write_at(path: &Path, enabled: bool, binary_path: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| Error::msg(format!("mkdir {}: {e}", parent.display())))?; + } + claude_code_write_at(path, enabled, binary_path) +} + fn toml_args(item: Option<&toml_edit::Item>) -> Vec { item.and_then(|item| item.as_array()) .map(|args| { @@ -289,7 +320,14 @@ pub(crate) fn codex_write_at(path: &Path, enabled: bool, binary_path: &str) -> R servers.remove("runner"); } - std::fs::write(path, doc.to_string()) + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| Error::msg(format!("write {}: {e}", path.display())))?; + file.write_all(doc.to_string().as_bytes()) .map_err(|e| Error::msg(format!("write {}: {e}", path.display())))?; Ok(()) } @@ -298,16 +336,24 @@ pub fn mcp_integration_status(state: &AppCore) -> Result { let binary_path = mcp_binary_path(state); let claude_code_path = claude_code_path()?; let codex_path = codex_path()?; + let qoder_path = qoder_path()?; + let trae_path = trae_path()?; let claude_code = claude_code_status_at(&claude_code_path, &binary_path) .unwrap_or_else(|e| McpClientStatus::error(&claude_code_path, e.to_string())); let codex = codex_status_at(&codex_path, &binary_path) .unwrap_or_else(|e| McpClientStatus::error(&codex_path, e.to_string())); + let qoder = qoder_status_at(&qoder_path, &binary_path) + .unwrap_or_else(|e| McpClientStatus::error(&qoder_path, e.to_string())); + let trae = codex_status_at(&trae_path, &binary_path) + .unwrap_or_else(|e| McpClientStatus::error(&trae_path, e.to_string())); Ok(McpIntegrationStatus { environment: environment_label(), socket_path: socket_path(state), binary_path, claude_code, codex, + qoder, + trae, }) } @@ -316,6 +362,8 @@ pub fn mcp_set_integration(state: &AppCore, client: &str, enabled: bool) -> Resu match Client::parse(client)? { Client::ClaudeCode => claude_code_write_at(&claude_code_path()?, enabled, &binary_path), Client::Codex => codex_write_at(&codex_path()?, enabled, &binary_path), + Client::Qoder => qoder_write_at(&qoder_path()?, enabled, &binary_path), + Client::Trae => codex_write_at(&trae_path()?, enabled, &binary_path), } } @@ -324,15 +372,23 @@ pub fn mcp_config_snippet(state: &AppCore) -> Result { let claude_code = json!({ "mcpServers": { - "runner": claude_code_entry(&runner_bin) + "runner": json_mcp_entry(&runner_bin) } }); let codex = format!("[mcp_servers.runner]\ncommand = \"{runner_bin}\"\n"); + let trae = codex.clone(); + let qoder = json!({ + "mcpServers": { + "runner": json_mcp_entry(&runner_bin) + } + }); Ok(McpConfigSnippet { claude_code: serde_json::to_string_pretty(&claude_code).unwrap_or_default(), codex, + qoder: serde_json::to_string_pretty(&qoder).unwrap_or_default(), + trae, }) } @@ -421,6 +477,61 @@ mod tests { assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not valid json"); } + #[test] + fn qoder_write_creates_dir_and_runner_entry() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".qoder").join("settings.json"); + + qoder_write_at(&path, true, "/test/runner-mcp").unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(value["mcpServers"]["runner"]["type"], json!("stdio")); + assert_eq!( + value["mcpServers"]["runner"]["command"], + json!("/test/runner-mcp") + ); + assert!(value["mcpServers"]["runner"].get("args").is_none()); + let status = qoder_status_at(&path, "/test/runner-mcp").unwrap(); + assert!(status.registered); + assert!(status.matches_current); + } + + #[test] + fn qoder_write_preserves_other_servers_and_settings() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".qoder").join("settings.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{"mcpServers":{"context7":{"command":"context7-mcp"}},"permissions":{"allow":["Read"]}}"#, + ) + .unwrap(); + + qoder_write_at(&path, true, "/test/runner-mcp").unwrap(); + let after_enable: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + after_enable["mcpServers"]["runner"]["command"], + json!("/test/runner-mcp") + ); + assert_eq!( + after_enable["mcpServers"]["context7"]["command"], + json!("context7-mcp") + ); + assert_eq!(after_enable["permissions"]["allow"], json!(["Read"])); + + qoder_write_at(&path, false, "/test/runner-mcp").unwrap(); + let after_disable: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(after_disable["mcpServers"].get("runner").is_none()); + assert_eq!( + after_disable["mcpServers"]["context7"]["command"], + json!("context7-mcp") + ); + assert_eq!(after_disable["permissions"]["allow"], json!(["Read"])); + } + #[test] fn codex_status_false_when_file_missing() { let dir = TempDir::new().unwrap(); @@ -487,6 +598,79 @@ mod tests { assert_eq!(after_disable["model"].as_str(), Some("gpt-5")); } + #[test] + fn trae_write_creates_dir_and_runner_entry() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".trae").join("traecli.toml"); + + codex_write_at(&path, true, "/test/runner-mcp").unwrap(); + + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + ); + let doc: toml_edit::DocumentMut = std::fs::read_to_string(&path).unwrap().parse().unwrap(); + assert_eq!( + doc["mcp_servers"]["runner"]["command"].as_str(), + Some("/test/runner-mcp") + ); + let status = codex_status_at(&path, "/test/runner-mcp").unwrap(); + assert!(status.registered); + assert!(status.matches_current); + } + + #[test] + fn trae_write_preserves_auth_hooks_and_other_servers() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("traecli.toml"); + std::fs::write( + &path, + "auth_token = \"secret\"\n\n[hooks.state]\nstop = \"trusted\"\n\n[mcp_servers.github]\ncommand = \"gh-mcp\"\nargs = []\n", + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + + codex_write_at(&path, true, "/test/runner-mcp").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + ); + let after: toml_edit::DocumentMut = + std::fs::read_to_string(&path).unwrap().parse().unwrap(); + assert_eq!(after["auth_token"].as_str(), Some("secret")); + assert_eq!(after["hooks"]["state"]["stop"].as_str(), Some("trusted")); + assert_eq!( + after["mcp_servers"]["github"]["command"].as_str(), + Some("gh-mcp") + ); + assert_eq!( + after["mcp_servers"]["runner"]["command"].as_str(), + Some("/test/runner-mcp") + ); + + codex_write_at(&path, false, "/test/runner-mcp").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + ); + let after_disable: toml_edit::DocumentMut = + std::fs::read_to_string(&path).unwrap().parse().unwrap(); + assert!(after_disable["mcp_servers"].get("runner").is_none()); + assert_eq!(after_disable["auth_token"].as_str(), Some("secret")); + assert_eq!( + after_disable["hooks"]["state"]["stop"].as_str(), + Some("trusted") + ); + assert_eq!( + after_disable["mcp_servers"]["github"]["command"].as_str(), + Some("gh-mcp") + ); + } + #[test] fn codex_write_errors_on_malformed_toml_without_overwriting() { let dir = TempDir::new().unwrap(); diff --git a/crates/runner-backend/src/ops/mission.rs b/crates/runner-backend/src/ops/mission.rs index b71a6dd0..3e81128c 100644 --- a/crates/runner-backend/src/ops/mission.rs +++ b/crates/runner-backend/src/ops/mission.rs @@ -1783,7 +1783,7 @@ mod tests { }, ) .unwrap(); - slot::create(conn, crew_id, &r.id, handle, None) + slot::create(conn, crew_id, &r.id, handle, None, None) .unwrap() .slot .id diff --git a/crates/runner-backend/src/ops/node.rs b/crates/runner-backend/src/ops/node.rs index 29525584..42e9c52e 100644 --- a/crates/runner-backend/src/ops/node.rs +++ b/crates/runner-backend/src/ops/node.rs @@ -561,10 +561,20 @@ mod tests { } fn test_core_in(app_data_dir: PathBuf) -> AppCore { + let runtime_shell_env = Arc::new(std::sync::RwLock::new(LoginShellEnv::default())); + let runtime_discovery = Arc::new(std::sync::RwLock::new( + crate::shell_path::DiscoveryState::startup(None, None), + )); AppCore { db: Arc::new(db::open_in_memory().unwrap()), app_data_dir, - sessions: SessionManager::new(LoginShellEnv::default(), Arc::new(InertRuntime)), + sessions: SessionManager::new( + Arc::clone(&runtime_shell_env), + Arc::clone(&runtime_discovery), + Arc::new(InertRuntime), + ), + runtime_shell_env, + runtime_discovery, buses: BusRegistry::new(), routers: RouterRegistry::new(), mcp: Arc::new(McpHandle::new()), diff --git a/crates/runner-backend/src/ops/runner.rs b/crates/runner-backend/src/ops/runner.rs index 14ab22a7..2647362d 100644 --- a/crates/runner-backend/src/ops/runner.rs +++ b/crates/runner-backend/src/ops/runner.rs @@ -303,6 +303,7 @@ pub fn update(conn: &Connection, id: &str, input: UpdateRunnerInput) -> Result Result { let base = input.args.unwrap_or(existing.args); - let cleared = if prior_runtime != runtime { + let cleared = if runtime_changed { crate::router::runtime::strip_permission_flags(&prior_runtime, &base) } else { base @@ -366,8 +367,9 @@ pub fn update(conn: &Connection, id: &str, input: UpdateRunnerInput) -> Result Result (Option, Option) { + conn.query_row( + "SELECT model_override, effort_override FROM slots WHERE id = ?1", + params![slot_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + }; + assert_eq!(overrides_for("s-inherit"), (None, None)); + assert_eq!( + overrides_for("s-pinned"), + (Some("opus".into()), Some("max".into())), + ); + } + #[test] fn delete_removes_row() { let pool = ctx(); @@ -912,6 +973,55 @@ mod tests { ); } + #[test] + fn create_and_update_apply_trae_native_permission_mode() { + let pool = ctx(); + let conn = pool.get().unwrap(); + let r = create( + &conn, + CreateRunnerInput { + handle: "trae-tester".into(), + display_name: "TRAE".into(), + runtime: "trae".into(), + command: "traecli".into(), + args: vec!["--debug".into()], + working_dir: None, + system_prompt: None, + env: HashMap::new(), + model: None, + effort: None, + permission_mode: PermissionMode::Auto, + }, + ) + .unwrap(); + assert_eq!( + r.args, + vec![ + "--debug".to_string(), + "--permission-mode".to_string(), + "auto".to_string(), + ], + ); + + let r = update( + &conn, + &r.id, + UpdateRunnerInput { + permission_mode: Some(PermissionMode::Bypass), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + r.args, + vec![ + "--debug".to_string(), + "--permission-mode".to_string(), + "bypass_permissions".to_string(), + ], + ); + } + #[test] fn create_omits_bypass_flags_when_toggle_off() { let pool = ctx(); diff --git a/crates/runner-backend/src/ops/runtime.rs b/crates/runner-backend/src/ops/runtime.rs index 59858c69..c763c2e0 100644 --- a/crates/runner-backend/src/ops/runtime.rs +++ b/crates/runner-backend/src/ops/runtime.rs @@ -1,5 +1,12 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + use serde::Serialize; +use crate::error::{Error, Result}; +use crate::runtime_status::{OverrideValidationError, RuntimeCommandSource, RuntimeStatusResponse}; +use crate::AppCore; + #[derive(Debug, Clone, Serialize)] pub struct RuntimeDefinition { pub name: String, @@ -7,6 +14,25 @@ pub struct RuntimeDefinition { pub command: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeCatalogOption { + pub value: String, + pub label: String, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeCatalogEntry { + pub name: String, + pub display_name: String, + pub command: String, + pub description: String, + pub default_enabled: bool, + pub available: bool, + pub models: Vec, + pub efforts: Vec, +} + pub fn runtime_list() -> Vec { crate::router::runtime::runtime_definitions() .iter() @@ -17,3 +43,339 @@ pub fn runtime_list() -> Vec { }) .collect() } + +pub fn runtime_status_list(state: &AppCore) -> Result { + crate::runtime_status::status_list( + &state.db, + &state.runtime_shell_env, + &state.runtime_discovery, + ) +} + +pub fn runtime_set_override( + state: &AppCore, + runtime: &str, + path: &str, +) -> std::result::Result { + let path = path.trim(); + if crate::router::runtime::runtime_definition(runtime).is_none() { + return Err(OverrideValidationError { + code: "unknown_runtime".into(), + message: format!("Unknown runtime: {runtime}."), + }); + } + if path.is_empty() { + crate::db::set_runtime_override(&state.db, runtime, None).map_err(persistence_error)?; + } else { + crate::runtime_status::validate_override(runtime, path)?; + crate::db::set_runtime_override(&state.db, runtime, Some(path)) + .map_err(persistence_error)?; + log::info!("runtime override saved: runtime={runtime} path={path}"); + } + state.events.emit("runtime/changed", &()); + runtime_status_list(state).map_err(persistence_error) +} + +pub fn runtime_clear_override(state: &AppCore, runtime: &str) -> Result { + if crate::router::runtime::runtime_definition(runtime).is_none() { + return Err(Error::msg(format!("unknown runtime: {runtime}"))); + } + crate::db::set_runtime_override(&state.db, runtime, None)?; + log::info!("runtime override cleared: runtime={runtime}"); + state.events.emit("runtime/changed", &()); + runtime_status_list(state) +} + +pub fn runtime_refresh(state: &AppCore) -> Result { + crate::runtime_status::refresh_background_discovery( + state.events.clone(), + Arc::clone(&state.db), + Arc::clone(&state.runtime_shell_env), + Arc::clone(&state.runtime_discovery), + )?; + runtime_status_list(state) +} + +pub fn runtime_catalog(state: &AppCore) -> Result> { + let statuses = runtime_status_list(state)?; + let availability: HashMap<_, _> = statuses + .runtimes + .into_iter() + .map(|runtime| { + let available = matches!( + runtime.effective_source, + Some(RuntimeCommandSource::Detected | RuntimeCommandSource::Override) + ); + (runtime.name, available) + }) + .collect(); + Ok(runtime_catalog_options() + .into_iter() + .map(|mut runtime| { + runtime.available = availability.get(&runtime.name).copied().unwrap_or(false); + runtime + }) + .collect()) +} + +pub fn selectable_runtime_catalog( + state: &AppCore, + enabled_agents: Option<&[String]>, +) -> Result> { + Ok(filter_selectable(runtime_catalog(state)?, enabled_agents)) +} + +fn filter_selectable( + catalog: Vec, + enabled_agents: Option<&[String]>, +) -> Vec { + let enabled_agents: Option> = + enabled_agents.map(|agents| agents.iter().map(String::as_str).collect()); + catalog + .into_iter() + .filter(|runtime| { + let enabled = enabled_agents + .as_ref() + .map_or(runtime.default_enabled, |agents| { + agents.contains(runtime.name.as_str()) + }); + enabled && runtime.available + }) + .collect() +} + +fn option(value: &str, label: &str, description: &str) -> RuntimeCatalogOption { + RuntimeCatalogOption { + value: value.into(), + label: label.into(), + description: Some(description.into()), + } +} + +fn plain_option(value: &str, label: &str) -> RuntimeCatalogOption { + RuntimeCatalogOption { + value: value.into(), + label: label.into(), + description: None, + } +} + +fn default_model() -> RuntimeCatalogOption { + option("", "default", "Use the agent's own default model.") +} + +fn default_effort() -> RuntimeCatalogOption { + option( + "", + "default", + "Use the agent's own default effort; no flag passed.", + ) +} + +fn common_efforts() -> Vec { + vec![ + default_effort(), + option("low", "Low", "Fast responses with lighter reasoning."), + option("medium", "Medium", "Balances speed and reasoning depth."), + option( + "high", + "High", + "Greater reasoning depth for complex problems.", + ), + option( + "xhigh", + "Extra high", + "Extra reasoning depth for complex problems.", + ), + ] +} + +fn runtime_catalog_options() -> Vec { + let claude_efforts = vec![ + default_effort(), + plain_option("low", "low"), + plain_option("medium", "medium"), + plain_option("high", "high"), + plain_option("xhigh", "xhigh"), + plain_option("max", "max"), + ]; + let mut codex_efforts = common_efforts(); + codex_efforts.push(option( + "max", + "Max", + "Maximum reasoning depth for the hardest problems.", + )); + codex_efforts.push(option( + "ultra", + "Ultra", + "Maximum reasoning with automatic task delegation.", + )); + + vec![ + RuntimeCatalogEntry { + name: "codex".into(), + display_name: "Codex".into(), + command: "codex".into(), + description: "OpenAI Codex CLI".into(), + default_enabled: true, + available: false, + models: vec![ + default_model(), + option( + "gpt-5.6-sol", + "gpt-5.6-sol", + "Latest frontier agentic coding model.", + ), + option( + "gpt-5.6-terra", + "gpt-5.6-terra", + "Balanced agentic coding model for everyday work.", + ), + option( + "gpt-5.6-luna", + "gpt-5.6-luna", + "Fast and affordable agentic coding model.", + ), + option( + "gpt-5.5", + "gpt-5.5", + "Frontier model for complex coding, research, and real-world work.", + ), + option("gpt-5.4", "gpt-5.4", "Strong model for everyday coding."), + option( + "gpt-5.4-mini", + "gpt-5.4-mini", + "Small, fast, and cost-efficient model for simpler coding tasks.", + ), + option( + "gpt-5.3-codex-spark", + "gpt-5.3-codex-spark", + "Ultra-fast coding model.", + ), + ], + efforts: codex_efforts, + }, + RuntimeCatalogEntry { + name: "claude-code".into(), + display_name: "Claude Code".into(), + command: "claude".into(), + description: "Anthropic Claude Code CLI".into(), + default_enabled: true, + available: false, + models: vec![ + default_model(), + option("fable", "fable", "Latest Claude Fable."), + option("opus", "opus", "Latest Claude Opus."), + option("sonnet", "sonnet", "Latest Claude Sonnet."), + option("haiku", "haiku", "Latest Claude Haiku."), + ], + efforts: claude_efforts, + }, + RuntimeCatalogEntry { + name: "qoder".into(), + display_name: "Qoder".into(), + command: "qodercli".into(), + description: "Qoder CLI".into(), + default_enabled: false, + available: false, + models: vec![default_model()], + efforts: Vec::new(), + }, + RuntimeCatalogEntry { + name: "trae".into(), + display_name: "TRAE CLI".into(), + command: "traecli".into(), + description: "TRAE CLI".into(), + default_enabled: false, + available: false, + models: vec![default_model()], + efforts: common_efforts(), + }, + ] +} + +fn persistence_error(error: Error) -> OverrideValidationError { + OverrideValidationError { + code: "persistence_failed".into(), + message: error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_matches_supported_runtime_order_and_defaults() { + let catalog = runtime_catalog_options(); + assert_eq!( + catalog + .iter() + .map(|runtime| runtime.name.as_str()) + .collect::>(), + ["codex", "claude-code", "qoder", "trae"] + ); + assert!(catalog[0].default_enabled); + assert!(catalog[1].default_enabled); + assert!(!catalog[2].default_enabled); + assert!(!catalog[3].default_enabled); + assert!(catalog[2].efforts.is_empty()); + assert_eq!( + catalog[0] + .models + .iter() + .map(|model| model.value.as_str()) + .collect::>(), + [ + "", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", + ] + ); + assert_eq!( + catalog[0] + .efforts + .iter() + .map(|effort| effort.value.as_str()) + .collect::>(), + ["", "low", "medium", "high", "xhigh", "max", "ultra"] + ); + assert_eq!( + catalog[3] + .efforts + .iter() + .map(|effort| effort.value.as_str()) + .collect::>(), + ["", "low", "medium", "high", "xhigh"] + ); + } + + #[test] + fn selectable_catalog_requires_availability_and_honors_agent_settings() { + let mut catalog = runtime_catalog_options(); + for runtime in &mut catalog { + runtime.available = true; + } + assert_eq!( + filter_selectable(catalog.clone(), None) + .iter() + .map(|runtime| runtime.name.as_str()) + .collect::>(), + ["codex", "claude-code"] + ); + + let enabled = vec!["qoder".to_string()]; + assert_eq!( + filter_selectable(catalog, Some(&enabled)) + .iter() + .map(|runtime| runtime.name.as_str()) + .collect::>(), + ["qoder"] + ); + } +} diff --git a/crates/runner-backend/src/ops/session.rs b/crates/runner-backend/src/ops/session.rs index 1acbbe57..6eb9c9cf 100644 --- a/crates/runner-backend/src/ops/session.rs +++ b/crates/runner-backend/src/ops/session.rs @@ -613,6 +613,8 @@ pub fn session_start_direct_impl( state: &AppCore, runner_id: String, runtime: Option, + model: Option, + effort: Option, project_id: Option, cwd: Option, cols: Option, @@ -630,6 +632,8 @@ pub fn session_start_direct_impl( .spawn_direct( &runner, runtime.as_deref(), + model.as_deref(), + effort.as_deref(), project_id.as_deref(), effective_cwd.as_deref(), cols, @@ -652,14 +656,20 @@ pub fn session_start_direct( state: &AppCore, runner_id: String, runtime: Option, + model: Option, + effort: Option, project_id: Option, cwd: Option, cols: Option, rows: Option, ) -> Result { - Ok(session_start_direct_impl(state, runner_id, runtime, project_id, cwd, cols, rows)?.session) + Ok(session_start_direct_impl( + state, runner_id, runtime, model, effort, project_id, cwd, cols, rows, + )? + .session) } +#[allow(clippy::too_many_arguments)] pub fn session_start_runtime( state: &AppCore, runtime: &str, @@ -667,8 +677,10 @@ pub fn session_start_runtime( cwd: Option, cols: Option, rows: Option, + model: Option, + effort: Option, ) -> Result { - let runner = runtime_direct_runner(runtime, None)?; + let runner = runtime_direct_runner(runtime, None, model.as_deref(), effort.as_deref())?; let emitter: Arc = Arc::new(state.session_events()); let spawned = state .sessions diff --git a/crates/runner-backend/src/ops/slot.rs b/crates/runner-backend/src/ops/slot.rs index 9c764725..637b66f7 100644 --- a/crates/runner-backend/src/ops/slot.rs +++ b/crates/runner-backend/src/ops/slot.rs @@ -54,6 +54,14 @@ pub struct UpdateSlotInput { /// name to override. Validated against the runtime registry. #[serde(default, deserialize_with = "double_option")] pub runtime_override: Option>, + /// Per-slot model. Omit to preserve, pass `null` or blank to + /// inherit, or pass a model name to override. + #[serde(default, deserialize_with = "double_option")] + pub model_override: Option>, + /// Per-slot thinking effort. Omit to preserve, pass `null` or + /// blank to inherit, or pass an effort level to override. + #[serde(default, deserialize_with = "double_option")] + pub effort_override: Option>, } /// Present-vs-missing deserializer for the clear/preserve/set field. @@ -89,6 +97,13 @@ fn validate_runtime_override(value: Option<&str>) -> Result> { Ok(Some(name.to_string())) } +fn normalize_override_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn new_id() -> String { UlidGen::new().to_string() } @@ -189,6 +204,7 @@ pub fn create( runner_id: &str, slot_handle: &str, runtime_override: Option<&str>, + model_override: Option<&str>, ) -> Result { if !crew_exists(conn, crew_id)? { return Err(Error::msg(format!("crew not found: {crew_id}"))); @@ -201,6 +217,7 @@ pub fn create( return Err(Error::msg("slot_handle must not be empty")); } let runtime_override = validate_runtime_override(runtime_override)?; + let model_override = normalize_override_value(model_override); let id = new_id(); let added_at = now(); @@ -228,7 +245,7 @@ pub fn create( position: next_position, lead: is_first, runtime_override, - model_override: None, + model_override, effort_override: None, added_at, }, @@ -248,11 +265,10 @@ pub fn create( .ok_or_else(|| Error::msg("slot_create: inserted row vanished")) } -/// Edit a slot's `slot_handle` and/or `runtime_override`. Trims and -/// rejects empty handles; `runtime_override: Some(None)` clears the -/// override, `Some(Some(name))` validates against the runtime -/// registry, `None` preserves. Slot id, crew membership, runner -/// template ref, position, and lead flag are unchanged. +/// Edit a slot's handle and/or agent overrides. Engine changes clear +/// stale model and effort values unless the caller supplies replacements +/// in the same patch. Slot id, crew membership, runner template ref, +/// position, and lead flag are unchanged. pub fn update( conn: &mut Connection, slot_id: &str, @@ -274,6 +290,36 @@ pub fn update( .runtime_override .map(|v| validate_runtime_override(v.as_deref())) .transpose()?; + let model_override = input + .model_override + .map(|value| normalize_override_value(value.as_deref())); + let effort_override = input + .effort_override + .map(|value| normalize_override_value(value.as_deref())); + let runner = runner::get(conn, &existing.runner_id)?; + let final_runtime_override = runtime_override + .clone() + .unwrap_or_else(|| existing.runtime_override.clone()); + let current_runtime = existing + .runtime_override + .as_deref() + .unwrap_or(&runner.runtime); + let next_runtime = final_runtime_override.as_deref().unwrap_or(&runner.runtime); + let engine_changed = current_runtime != next_runtime; + let final_model_override = model_override.clone().unwrap_or_else(|| { + if engine_changed { + None + } else { + existing.model_override.clone() + } + }); + let final_effort_override = effort_override.clone().unwrap_or_else(|| { + if engine_changed { + None + } else { + existing.effort_override.clone() + } + }); // Both fields commit atomically: a handle collision must not // leave a half-applied runtime change behind (or vice versa). @@ -281,6 +327,12 @@ pub fn update( if let Some(runtime_override) = &runtime_override { repo::slot::set_runtime_override(&tx, slot_id, runtime_override.as_deref())?; } + if model_override.is_some() || engine_changed { + repo::slot::set_model_override(&tx, slot_id, final_model_override.as_deref())?; + } + if effort_override.is_some() || engine_changed { + repo::slot::set_effort_override(&tx, slot_id, final_effort_override.as_deref())?; + } repo::slot::set_slot_handle(&tx, slot_id, &slot_handle).map_err(|e| { match e.sqlite_error_code() { Some(rusqlite::ErrorCode::ConstraintViolation) => Error::msg(format!( @@ -441,6 +493,10 @@ pub struct CreateSlotInput { /// "Runner default" behavior; otherwise a runtime registry name. #[serde(default)] pub runtime_override: Option, + /// Optional model pinned to the selected runtime. Blank or omitted + /// inherits from the runner template. + #[serde(default)] + pub model_override: Option, } pub fn slot_create(state: &AppCore, input: CreateSlotInput) -> Result { @@ -451,6 +507,7 @@ pub fn slot_create(state: &AppCore, input: CreateSlotInput) -> Result &'static [RuntimeDefinition] { @@ -88,13 +98,16 @@ pub fn runtime_display_name(name: &str) -> String { /// used in `~/.codex/config.toml`. The value is parsed as TOML /// with a raw-string fallback, so passing the level unquoted is /// fine. The level is lowercased before being formatted in: -/// codex's TOML enum is case-sensitive and rejects e.g. `High` -/// with `unknown variant 'High', expected one of 'none', -/// 'minimal', 'low', 'medium', 'high', 'xhigh'`, but rows often -/// store the level title-cased ("High"). claude-code's -/// `--effort` is *not* case-sensitive (accepts `High`), so the -/// claude-code branch deliberately forwards the value verbatim -/// to avoid a regression on already-shipped behavior. +/// codex-cli 0.130.0's TOML enum was case-sensitive and rejected +/// e.g. `High` with `unknown variant 'High', expected one of +/// 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'`, while +/// rows often store the level title-cased ("High"). On 0.146.0, +/// config-loading commands accept unknown values without that +/// enum error, and the refreshed model catalog includes `max` and +/// `ultra` for current models. claude-code's `--effort` is *not* +/// case-sensitive (accepts `High`), so the claude-code branch +/// deliberately forwards the value verbatim to avoid a regression +/// on already-shipped behavior. /// /// shell / unknown runtimes: no equivalent flags — degrade silently /// so the runner row's preference is recorded but the spawn @@ -121,7 +134,7 @@ pub fn model_effort_args(runtime: &str, model: Option<&str>, effort: Option<&str } out } - "codex" => { + "codex" | "trae" => { let mut out = Vec::new(); if let Some(m) = model { out.push("--model".into()); @@ -175,6 +188,15 @@ pub fn model_effort_args(runtime: &str, model: Option<&str>, effort: Option<&str /// exposed here.) /// - **Bypass** — `--ask-for-approval never --sandbox /// workspace-write`. Never ask. +/// +/// qoder (2 modes — only the live-probed flag is exposed): +/// - **Default** — no flag. +/// - **Auto** — `--permission-mode auto`. +/// +/// trae (3 modes — native presets declared by `traecli --help`): +/// - **Default** — no flag; use TRAE CLI's configured default. +/// - **Auto** — `--permission-mode auto`. +/// - **Bypass** — `--permission-mode bypass_permissions`. #[derive( Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, )] @@ -211,11 +233,22 @@ pub fn permission_mode_args(runtime: &str, mode: PermissionMode) -> Vec ("claude-code", PermissionMode::Bypass) => { vec!["--permission-mode".into(), "bypassPermissions".into()] } + ("qoder", PermissionMode::Auto) => { + vec!["--permission-mode".into(), "auto".into()] + } + ("trae", PermissionMode::Auto) => { + vec!["--permission-mode".into(), "auto".into()] + } + ("trae", PermissionMode::Bypass) => { + vec!["--permission-mode".into(), "bypass_permissions".into()] + } // codex: `--ask-for-approval --sandbox // workspace-write` pair for both Auto and Bypass. AcceptEdits // returns empty (codex has no equivalent) so a user who // somehow lands AcceptEdits on a codex row reads as Default. - ("codex", PermissionMode::AcceptEdits) => Vec::new(), + ("codex", PermissionMode::AcceptEdits) | ("trae", PermissionMode::AcceptEdits) => { + Vec::new() + } ("codex", PermissionMode::Auto) => vec![ "--ask-for-approval".into(), "on-request".into(), @@ -247,6 +280,7 @@ pub fn permission_mode_args(runtime: &str, mode: PermissionMode) -> Vec /// `--dangerously-skip-permissions` (standalone, kept in the /// strip set so legacy rows that still carry the deprecated flag /// get cleaned up the next time the user touches their row). +/// - qoder/trae: `--permission-mode ` (value-bearing). pub fn strip_permission_flags(runtime: &str, args: &[String]) -> Vec { // (flag_name, takes_value) let keys: &[(&str, bool)] = match runtime { @@ -255,6 +289,7 @@ pub fn strip_permission_flags(runtime: &str, args: &[String]) -> Vec { ("--dangerously-skip-permissions", false), ("--permission-mode", true), ], + "qoder" | "trae" => &[("--permission-mode", true)], _ => &[], }; if keys.is_empty() { @@ -381,6 +416,9 @@ fn mode_match_pairs( ("claude-code", PermissionMode::Bypass) => { &[("--permission-mode", Some("bypassPermissions"))] } + ("qoder", PermissionMode::Auto) => &[("--permission-mode", Some("auto"))], + ("trae", PermissionMode::Auto) => &[("--permission-mode", Some("auto"))], + ("trae", PermissionMode::Bypass) => &[("--permission-mode", Some("bypass_permissions"))], ("codex", PermissionMode::Auto) => &[ ("--ask-for-approval", Some("on-request")), ("--sandbox", Some("workspace-write")), @@ -473,8 +511,8 @@ pub const FIRST_TURN_ARGV_MAX_BYTES: usize = 32 * 1024; /// Map a runtime + composed first-turn body to the positional argv /// the agent CLI reads as its first user turn at process spawn. /// -/// claude-code and codex both accept a positional `[PROMPT]` argument -/// (verified against claude-code and codex-cli 0.130.0). Delivering +/// claude-code, codex, qoder, and trae accept a positional `[PROMPT]` argument. +/// Delivering /// the first turn at spawn-time eliminates the post-spawn paste race /// the original `inject_paste_with_verify` machinery was working /// around: if the child starts, the prompt is already part of its @@ -504,7 +542,7 @@ pub fn first_turn_argv(runtime: &str, body: Option<&str>) -> Vec { return Vec::new(); } match runtime { - "claude-code" | "codex" => vec![body.to_string()], + "claude-code" | "codex" | "qoder" | "trae" => vec![body.to_string()], _ => Vec::new(), } } @@ -513,12 +551,12 @@ pub fn first_turn_argv(runtime: &str, body: Option<&str>) -> Vec { /// any `system_prompt` argv + first-turn body positional) in the /// order the runtime's CLI expects. /// -/// `system_prompt_args` still returns empty for both supported -/// runtimes (claude-code's `--append-system-prompt` is SDK-only; codex -/// has no equivalent flag). The first user turn — composed launch -/// prompt for a mission lead, worker preamble for non-leads, persona -/// for direct chats — rides on `first_turn_argv` instead and lands as -/// the trailing positional. +/// `system_prompt_args` still returns empty for all four first-class +/// runtimes (claude-code's `--append-system-prompt` is SDK-only; codex, +/// qoder, and trae have no probed equivalent flag). The first user turn — +/// composed launch prompt for a mission lead, worker preamble for +/// non-leads, persona for direct chats — rides on `first_turn_argv` +/// instead and lands as the trailing positional. /// /// `plan_resuming` suppresses both the system_prompt argv (legacy, /// no-op today) and the first_turn argv — replaying a launch prompt @@ -560,19 +598,19 @@ pub fn mission_bus_sandbox_args(runtime: &str, mission_dir: Option<&Path>) -> Ve /// can pass it back via `prior_key`. #[derive(Debug, Clone)] pub struct ResumePlan { - /// Args to splice into the spawn command. For claude-code these are - /// trailing flags; for codex `resume ` is a subcommand prefix the - /// caller must place ahead of any user-supplied args. See `prepend`. + /// Args to splice into the spawn command. For claude-code and qoder these + /// are trailing flags; for codex and trae `resume ` is a subcommand + /// prefix the caller must place ahead of any user-supplied args. See `prepend`. pub args: Vec, /// `true` when `args` are a subcommand prefix that must precede the - /// runner's configured args (codex resume). `false` when they are - /// trailing flags safe to append (claude-code --session-id / --resume). + /// runner's configured args (codex/trae resume). `false` when they are + /// trailing flags safe to append (claude-code/qoder --session-id / --resume). pub prepend: bool, /// The native agent session key this spawn is bound to, when known up - /// front. claude-code: a freshly-generated UUID we just told the CLI to - /// use, or the prior key when resuming. codex: the prior key when - /// resuming, otherwise `None` (fresh codex sessions self-assign an id; - /// post-spawn capture is a follow-up). + /// front. claude-code/qoder: a freshly-generated UUID we just told the CLI + /// to use, or the prior key when resuming. codex/trae: the prior key when + /// resuming, otherwise `None` (fresh sessions self-assign an id that + /// Runner captures post-spawn). pub assigned_key: Option, /// Whether this plan is a resume of a prior conversation. Callers can /// surface a "resuming previous session" hint, and on later detection @@ -597,12 +635,11 @@ impl ResumePlan { /// /// Failure modes: /// - Unknown runtime → fresh spawn, no key (degrade silently). -/// - claude-code with no `prior_key` → fresh spawn but with a self-assigned -/// UUID via `--session-id`, so the very next respawn can resume. -/// - codex with no `prior_key` → fresh spawn, no key. Capturing the codex -/// rollout id post-spawn is tracked as a follow-up; until then, codex -/// resumes only if the user has previously triggered a captured key by -/// other means (manual seed, future capture path). +/// - claude-code/qoder with no `prior_key` → fresh spawn but with a +/// self-assigned UUID via `--session-id`, so the very next respawn can +/// resume. +/// - codex/trae with no `prior_key` → fresh spawn, no key. Runner captures +/// the rollout id post-spawn. /// /// `prior_key` should be the value of `sessions.agent_session_key` from the /// most recent prior session in the same scope. The caller decides how to @@ -647,7 +684,24 @@ pub fn resume_plan(runtime: &str, prior_key: Option<&str>) -> ResumePlan { } } }, - "codex" => match prior_key { + "qoder" => match prior_key { + Some(k) if is_uuid(k) => ResumePlan { + args: vec!["--resume".into(), k.to_string()], + prepend: false, + assigned_key: Some(k.to_string()), + resuming: true, + }, + _ => { + let id = uuid::Uuid::new_v4().to_string(); + ResumePlan { + args: vec!["--session-id".into(), id.clone()], + prepend: false, + assigned_key: Some(id), + resuming: false, + } + } + }, + "codex" | "trae" => match prior_key { Some(k) if is_uuid(k) => ResumePlan { // `codex resume ` is a subcommand prefix. The caller // places these args ahead of any user-supplied args. @@ -668,27 +722,21 @@ fn is_uuid(s: &str) -> bool { uuid::Uuid::parse_str(s).is_ok() } -/// True iff claude-code's conversation file for `(cwd, uuid)` exists on -/// disk. Used by `SessionManager::resume` to skip `--resume ` when -/// the agent never persisted a turn (for example, fast Stop after -/// spawn before a first response persists) — passing -/// `--resume` against a missing file makes claude-code print -/// "No conversation found with session ID …" and leave the TUI sitting -/// in a half-initialised state. Path scheme: -/// `$HOME/.claude/projects//.jsonl`. We -/// resolve `cwd` with the same precedence the spawn used (mission / -/// runner override) and skip the check when no concrete cwd is known. -pub fn claude_code_conversation_exists(cwd: Option<&str>, uuid: &str) -> bool { +/// Check an agent conversation path using that CLI's project-directory +/// encoder. Claude Code replaces `/` and `.`, while Qoder replaces every +/// non-ASCII-alphanumeric UTF-16 code unit and hashes paths over 200 units. +fn conversation_file_exists( + agent_dir: &str, + cwd: Option<&str>, + uuid: &str, + encode_project_dir: fn(&str) -> String, +) -> bool { // `cfg(test)` short-circuits the filesystem check so unit tests - // for the resume flow don't have to fake out - // `~/.claude/projects//.jsonl`. The path - // encoding is exercised directly by `claude_code_conversation_*` - // tests below; the SessionManager-level resume tests just want - // the production semantic of "prior conversation present" to - // hold so they can assert key preservation. + // for the resume flow don't have to fake out the agent project + // directory. The encoders are exercised directly below. #[cfg(test)] { - let _ = (cwd, uuid); + let _ = (agent_dir, cwd, uuid, encode_project_dir); true } #[cfg(not(test))] @@ -702,27 +750,68 @@ pub fn claude_code_conversation_exists(cwd: Option<&str>, uuid: &str) -> bool { let Some(home) = std::env::var_os("HOME") else { return true; }; - // claude-code encodes the project dir by replacing both `/` and - // `.` with `-`. e.g. `/Users/jason/go/src/github.com/yicheng47` - // → `-Users-jason-go-src-github-com-yicheng47`. Confirmed against - // `~/.claude/projects/` directory listings. Only swapping `/` - // would miss every cwd containing a `.` (most repos), causing - // `path.exists()` to return false even when the conversation - // file is on disk — every resume would then spuriously fall back - // to a fresh spawn. - let encoded: String = cwd - .chars() - .map(|c| if c == '/' || c == '.' { '-' } else { c }) - .collect(); let path = std::path::PathBuf::from(home) - .join(".claude") + .join(agent_dir) .join("projects") - .join(encoded) + .join(encode_project_dir(cwd)) .join(format!("{uuid}.jsonl")); path.exists() } } +fn claude_code_project_dir(cwd: &str) -> String { + cwd.chars() + .map(|c| if c == '/' || c == '.' { '-' } else { c }) + .collect() +} + +fn qoder_project_dir(cwd: &str) -> String { + const PREFIX_LEN: usize = 200; + let mut encoded = String::with_capacity(cwd.len()); + let mut hash = 5381_i32; + for code_unit in cwd.encode_utf16() { + encoded.push( + char::from_u32(u32::from(code_unit)) + .filter(char::is_ascii_alphanumeric) + .unwrap_or('-'), + ); + hash = hash.wrapping_mul(33) ^ i32::from(code_unit); + } + if encoded.len() <= PREFIX_LEN { + return encoded; + } + format!( + "{}-{}", + &encoded[..PREFIX_LEN], + base36(i64::from(hash).unsigned_abs()), + ) +} + +fn base36(mut value: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut out = Vec::new(); + loop { + out.push(char::from(DIGITS[(value % 36) as usize])); + value /= 36; + if value == 0 { + break; + } + } + out.into_iter().rev().collect() +} + +/// True iff claude-code's conversation file for `(cwd, uuid)` exists on +/// disk. Used by `SessionManager::resume` to skip `--resume ` when +/// the agent never persisted a turn. +pub fn claude_code_conversation_exists(cwd: Option<&str>, uuid: &str) -> bool { + conversation_file_exists(".claude", cwd, uuid, claude_code_project_dir) +} + +/// True iff qoder's conversation file for `(cwd, uuid)` exists on disk. +pub fn qoder_conversation_exists(cwd: Option<&str>, uuid: &str) -> bool { + conversation_file_exists(".qoder", cwd, uuid, qoder_project_dir) +} + #[cfg(test)] mod tests { use super::*; @@ -808,6 +897,46 @@ mod tests { assert_eq!(plan.args[0], "--session-id"); } + #[test] + fn qoder_fresh_self_assigns_session_id() { + let plan = resume_plan("qoder", None); + assert!(!plan.resuming); + assert!(!plan.prepend); + assert_eq!(plan.args.len(), 2); + assert_eq!(plan.args[0], "--session-id"); + let assigned = plan.assigned_key.as_deref().unwrap(); + assert_eq!(plan.args[1], assigned); + assert!(is_uuid(assigned), "assigned key must be a UUID"); + } + + #[test] + fn qoder_resumes_with_prior_uuid() { + let prior = uuid::Uuid::new_v4().to_string(); + let plan = resume_plan("qoder", Some(&prior)); + assert!(plan.resuming); + assert!(!plan.prepend); + assert_eq!(plan.args, vec!["--resume", &prior]); + assert_eq!(plan.assigned_key.as_deref(), Some(prior.as_str())); + } + + #[test] + fn qoder_project_dir_matches_cli_encoding() { + assert_eq!( + qoder_project_dir("/Users/jason/a b/c_d+e.rs"), + "-Users-jason-a-b-c-d-e-rs", + ); + assert_eq!(qoder_project_dir("/tmp/💡"), "-tmp---"); + } + + #[test] + fn qoder_project_dir_truncates_with_djb2_base36_suffix() { + let cwd = "a".repeat(201); + assert_eq!( + qoder_project_dir(&cwd), + format!("{}-x54x4s", "a".repeat(200)), + ); + } + #[test] fn codex_fresh_returns_empty_plan() { let plan = resume_plan("codex", None); @@ -825,6 +954,24 @@ mod tests { assert_eq!(plan.args, vec!["resume", &prior]); } + #[test] + fn trae_fresh_returns_empty_plan() { + let plan = resume_plan("trae", None); + assert!(plan.args.is_empty()); + assert!(plan.assigned_key.is_none()); + assert!(!plan.resuming); + } + + #[test] + fn trae_resume_uses_subcommand_prefix() { + let prior = "019fa1b9-a133-7841-b4dd-730d376ab1d1".to_string(); + let plan = resume_plan("trae", Some(&prior)); + assert!(plan.resuming); + assert!(plan.prepend, "trae resume is a subcommand, must prepend"); + assert_eq!(plan.args, vec!["resume", &prior]); + assert_eq!(plan.assigned_key.as_deref(), Some(prior.as_str())); + } + #[test] fn unknown_runtime_returns_empty_resume_plan() { let plan = resume_plan("aider-future", Some("anything")); @@ -866,6 +1013,20 @@ mod tests { ); } + #[test] + fn trae_emits_model_and_reasoning_effort_override() { + let args = model_effort_args("trae", Some("trae-model"), Some("High")); + assert_eq!( + args, + vec![ + "--model".to_string(), + "trae-model".to_string(), + "-c".to_string(), + "model_reasoning_effort=high".to_string(), + ], + ); + } + #[test] fn codex_emits_only_model_when_effort_unset() { let args = model_effort_args("codex", Some("gpt-5-codex"), None); @@ -961,6 +1122,7 @@ mod tests { // Default → no flags for any runtime / any mode. assert!(permission_mode_args("claude-code", PermissionMode::Default).is_empty()); assert!(permission_mode_args("codex", PermissionMode::Default).is_empty()); + assert!(permission_mode_args("trae", PermissionMode::Default).is_empty()); // claude-code: AcceptEdits / Auto / Bypass each emit // `--permission-mode ` with a runtime-specific value. assert_eq!( @@ -978,6 +1140,12 @@ mod tests { "bypassPermissions".to_string(), ], ); + assert_eq!( + permission_mode_args("qoder", PermissionMode::Auto), + vec!["--permission-mode".to_string(), "auto".to_string()], + ); + assert!(permission_mode_args("qoder", PermissionMode::AcceptEdits).is_empty()); + assert!(permission_mode_args("qoder", PermissionMode::Bypass).is_empty()); // codex: AcceptEdits has no equivalent (returns empty); // Auto uses on-request (on-failure is deprecated per // `codex --help`); Bypass uses never. @@ -1000,6 +1168,18 @@ mod tests { "workspace-write".to_string(), ], ); + assert!(permission_mode_args("trae", PermissionMode::AcceptEdits).is_empty()); + assert_eq!( + permission_mode_args("trae", PermissionMode::Auto), + vec!["--permission-mode".to_string(), "auto".to_string()], + ); + assert_eq!( + permission_mode_args("trae", PermissionMode::Bypass), + vec![ + "--permission-mode".to_string(), + "bypass_permissions".to_string(), + ], + ); // Unknown runtime → empty for every mode. for mode in [ PermissionMode::Default, @@ -1105,6 +1285,25 @@ mod tests { assert_eq!(out, vec!["--debug".to_string()]); } + #[test] + fn apply_permission_mode_trae_round_trips_auto_to_default() { + let user = vec!["--debug".to_string()]; + let auto = apply_permission_mode("trae", &user, PermissionMode::Auto); + assert_eq!( + auto, + vec![ + "--debug".to_string(), + "--permission-mode".to_string(), + "auto".to_string(), + ], + ); + assert_eq!(infer_permission_mode("trae", &auto), PermissionMode::Auto); + assert_eq!( + apply_permission_mode("trae", &auto, PermissionMode::Default), + user, + ); + } + #[test] fn apply_permission_mode_claude_code_each_mode() { for (mode, expected_extra) in [ @@ -1193,6 +1392,25 @@ mod tests { ); } + #[test] + fn apply_permission_mode_qoder_round_trips_auto_to_default() { + let user = vec!["--debug".to_string()]; + let auto = apply_permission_mode("qoder", &user, PermissionMode::Auto); + assert_eq!( + auto, + vec![ + "--debug".to_string(), + "--permission-mode".to_string(), + "auto".to_string(), + ], + ); + assert_eq!(infer_permission_mode("qoder", &auto), PermissionMode::Auto,); + assert_eq!( + apply_permission_mode("qoder", &auto, PermissionMode::Default), + user, + ); + } + #[test] fn apply_permission_mode_no_op_for_unsupported_runtime() { let user = vec!["--whatever".to_string()]; @@ -1411,8 +1629,8 @@ mod tests { } #[test] - fn first_turn_rides_trailing_argv_on_fresh_spawn_for_both_runtimes() { - for runtime in ["claude-code", "codex"] { + fn first_turn_rides_trailing_argv_on_fresh_spawn_for_supported_runtimes() { + for runtime in ["claude-code", "codex", "qoder", "trae"] { let body = "You are the architect. Goal: ship 0007."; let args = trailing_runtime_args( runtime, @@ -1422,19 +1640,20 @@ mod tests { Some("persona"), Some(body), ); - // Body lands as the trailing positional after model/effort - // flags. system_prompt is unchanged (still empty) for both - // runtimes. + // Body lands as the trailing positional. Qoder deliberately + // ignores the unprobed model/effort fields. assert_eq!(args.last().map(String::as_str), Some(body)); - assert!(args - .windows(2) - .any(|w| w[0] == "--model" && w[1] == "model-x")); + if runtime != "qoder" { + assert!(args + .windows(2) + .any(|w| w[0] == "--model" && w[1] == "model-x")); + } } } #[test] - fn first_turn_suppressed_on_resume_for_both_runtimes() { - for runtime in ["claude-code", "codex"] { + fn first_turn_suppressed_on_resume_for_supported_runtimes() { + for runtime in ["claude-code", "codex", "qoder", "trae"] { let body = "You are the architect. Goal: ship 0007."; let args = trailing_runtime_args( runtime, diff --git a/crates/runner-backend/src/runtime_status.rs b/crates/runner-backend/src/runtime_status.rs new file mode 100644 index 00000000..15f01a96 --- /dev/null +++ b/crates/runner-backend/src/runtime_status.rs @@ -0,0 +1,611 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use crate::db::{self, DbPool, LoginShellEnvLkg}; +use crate::error::{Error, Result}; +use crate::events::EventChannel; +use crate::router::runtime::{runtime_definition, runtime_definitions}; +use crate::session::launch; +use crate::shell_path::{DiscoveryOutcome, DiscoveryResult, DiscoveryState, LoginShellEnv}; +use serde::Serialize; + +pub type SharedShellEnv = Arc>; +pub type SharedDiscoveryState = Arc>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCommandSource { + Override, + Detected, + Catalog, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveRuntimeCommand { + pub command: String, + pub source: RuntimeCommandSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeRowState { + Detected, + Override, + NotFound, + Checking, + ProbeTimedOut, + InvalidOverride, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ShellDiscoveryStatus { + pub shell: Option, + pub outcome: Option, + pub duration_ms: Option, + pub checking: bool, + pub using_last_known_good: bool, + pub last_known_good_captured_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeExecutableStatus { + pub name: String, + pub display_name: String, + pub command: String, + pub detected_path: Option, + pub override_path: Option, + pub effective_command: Option, + pub effective_source: Option, + pub state: RuntimeRowState, + pub invalid_reason: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStatusResponse { + pub shell: ShellDiscoveryStatus, + pub runtimes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OverrideValidationError { + pub code: String, + pub message: String, +} + +impl std::fmt::Display for OverrideValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for OverrideValidationError {} + +pub fn status_list( + pool: &DbPool, + shell_env: &SharedShellEnv, + discovery: &SharedDiscoveryState, +) -> Result { + let shell_env = shell_env + .read() + .map_err(|_| Error::msg("runtime shell environment lock poisoned"))? + .clone(); + let discovery = discovery + .read() + .map_err(|_| Error::msg("runtime discovery lock poisoned"))? + .clone(); + let overrides = db::runtime_overrides(pool)?; + let path = direct_chat_path(&shell_env); + + let runtimes = runtime_definitions() + .iter() + .map(|runtime| { + let detected_path = + find_executable(runtime.command, &path).map(|path| path.display().to_string()); + let override_path = overrides.get(runtime.name).cloned(); + let invalid_reason = override_path + .as_deref() + .and_then(|path| validate_executable_path(Path::new(path)).err()) + .map(|error| error.message); + let valid_override = override_path + .as_deref() + .filter(|_| invalid_reason.is_none()) + .map(str::to_string); + let (effective_command, effective_source) = if let Some(path) = valid_override { + (Some(path), Some(RuntimeCommandSource::Override)) + } else if let Some(path) = detected_path.clone() { + (Some(path), Some(RuntimeCommandSource::Detected)) + } else if discovery.checking { + ( + Some(runtime.command.to_string()), + Some(RuntimeCommandSource::Catalog), + ) + } else { + (None, None) + }; + let state = if invalid_reason.is_some() { + RuntimeRowState::InvalidOverride + } else if override_path.is_some() { + RuntimeRowState::Override + } else if discovery.checking { + RuntimeRowState::Checking + } else if discovery + .result + .as_ref() + .is_some_and(|result| result.outcome != DiscoveryOutcome::Ok) + { + RuntimeRowState::ProbeTimedOut + } else if detected_path.is_some() { + RuntimeRowState::Detected + } else { + RuntimeRowState::NotFound + }; + RuntimeExecutableStatus { + name: runtime.name.to_string(), + display_name: runtime.display_name.to_string(), + command: runtime.command.to_string(), + detected_path, + override_path, + effective_command, + effective_source, + state, + invalid_reason, + } + }) + .collect(); + + let result = discovery.result.as_ref(); + let failed = result.is_some_and(|result| result.outcome != DiscoveryOutcome::Ok); + Ok(RuntimeStatusResponse { + shell: ShellDiscoveryStatus { + shell: result + .and_then(|result| result.shell.clone()) + .or(discovery.seeded_shell), + outcome: result.map(|result| result.outcome), + duration_ms: result.map(|result| result.duration_ms), + checking: discovery.checking, + using_last_known_good: discovery.last_known_good_captured_at.is_some() + && (discovery.checking || failed), + last_known_good_captured_at: discovery.last_known_good_captured_at, + }, + runtimes, + }) +} + +pub fn effective_runtime_command( + runtime: &str, + pool: &DbPool, + shell_env: &SharedShellEnv, + discovery: &SharedDiscoveryState, +) -> Result { + let overrides = db::runtime_overrides(pool)?; + let shell_env = shell_env + .read() + .map_err(|_| Error::msg("runtime shell environment lock poisoned"))? + .clone(); + let checking = discovery + .read() + .map_err(|_| Error::msg("runtime discovery lock poisoned"))? + .checking; + effective_runtime_command_on_path(runtime, &overrides, &direct_chat_path(&shell_env), checking) +} + +fn effective_runtime_command_on_path( + runtime: &str, + overrides: &std::collections::BTreeMap, + path: &str, + checking: bool, +) -> Result { + let definition = runtime_definition(runtime) + .ok_or_else(|| Error::msg(format!("unknown runtime: {runtime}")))?; + if let Some(path) = overrides.get(runtime) { + if validate_executable_path(Path::new(path)).is_ok() { + log::info!( + "runtime executable: runtime={} source=override command={}", + runtime, + path + ); + return Ok(EffectiveRuntimeCommand { + command: path.clone(), + source: RuntimeCommandSource::Override, + }); + } + } + + if let Some(path) = find_executable(definition.command, path) { + let command = path.display().to_string(); + log::info!( + "runtime executable: runtime={} source=detected command={}", + runtime, + command + ); + return Ok(EffectiveRuntimeCommand { + command, + source: RuntimeCommandSource::Detected, + }); + } + + if checking { + return Ok(EffectiveRuntimeCommand { + command: definition.command.to_string(), + source: RuntimeCommandSource::Catalog, + }); + } + + log::warn!( + "runtime executable not found: runtime={} catalog_command={}", + runtime, + definition.command + ); + Err(runtime_not_found_error(runtime)) +} + +pub fn runtime_not_found_error(runtime: &str) -> Error { + let name = runtime_definition(runtime) + .map(|definition| definition.display_name) + .unwrap_or(runtime); + Error::msg(format!( + "{name} executable was not found. Open Settings → Agents to refresh discovery or set an override." + )) +} + +pub fn validate_override( + runtime: &str, + path: &str, +) -> std::result::Result<(), OverrideValidationError> { + if runtime_definition(runtime).is_none() { + return Err(validation_error( + "unknown_runtime", + format!("Unknown runtime: {runtime}."), + )); + } + validate_executable_path(Path::new(path)) +} + +fn validate_executable_path(path: &Path) -> std::result::Result<(), OverrideValidationError> { + if !path.is_absolute() { + return Err(validation_error( + "not_absolute", + "Choose an absolute executable path.", + )); + } + let metadata = std::fs::metadata(path) + .map_err(|_| validation_error("not_found", "File does not exist."))?; + if !metadata.is_file() { + return Err(validation_error("not_file", "Not a regular file.")); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(validation_error( + "not_executable", + "Not an executable file.", + )); + } + } + Ok(()) +} + +pub(crate) fn executable_path_is_valid(path: &Path) -> bool { + validate_executable_path(path).is_ok() +} + +fn validation_error(code: &str, message: impl Into) -> OverrideValidationError { + OverrideValidationError { + code: code.to_string(), + message: message.into(), + } +} + +pub fn direct_chat_path(shell_env: &LoginShellEnv) -> String { + let process_path = std::env::var("PATH").ok(); + let home = std::env::var_os("HOME").map(PathBuf::from); + launch::compose_path( + None, + None, + shell_env.path.as_deref(), + home.as_deref(), + process_path.as_deref(), + ) +} + +pub fn find_executable(command: &str, path: &str) -> Option { + path.split(':') + .filter(|entry| !entry.is_empty()) + .map(|entry| Path::new(entry).join(command)) + .find(|candidate| validate_executable_path(candidate).is_ok()) +} + +pub fn apply_discovery_result( + pool: &DbPool, + shell_env: &SharedShellEnv, + discovery: &SharedDiscoveryState, + result: DiscoveryResult, +) -> Result<()> { + let mut persistence_error = None; + let captured_at = if result.outcome == DiscoveryOutcome::Ok { + let captured_at = chrono::Utc::now().to_rfc3339(); + *shell_env + .write() + .map_err(|_| Error::msg("runtime shell environment lock poisoned"))? = + result.env.clone(); + let snapshot = LoginShellEnvLkg { + env: result.env.clone(), + shell: result.shell.clone().unwrap_or_default(), + captured_at: captured_at.clone(), + }; + if let Err(error) = db::set_login_shell_env_lkg(pool, &snapshot) { + persistence_error = Some(error); + } + Some(captured_at) + } else { + None + }; + + let mut state = discovery + .write() + .map_err(|_| Error::msg("runtime discovery lock poisoned"))?; + state.checking = false; + state.seeded_shell = result.shell.clone().or_else(|| state.seeded_shell.clone()); + state.result = Some(result); + if let Some(captured_at) = captured_at { + state.last_known_good_captured_at = Some(captured_at); + } + drop(state); + match persistence_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +pub fn start_background_discovery( + events: EventChannel, + pool: Arc, + shell_env: SharedShellEnv, + discovery: SharedDiscoveryState, +) { + std::thread::spawn(move || { + let result = crate::shell_path::resolve_login_shell_env(); + if let Err(error) = apply_discovery_result(&pool, &shell_env, &discovery, result) { + log::warn!("runtime discovery state update failed: {error}"); + if let Ok(mut state) = discovery.write() { + state.checking = false; + } + } + log_runtime_paths(&pool, &shell_env); + events.emit("runtime/changed", &()); + }); +} + +pub fn refresh_background_discovery( + events: EventChannel, + pool: Arc, + shell_env: SharedShellEnv, + discovery: SharedDiscoveryState, +) -> Result { + { + let mut state = discovery + .write() + .map_err(|_| Error::msg("runtime discovery lock poisoned"))?; + if state.checking { + return Ok(false); + } + state.checking = true; + } + events.emit("runtime/changed", &()); + start_background_discovery(events, pool, shell_env, discovery); + Ok(true) +} + +fn log_runtime_paths(pool: &DbPool, shell_env: &SharedShellEnv) { + let Ok(shell_env) = shell_env.read() else { + return; + }; + let path = direct_chat_path(&shell_env); + for runtime in runtime_definitions() { + match find_executable(runtime.command, &path) { + Some(found) => log::info!( + "runtime discovery result: runtime={} detected={}", + runtime.name, + found.display() + ), + None => log::info!( + "runtime discovery result: runtime={} detected=not_found", + runtime.name + ), + } + } + if let Ok(overrides) = db::runtime_overrides(pool) { + for (runtime, path) in overrides { + log::info!( + "runtime override configured: runtime={} valid={}", + runtime, + validate_executable_path(Path::new(&path)).is_ok() + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn executable(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).unwrap(); + path + } + + fn completed_discovery() -> SharedDiscoveryState { + Arc::new(RwLock::new(DiscoveryState { + checking: false, + result: Some(DiscoveryResult { + shell: Some("/bin/zsh".into()), + outcome: DiscoveryOutcome::Ok, + duration_ms: 10, + env: LoginShellEnv::default(), + }), + seeded_shell: None, + last_known_good_captured_at: None, + })) + } + + #[test] + fn status_list_includes_all_catalog_runtimes() { + let pool = crate::db::open_in_memory().unwrap(); + let shell_env = Arc::new(RwLock::new(LoginShellEnv::default())); + let status = status_list(&pool, &shell_env, &completed_discovery()).unwrap(); + assert_eq!( + status + .runtimes + .iter() + .map(|runtime| ( + runtime.name.as_str(), + runtime.display_name.as_str(), + runtime.command.as_str(), + )) + .collect::>(), + vec![ + ("codex", "Codex", "codex"), + ("claude-code", "Claude Code", "claude"), + ("qoder", "Qoder", "qodercli"), + ("trae", "TRAE CLI", "traecli"), + ], + ); + } + + #[test] + fn resolver_requires_regular_executable_file() { + let dir = tempfile::tempdir().unwrap(); + let executable = executable(dir.path(), "codex"); + assert_eq!( + find_executable("codex", &dir.path().display().to_string()), + Some(executable.clone()) + ); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o644); + std::fs::set_permissions(&executable, permissions).unwrap(); + assert_eq!( + find_executable("codex", &dir.path().display().to_string()), + None + ); + } + + #[test] + fn override_validation_rejects_relative_missing_directory_and_non_executable_paths() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + validate_override("codex", "codex").unwrap_err().code, + "not_absolute" + ); + assert_eq!( + validate_override("codex", "/definitely/missing/codex") + .unwrap_err() + .code, + "not_found" + ); + assert_eq!( + validate_override("codex", dir.path().to_str().unwrap()) + .unwrap_err() + .code, + "not_file" + ); + let file = dir.path().join("codex"); + std::fs::write(&file, "#!/bin/sh\n").unwrap(); + assert_eq!( + validate_override("codex", file.to_str().unwrap()) + .unwrap_err() + .code, + "not_executable" + ); + } + + #[test] + fn effective_command_precedence_and_stale_override_fallthrough() { + let pool = crate::db::open_in_memory().unwrap(); + let detected_dir = tempfile::tempdir().unwrap(); + let override_dir = tempfile::tempdir().unwrap(); + let detected = executable(detected_dir.path(), "codex"); + let override_path = executable(override_dir.path(), "codex-custom"); + let shell_env = Arc::new(RwLock::new(LoginShellEnv { + path: Some(detected_dir.path().display().to_string()), + vars: Default::default(), + })); + let discovery = completed_discovery(); + + db::set_runtime_override(&pool, "codex", Some(override_path.to_str().unwrap())).unwrap(); + assert_eq!( + effective_runtime_command("codex", &pool, &shell_env, &discovery) + .unwrap() + .command, + override_path.display().to_string() + ); + std::fs::remove_file(&override_path).unwrap(); + assert_eq!( + effective_runtime_command("codex", &pool, &shell_env, &discovery) + .unwrap() + .command, + detected.display().to_string() + ); + assert_eq!( + status_list(&pool, &shell_env, &discovery).unwrap().runtimes[0].state, + RuntimeRowState::InvalidOverride + ); + } + + #[test] + fn pending_probe_allows_catalog_but_completed_missing_probe_errors() { + assert_eq!( + effective_runtime_command_on_path( + "codex", + &Default::default(), + "/definitely/missing", + true, + ) + .unwrap() + .command, + "codex" + ); + let error = effective_runtime_command_on_path( + "codex", + &Default::default(), + "/definitely/missing", + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("Settings → Agents")); + assert!(error.to_string().contains("Codex")); + } + + #[test] + fn failed_probe_keeps_prior_environment() { + let pool = crate::db::open_in_memory().unwrap(); + let prior = LoginShellEnv { + path: Some("/prior/bin".into()), + vars: Default::default(), + }; + let shell_env = Arc::new(RwLock::new(prior.clone())); + let discovery = Arc::new(RwLock::new(DiscoveryState::pending())); + apply_discovery_result( + &pool, + &shell_env, + &discovery, + DiscoveryResult { + shell: Some("/bin/zsh".into()), + outcome: DiscoveryOutcome::Timeout, + duration_ms: 5_000, + env: LoginShellEnv::default(), + }, + ) + .unwrap(); + assert_eq!(*shell_env.read().unwrap(), prior); + assert_eq!( + discovery.read().unwrap().result.as_ref().unwrap().outcome, + DiscoveryOutcome::Timeout + ); + } +} diff --git a/crates/runner-backend/src/session/codex_capture.rs b/crates/runner-backend/src/session/codex_capture.rs index 5c068d5d..de8d593e 100644 --- a/crates/runner-backend/src/session/codex_capture.rs +++ b/crates/runner-backend/src/session/codex_capture.rs @@ -1,13 +1,12 @@ -// Codex post-spawn session-key capture. +// Codex-lineage post-spawn session-key capture for Codex and TRAE CLI. // // Codex's CLI doesn't accept a caller-provided session id at spawn time // (claude-code does, via `--session-id `), so we can't pre-assign // the key the way the runtime adapter does for claude-code. Instead, -// codex writes a "rollout" file at -// `$HOME/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` whose +// both runtimes write a "rollout" file under their sessions root at +// `YYYY/MM/DD/rollout--.jsonl` whose // first JSON line is a `session_meta` envelope containing `payload.id` -// (the session UUID) and `payload.cwd` (the working directory codex -// started in). +// (the session UUID) and `payload.cwd` (the runtime's working directory). // // After every codex spawn that didn't already have an // `agent_session_key` on the row, we kick off a short-lived background @@ -64,13 +63,23 @@ fn claimed_rollouts() -> &'static Mutex> { SET.get_or_init(|| Mutex::new(HashSet::new())) } -/// Spawn a background thread that captures the codex session id for +pub fn sessions_root_for(runtime: &str) -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + match runtime { + "codex" => Some(home.join(".codex").join("sessions")), + "trae" => Some(home.join(".trae").join("cli").join("sessions")), + _ => None, + } +} + +/// Spawn a background thread that captures the codex-lineage session id for /// `session_id` (a Runner sessions row) and writes it into -/// `agent_session_key`. Returns immediately; no-op if `$HOME/.codex/` -/// doesn't exist. +/// `agent_session_key`. Returns immediately; no-op if the runtime's +/// sessions root doesn't exist. pub struct CaptureRequest { pub session_id: String, pub mission_id: Option, + pub sessions_root: PathBuf, pub spawn_cwd: String, pub started_at: DateTime, pub expected_row_started_at: String, @@ -85,11 +94,7 @@ pub fn spawn_capture(request: CaptureRequest) { } fn run(request: CaptureRequest) { - let Some(home) = std::env::var_os("HOME") else { - return; - }; - let sessions_root = PathBuf::from(home).join(".codex").join("sessions"); - if !sessions_root.is_dir() { + if !request.sessions_root.is_dir() { return; } @@ -110,8 +115,8 @@ fn run(request: CaptureRequest) { loop { let (scan, source) = if let Some(pid) = request.spawn_pid { scan_pid_result_or_fallback( - open_rollout_paths_for_pid(pid, &sessions_root), - &sessions_root, + open_rollout_paths_for_pid(pid, &request.sessions_root), + &request.sessions_root, &candidates, &request.spawn_cwd, request.started_at, @@ -120,7 +125,7 @@ fn run(request: CaptureRequest) { ) } else { scan_fallback_with_marker( - &sessions_root, + &request.sessions_root, &candidates, &request.spawn_cwd, request.started_at, @@ -618,6 +623,20 @@ mod tests { .unwrap(); } + #[test] + fn sessions_root_for_maps_codex_lineage_layouts() { + let home = PathBuf::from(std::env::var_os("HOME").expect("tests require HOME")); + assert_eq!( + sessions_root_for("codex"), + Some(home.join(".codex").join("sessions")), + ); + assert_eq!( + sessions_root_for("trae"), + Some(home.join(".trae").join("cli").join("sessions")), + ); + assert_eq!(sessions_root_for("shell"), None); + } + #[test] fn parse_session_meta_rejects_rollout_before_spawn_timestamp() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/runner-backend/src/session/codex_trust.rs b/crates/runner-backend/src/session/codex_trust.rs new file mode 100644 index 00000000..db4dfb58 --- /dev/null +++ b/crates/runner-backend/src/session/codex_trust.rs @@ -0,0 +1,502 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::error::{Error, Result}; + +static CONFIG_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(not(test))] +pub(crate) fn seed_project_trust(cwd: &Path) -> Result<()> { + let config_path = crate::ops::mcp::codex_path()?; + let home = config_path.parent().and_then(Path::parent).ok_or_else(|| { + Error::msg(format!( + "invalid codex config path: {}", + config_path.display() + )) + })?; + seed_project_trust_at_with_home(cwd, &config_path, Some(home)) +} + +#[cfg(test)] +pub(crate) fn seed_project_trust(_cwd: &Path) -> Result<()> { + // SessionManager tests use mocked runtimes and must not mutate the developer's Codex config. + Ok(()) +} + +#[cfg(test)] +pub(crate) fn seed_project_trust_at(cwd: &Path, config_path: &Path) -> Result<()> { + seed_project_trust_at_with_home(cwd, config_path, None) +} + +fn seed_project_trust_at_with_home( + cwd: &Path, + config_path: &Path, + home: Option<&Path>, +) -> Result<()> { + let project_root = resolve_project_trust_root(cwd)?; + if is_broad_trust_root(&project_root, home) { + log::debug!( + "skipping broad codex project trust root: cwd={} root={}", + cwd.display(), + project_root.display() + ); + return Ok(()); + } + + let _guard = CONFIG_LOCK + .lock() + .map_err(|_| Error::msg("codex trust config lock poisoned"))?; + let write_path = resolve_config_write_path(config_path)?; + let raw = if write_path.exists() { + fs::read_to_string(&write_path) + .map_err(|e| Error::msg(format!("read {}: {e}", write_path.display())))? + } else { + String::new() + }; + let mut doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|e| Error::msg(format!("parse {}: {e}", write_path.display())))?; + + if doc.get("projects").is_none() { + let mut projects = toml_edit::Table::new(); + projects.set_implicit(true); + doc["projects"] = toml_edit::Item::Table(projects); + } + let projects = doc["projects"] + .as_table_mut() + .ok_or_else(|| Error::msg("projects is not a table"))?; + let project_key = project_root.to_string_lossy(); + + if let Some(project) = projects.get_mut(project_key.as_ref()) { + let table = project + .as_table_mut() + .ok_or_else(|| Error::msg(format!("projects.{project_key} is not a table")))?; + if table.contains_key("trust_level") { + return Ok(()); + } + table["trust_level"] = toml_edit::value("trusted"); + } else { + let mut project = toml_edit::Table::new(); + project["trust_level"] = toml_edit::value("trusted"); + projects.insert(project_key.as_ref(), toml_edit::Item::Table(project)); + } + + write_config_atomically(&write_path, doc.to_string().as_bytes())?; + Ok(()) +} + +fn resolve_project_trust_root(cwd: &Path) -> Result { + let cwd = fs::canonicalize(cwd) + .map_err(|e| Error::msg(format!("realpath {}: {e}", cwd.display())))?; + for ancestor in cwd.ancestors() { + let git_marker = ancestor.join(".git"); + if git_marker.is_dir() { + return Ok(ancestor.to_path_buf()); + } + if git_marker.is_file() { + return Ok( + resolve_worktree_main_root(ancestor).unwrap_or_else(|| ancestor.to_path_buf()) + ); + } + } + Ok(cwd) +} + +fn is_broad_trust_root(project_root: &Path, home: Option<&Path>) -> bool { + if project_root.parent().is_none() { + return true; + } + home.map(|home| fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf())) + .as_deref() + == Some(project_root) +} + +fn resolve_config_write_path(config_path: &Path) -> Result { + match fs::symlink_metadata(config_path) { + Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(config_path) + .map_err(|e| Error::msg(format!("realpath {}: {e}", config_path.display()))), + Ok(_) => Ok(config_path.to_path_buf()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(config_path.to_path_buf()), + Err(e) => Err(Error::msg(format!( + "metadata {}: {e}", + config_path.display() + ))), + } +} + +fn write_config_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| Error::msg(format!("config path has no parent: {}", path.display())))?; + fs::create_dir_all(parent) + .map_err(|e| Error::msg(format!("mkdir {}: {e}", parent.display())))?; + let permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let mut temp = tempfile::NamedTempFile::new_in(parent) + .map_err(|e| Error::msg(format!("create temp file in {}: {e}", parent.display())))?; + if let Some(permissions) = permissions { + temp.as_file() + .set_permissions(permissions) + .map_err(|e| Error::msg(format!("set temp permissions for {}: {e}", path.display())))?; + } + temp.write_all(contents) + .map_err(|e| Error::msg(format!("write temp config for {}: {e}", path.display())))?; + temp.as_file() + .sync_all() + .map_err(|e| Error::msg(format!("sync temp config for {}: {e}", path.display())))?; + temp.persist(path) + .map_err(|e| Error::msg(format!("persist {}: {}", path.display(), e.error)))?; + Ok(()) +} + +fn resolve_worktree_main_root(cwd: &Path) -> Option { + let git_file = cwd.join(".git"); + let git_dir_reference = fs::read_to_string(&git_file).ok()?; + let git_dir_path = git_dir_reference.trim().strip_prefix("gitdir:")?.trim(); + if git_dir_path.is_empty() { + return None; + } + let git_dir_path = Path::new(git_dir_path); + let git_dir = if git_dir_path.is_absolute() { + git_dir_path.to_path_buf() + } else { + cwd.join(git_dir_path) + }; + let worktrees_dir = git_dir.parent()?; + if worktrees_dir.file_name()? != "worktrees" { + return None; + } + + let backlink_reference = fs::read_to_string(git_dir.join("gitdir")).ok()?; + let backlink_path = backlink_reference.trim(); + if backlink_path.is_empty() { + return None; + } + let backlink_path = Path::new(backlink_path); + let backlink = if backlink_path.is_absolute() { + backlink_path.to_path_buf() + } else { + git_dir.join(backlink_path) + }; + let canonical_backlink = fs::canonicalize(backlink).ok()?; + let canonical_git_file = fs::canonicalize(&git_file).ok()?; + if canonical_backlink != canonical_git_file { + return None; + } + + let main_root = worktrees_dir.parent()?.parent()?; + fs::canonicalize(main_root).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + use std::sync::{Arc, Barrier}; + use std::thread; + + fn trust_level(config_path: &Path, project_path: &Path) -> Option { + let raw = fs::read_to_string(config_path).unwrap(); + let doc: toml_edit::DocumentMut = raw.parse().unwrap(); + doc.get("projects")? + .as_table()? + .get(project_path.to_string_lossy().as_ref())? + .as_table()? + .get("trust_level")? + .as_str() + .map(str::to_owned) + } + + #[test] + fn empty_config_creates_explicit_project_block() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let config_path = temp.path().join("codex/config.toml"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "").unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!( + fs::read_to_string(config_path).unwrap(), + format!( + "[projects.\"{}\"]\ntrust_level = \"trusted\"\n", + cwd.display() + ) + ); + } + + #[test] + fn missing_config_creates_parent_and_project_block() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let config_path = temp.path().join("codex/config.toml"); + fs::create_dir_all(&cwd).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!(trust_level(&config_path, &cwd).as_deref(), Some("trusted")); + } + + #[test] + fn subdirectory_of_repo_seeds_repo_root() { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("repo"); + let cwd = repo.join("packages/app"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(&cwd).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let repo = fs::canonicalize(repo).unwrap(); + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!(trust_level(&config_path, &repo).as_deref(), Some("trusted")); + assert_eq!(trust_level(&config_path, &cwd), None); + } + + #[test] + fn subdirectory_of_linked_worktree_seeds_main_repo_root() { + let temp = tempfile::tempdir().unwrap(); + let main = temp.path().join("main"); + let worktree = temp.path().join("feature"); + let cwd = worktree.join("packages/app"); + let git_dir = main.join(".git/worktrees/feature"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&git_dir).unwrap(); + fs::create_dir_all(&cwd).unwrap(); + fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + fs::write( + git_dir.join("gitdir"), + format!("{}\n", worktree.join(".git").display()), + ) + .unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let main = fs::canonicalize(main).unwrap(); + let worktree = fs::canonicalize(worktree).unwrap(); + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!(trust_level(&config_path, &main).as_deref(), Some("trusted")); + assert_eq!(trust_level(&config_path, &worktree), None); + assert_eq!(trust_level(&config_path, &cwd), None); + } + + #[test] + fn no_git_ancestor_seeds_cwd() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("plain/nested"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&cwd).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!(trust_level(&config_path, &cwd).as_deref(), Some("trusted")); + } + + #[test] + fn home_git_marker_does_not_widen_trust_to_home() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cwd = home.join("Downloads/project"); + let config_path = home.join(".codex/config.toml"); + fs::create_dir_all(home.join(".git")).unwrap(); + fs::create_dir_all(&cwd).unwrap(); + + seed_project_trust_at_with_home(&cwd, &config_path, Some(&home)).unwrap(); + + assert!(!config_path.exists()); + } + + #[test] + fn filesystem_root_is_rejected_as_too_broad() { + assert!(is_broad_trust_root(Path::new("/"), None)); + } + + #[test] + fn unrelated_config_is_preserved_verbatim() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&cwd).unwrap(); + let existing = "# keep this comment\nmodel = \"gpt-5\"\n\n[mcp_servers.runner]\ncommand = \"/runner\"\n"; + fs::write(&config_path, existing).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + assert_eq!( + fs::read_to_string(config_path).unwrap(), + format!( + "{existing}\n[projects.\"{}\"]\ntrust_level = \"trusted\"\n", + cwd.display() + ) + ); + } + + #[test] + fn existing_trusted_entry_is_untouched() { + assert_existing_entry_is_untouched("trusted"); + } + + #[test] + fn existing_untrusted_entry_is_untouched() { + assert_existing_entry_is_untouched("untrusted"); + } + + fn assert_existing_entry_is_untouched(level: &str) { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&cwd).unwrap(); + let cwd = fs::canonicalize(cwd).unwrap(); + let existing = format!( + "# unchanged\n[projects.\"{}\"]\ntrust_level = \"{level}\" # operator choice\n", + cwd.display() + ); + fs::write(&config_path, &existing).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + assert_eq!(fs::read_to_string(config_path).unwrap(), existing); + } + + #[test] + fn linked_worktree_seeds_main_repo_root() { + let temp = tempfile::tempdir().unwrap(); + let main = temp.path().join("main"); + let worktree = temp.path().join("feature"); + let git_dir = main.join(".git/worktrees/feature"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&git_dir).unwrap(); + fs::create_dir_all(&worktree).unwrap(); + fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + fs::write( + git_dir.join("gitdir"), + format!("{}\n", worktree.join(".git").display()), + ) + .unwrap(); + + seed_project_trust_at(&worktree, &config_path).unwrap(); + + let main = fs::canonicalize(main).unwrap(); + let worktree = fs::canonicalize(worktree).unwrap(); + assert_eq!(trust_level(&config_path, &main).as_deref(), Some("trusted")); + assert_eq!(trust_level(&config_path, &worktree), None); + } + + #[test] + fn forged_gitdir_without_backlink_does_not_widen_trust() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let forged_main = temp.path().join("forged-main"); + let forged_git_dir = forged_main.join(".git/worktrees/forged"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&forged_git_dir).unwrap(); + fs::write( + cwd.join(".git"), + format!("gitdir: {}\n", forged_git_dir.display()), + ) + .unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + let forged_main = fs::canonicalize(forged_main).unwrap(); + assert_eq!(trust_level(&config_path, &cwd).as_deref(), Some("trusted")); + assert_eq!(trust_level(&config_path, &forged_main), None); + } + + #[test] + fn symlinked_cwd_seeds_realpath() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let linked = temp.path().join("linked"); + let config_path = temp.path().join("config.toml"); + fs::create_dir_all(&target).unwrap(); + symlink(&target, &linked).unwrap(); + + seed_project_trust_at(&linked, &config_path).unwrap(); + + let target = fs::canonicalize(target).unwrap(); + assert_eq!( + trust_level(&config_path, &target).as_deref(), + Some("trusted") + ); + assert_eq!(trust_level(&config_path, &linked), None); + } + + #[test] + fn atomic_write_preserves_config_symlink() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("project"); + let target = temp.path().join("dotfiles/codex-config.toml"); + let config_path = temp.path().join("home/.codex/config.toml"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&target, "model = \"gpt-5\"\n").unwrap(); + symlink(&target, &config_path).unwrap(); + + seed_project_trust_at(&cwd, &config_path).unwrap(); + + let cwd = fs::canonicalize(cwd).unwrap(); + assert!(fs::symlink_metadata(&config_path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(trust_level(&target, &cwd).as_deref(), Some("trusted")); + } + + #[test] + fn concurrent_seeds_preserve_every_project_entry() { + let temp = tempfile::tempdir().unwrap(); + let config_path = Arc::new(temp.path().join("config.toml")); + let projects: Vec = (0..8) + .map(|index| temp.path().join(format!("project-{index}"))) + .collect(); + for project in &projects { + fs::create_dir_all(project).unwrap(); + } + let barrier = Arc::new(Barrier::new(projects.len())); + let threads: Vec<_> = projects + .iter() + .cloned() + .map(|project| { + let barrier = Arc::clone(&barrier); + let config_path = Arc::clone(&config_path); + thread::spawn(move || { + barrier.wait(); + seed_project_trust_at(&project, &config_path).unwrap(); + }) + }) + .collect(); + + for thread in threads { + thread.join().unwrap(); + } + for project in projects { + let project = fs::canonicalize(project).unwrap(); + assert_eq!( + trust_level(&config_path, &project).as_deref(), + Some("trusted") + ); + } + } +} diff --git a/crates/runner-backend/src/session/launch.rs b/crates/runner-backend/src/session/launch.rs index 186a138b..49b7f0bd 100644 --- a/crates/runner-backend/src/session/launch.rs +++ b/crates/runner-backend/src/session/launch.rs @@ -33,10 +33,17 @@ const FALLBACK_CLI_DIRS: &[&str] = &[ "~/.local/bin", "~/.cargo/bin", "~/.npm-global/bin", - "/opt/homebrew/bin", - "/usr/local/bin", + "~/.local/share/mise/shims", + "~/.asdf/shims", + "~/.volta/bin", + "~/.bun/bin", + "~/.deno/bin", + "~/Library/pnpm", + "~/.local/share/fnm/aliases/default/bin", ]; +const FALLBACK_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"]; + /// Inputs `render_launch_script` needs that aren't already on /// `SpawnSpec`. Kept separate from `SpawnSpec` because the runtime /// computes some of these (composed PATH) on its own. @@ -100,9 +107,8 @@ pub fn compose_path( push(entry.to_string()); } } - for fallback in FALLBACK_CLI_DIRS { - let expanded = expand_home(fallback, home); - push(expanded); + for fallback in fallback_cli_dirs(home) { + push(fallback); } if let Some(pp) = process_path { for entry in pp.split(':') { @@ -113,6 +119,51 @@ pub fn compose_path( parts.join(":") } +fn fallback_cli_dirs(home: Option<&Path>) -> Vec { + let mut dirs = FALLBACK_CLI_DIRS + .iter() + .map(|path| expand_home(path, home)) + .collect::>(); + dirs.extend(nvm_node_bin_dirs(home)); + dirs.extend( + FALLBACK_SYSTEM_DIRS + .iter() + .map(|path| expand_home(path, home)), + ); + dirs +} + +fn nvm_node_bin_dirs(home: Option<&Path>) -> Vec { + let Some(versions_dir) = home.map(|home| home.join(".nvm/versions/node")) else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(versions_dir) else { + return Vec::new(); + }; + let mut versions = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let bin = entry.path().join("bin"); + bin.is_dir().then(|| { + ( + node_version_key(&entry.file_name().to_string_lossy()), + bin.display().to_string(), + ) + }) + }) + .collect::>(); + versions.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + versions.into_iter().map(|(_, path)| path).collect() +} + +fn node_version_key(version: &str) -> Vec { + version + .trim_start_matches('v') + .split('.') + .map(|part| part.parse().unwrap_or(0)) + .collect() +} + /// Expand a leading `~/` against the caller's HOME. Non-tilde paths /// pass through unchanged. We intentionally don't shell out for /// expansion — keeping it pure makes the function trivially @@ -307,9 +358,10 @@ mod tests { Some(Path::new("/Users/test")), Some("/usr/bin:/bin"), ); - assert!(!path.contains("shims"), "path = {path}"); - // Doesn't contain "/runner/bin" (the bundled-bin path - // shape) — it wasn't passed in. + // Doesn't contain the per-mission shim or "/runner/bin" + // bundled-bin path shapes — neither was passed in. Version + // manager fallback paths may legitimately contain "shims". + assert!(!path.contains("/data/shims/build/bin"), "path = {path}"); assert!(!path.contains("runner/bin"), "path = {path}"); assert!(path.contains("/opt/homebrew/bin"), "path = {path}"); } @@ -354,6 +406,13 @@ mod tests { "/h/.local/bin", "/h/.cargo/bin", "/h/.npm-global/bin", + "/h/.local/share/mise/shims", + "/h/.asdf/shims", + "/h/.volta/bin", + "/h/.bun/bin", + "/h/.deno/bin", + "/h/Library/pnpm", + "/h/.local/share/fnm/aliases/default/bin", "/opt/homebrew/bin", "/usr/local/bin", ] { @@ -361,6 +420,44 @@ mod tests { } } + #[test] + fn compose_path_keeps_shell_entries_before_seed_and_orders_nvm_newest_first() { + let home = tempfile::tempdir().unwrap(); + for version in ["v9.9.9", "v20.12.1", "v18.20.0"] { + std::fs::create_dir_all( + home.path() + .join(".nvm/versions/node") + .join(version) + .join("bin"), + ) + .unwrap(); + } + let path = compose_path( + None, + None, + Some("/shell/bin:/opt/homebrew/bin"), + Some(home.path()), + Some("/usr/bin"), + ); + let parts = path.split(':').collect::>(); + assert_eq!(parts[0], "/shell/bin"); + assert_eq!( + parts + .iter() + .filter(|entry| **entry == "/opt/homebrew/bin") + .count(), + 1 + ); + let nvm = parts + .iter() + .filter(|entry| entry.contains(".nvm/versions/node")) + .copied() + .collect::>(); + assert!(nvm[0].contains("v20.12.1"), "{nvm:?}"); + assert!(nvm[1].contains("v18.20.0"), "{nvm:?}"); + assert!(nvm[2].contains("v9.9.9"), "{nvm:?}"); + } + #[test] fn compose_path_dedupes_repeats() { // shell_path already includes /opt/homebrew/bin; fallbacks @@ -429,6 +526,19 @@ mod tests { assert!(body.contains("exec 'claude'\n")); } + #[test] + fn render_launch_script_quotes_command_path_with_spaces() { + let script = LaunchScript { + command: "/Applications/Agent Tools/codex".into(), + args: vec!["resume".into()], + cwd: None, + env: BTreeMap::new(), + path: "/usr/bin".into(), + }; + let body = render_launch_script(&script).unwrap(); + assert!(body.contains("exec '/Applications/Agent Tools/codex' 'resume'\n")); + } + #[test] fn render_launch_script_quotes_command_with_spaces() { // Defensive: if a caller executes through a shell wrapper, an diff --git a/crates/runner-backend/src/session/manager/mod.rs b/crates/runner-backend/src/session/manager/mod.rs index 35285281..6066f167 100644 --- a/crates/runner-backend/src/session/manager/mod.rs +++ b/crates/runner-backend/src/session/manager/mod.rs @@ -20,7 +20,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::RecvTimeoutError; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -510,9 +510,9 @@ struct SessionHandle { /// `runtime.resize` / `runtime.stop` for every operation on the /// live session. runtime_session: RuntimeSession, - /// Codex cannot be given a caller-owned session id at launch. + /// Codex-lineage runtimes cannot be given a caller-owned session id at launch. /// When this is present, user activity can retry native id - /// capture after Codex has actually created its rollout file. + /// capture after the runtime has actually created its rollout file. codex_capture: Option, /// Forwarder thread that drains the runtime's `OutputStream` /// into `session/output` events. `kill` joins on this so callers @@ -532,6 +532,7 @@ struct SessionHandle { #[derive(Clone)] struct CodexCaptureContext { mission_id: Option, + sessions_root: PathBuf, spawn_cwd: String, started_at: DateTime, row_started_at: String, @@ -617,9 +618,9 @@ pub struct SessionManager { /// PTY output for one busy session does not block lifecycle work on /// other sessions. sessions: Mutex>>>, - /// User's login-shell env snapshot, captured once at app start by - /// `shell_path::resolve_login_shell_env`. Empty when the resolve - /// failed/timed out, when running on Windows, or in tests. + /// User's current login-shell env snapshot. Discovery swaps this + /// handle after a successful background probe; spawns clone one + /// coherent value under a short read lock. /// /// `path` is composed into every child PTY's PATH (so GUI-launched /// apps can find tools like claude / codex / mise that aren't on @@ -627,7 +628,8 @@ pub struct SessionManager { /// proxy quartet in both cases) is layered into every spawn's env /// under `runner.env` so the child can reach the network the same /// way Terminal.app's children would (issues #109 / #152). - shell_env: crate::shell_path::LoginShellEnv, + shell_env: Arc>, + discovery_state: crate::runtime_status::SharedDiscoveryState, /// Timestamp of the most recent claude-code spawn through the /// launch gate. `None` until the first claude-code spawn lands. /// Each new claude-code spawn reads this, sleeps the remainder @@ -737,12 +739,14 @@ fn compute_gate_wait(last: Option, now: Instant, grace: Duration) -> Du impl SessionManager { pub fn new( - shell_env: crate::shell_path::LoginShellEnv, + shell_env: crate::runtime_status::SharedShellEnv, + discovery_state: crate::runtime_status::SharedDiscoveryState, runtime: Arc, ) -> Arc { Arc::new(Self { sessions: Mutex::new(HashMap::new()), shell_env, + discovery_state, claude_launch_gate: Mutex::new(None), pending_mission_cancels: Mutex::new(HashMap::new()), runtime, @@ -917,6 +921,7 @@ impl SessionManager { crate::session::codex_capture::CaptureRequest { session_id: session_id.to_string(), mission_id: ctx.mission_id.clone(), + sessions_root: ctx.sessions_root.clone(), spawn_cwd: ctx.spawn_cwd.clone(), started_at: ctx.started_at, expected_row_started_at: ctx.row_started_at.clone(), @@ -1003,15 +1008,10 @@ fn capture_cwd(explicit: Option) -> Option { /// (feature 41). #[derive(Debug)] pub(crate) struct RuntimeOverrideResolution { - /// Rebuilt runner config when the override names a *different* - /// runtime than the runner's: registry command, the default - /// permission mode's canonical args (same as a bare runtime - /// chat), `model` / `effort` cleared; persona fields - /// (`system_prompt`, `working_dir`, `env`, handles) carry over. - /// `None` = spawn from the runner row untouched, byte-identical - /// to no override. + /// Rebuilt runner config after applying any runtime, model, or + /// effort override. `None` means the runner row is byte-identical. pub effective: Option, - /// True when a non-blank override was explicitly requested — + /// True when a non-blank runtime override was explicitly requested — /// including one matching the runner's current runtime. Spawn /// paths record the effective runtime on the session row for /// pinned spawns so a later edit to the runner template's @@ -1020,46 +1020,73 @@ pub(crate) struct RuntimeOverrideResolution { pub pinned: bool, } -/// Resolve the runner config a spawn should actually use when a -/// runtime override is in play (feature 41). Effective runtime = -/// `override ?? runner.runtime`; a matching override keeps the spawn -/// byte-identical but still pins. Unknown differing runtime names -/// error. +/// Resolve the runner config a spawn should actually use. Layering is +/// runner template, then runtime override, then model/effort overrides. +/// A matching runtime override keeps an otherwise unchanged spawn +/// byte-identical but still pins. Model/effort-only overrides never pin. pub(crate) fn resolve_runtime_override( runner: &Runner, runtime_override: Option<&str>, + model_override: Option<&str>, + effort_override: Option<&str>, ) -> Result { - let Some(name) = runtime_override.map(str::trim).filter(|s| !s.is_empty()) else { + let runtime_override = runtime_override.map(str::trim).filter(|s| !s.is_empty()); + let model_override = model_override + .map(str::trim) + .filter(|value| !value.is_empty()); + let effort_override = effort_override + .map(str::trim) + .filter(|value| !value.is_empty()); + let pinned = runtime_override.is_some(); + if runtime_override.is_none() && model_override.is_none() && effort_override.is_none() { return Ok(RuntimeOverrideResolution { effective: None, pinned: false, }); - }; - if name == runner.runtime { + } + if runtime_override == Some(runner.runtime.as_str()) + && model_override.is_none() + && effort_override.is_none() + { return Ok(RuntimeOverrideResolution { effective: None, pinned: true, }); } - let def = router::runtime::runtime_definition(name) - .ok_or_else(|| Error::msg(format!("unknown runtime: {name}")))?; let mut effective = runner.clone(); - effective.runtime = def.name.to_string(); - effective.command = def.command.to_string(); - effective.args = router::runtime::apply_permission_mode( - def.name, - &[], - crate::ops::runner::default_permission_mode(), - ); - effective.model = None; - effective.effort = None; + if let Some(name) = runtime_override.filter(|name| *name != runner.runtime.as_str()) { + let def = router::runtime::runtime_definition(name) + .ok_or_else(|| Error::msg(format!("unknown runtime: {name}")))?; + effective.runtime = def.name.to_string(); + effective.command = def.command.to_string(); + effective.args = router::runtime::apply_permission_mode( + def.name, + &[], + crate::ops::runner::default_permission_mode(), + ); + // A differing engine starts from its own defaults; the + // runner's model/effort belong to the original runtime. + effective.model = None; + effective.effort = None; + } + if model_override.is_some() { + effective.model = model_override.map(ToOwned::to_owned); + } + if effort_override.is_some() { + effective.effort = effort_override.map(ToOwned::to_owned); + } Ok(RuntimeOverrideResolution { effective: Some(effective), - pinned: true, + pinned, }) } -pub(crate) fn runtime_direct_runner(runtime: &str, command: Option<&str>) -> Result { +pub(crate) fn runtime_direct_runner( + runtime: &str, + command: Option<&str>, + model: Option<&str>, + effort: Option<&str>, +) -> Result { let runtime = runtime.trim(); if runtime.is_empty() { return Err(Error::msg("runtime is required")); @@ -1087,8 +1114,14 @@ pub(crate) fn runtime_direct_runner(runtime: &str, command: Option<&str>) -> Res working_dir: None, system_prompt: None, env: HashMap::new(), - model: None, - effort: None, + model: model + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + effort: effort + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), created_at: now, updated_at: now, }) diff --git a/crates/runner-backend/src/session/manager/output.rs b/crates/runner-backend/src/session/manager/output.rs index 81457c48..f63bbca9 100644 --- a/crates/runner-backend/src/session/manager/output.rs +++ b/crates/runner-backend/src/session/manager/output.rs @@ -643,7 +643,7 @@ impl SessionManager { let mut events: Vec = state.output_buffer.iter().cloned().collect(); // For sessions currently inside terminal modes that xterm // reset clears, prepend a synthetic chunk restoring them. - // Long-running TUI sessions (claude-code, codex) lose the + // Long-running TUI sessions lose the // original enter-alt-screen escape from the bounded // 4096-chunk buffer over time, so a re-attach that just // replays the remaining chunks lands mid-alt-screen content @@ -877,19 +877,21 @@ pub(super) fn runtime_clears_on_resize(session_id: &str, pool: &DbPool) -> bool ) .ok() .flatten(); - matches!(runtime.as_deref(), Some("claude-code") | Some("codex")) + matches!( + runtime.as_deref(), + Some("claude-code") | Some("codex") | Some("qoder") | Some("trae") + ) } /// Whether `resume` should drop the session's output ring before -/// spawning the new PTY. Codex repaints its whole frame on resume -/// (and its own resume replay restores a deep conversation tail), so +/// spawning the new PTY. Codex and TRAE repaint their whole frame on resume +/// (and their own resume replays restore a deep conversation tail), so /// replaying retained scrollback under the new frame stacks garbled -/// content — the artifact class from impls 0009/0011/0020. Claude-code -/// paints inline into the main screen: kept scrollback, then the +/// content — the artifact class from impls 0009/0011/0020. Claude Code +/// and Qoder paint inline into the main screen: kept scrollback, then the /// resume banner, then the tail repaint is exactly what a physical -/// terminal shows, so its ring is kept. Shells and future runtimes -/// keep today's purge purely -/// for scope (extending them is a one-line change here). Best-effort: +/// terminal shows, so their rings are kept. Shells and future runtimes +/// keep today's purge purely for scope. Best-effort: /// a DB miss fails toward the purge, i.e. today's behavior. pub(super) fn runtime_purges_on_resume(session_id: &str, pool: &DbPool) -> bool { let Ok(conn) = pool.get() else { @@ -906,5 +908,5 @@ pub(super) fn runtime_purges_on_resume(session_id: &str, pool: &DbPool) -> bool ) .ok() .flatten(); - !matches!(runtime.as_deref(), Some("claude-code")) + !matches!(runtime.as_deref(), Some("claude-code") | Some("qoder")) } diff --git a/crates/runner-backend/src/session/manager/spawn.rs b/crates/runner-backend/src/session/manager/spawn.rs index ba132bb9..9d7d2081 100644 --- a/crates/runner-backend/src/session/manager/spawn.rs +++ b/crates/runner-backend/src/session/manager/spawn.rs @@ -1,6 +1,50 @@ use super::*; impl SessionManager { + fn resolve_runner_executable(&self, runner: &Runner, pool: &DbPool) -> Result { + let Some(definition) = router::runtime::runtime_definition(&runner.runtime) else { + return Ok(runner.clone()); + }; + if runner.command != definition.command { + return Ok(runner.clone()); + } + let effective = crate::runtime_status::effective_runtime_command( + definition.name, + pool, + &self.shell_env, + &self.discovery_state, + )?; + let mut resolved = runner.clone(); + resolved.command = effective.command; + Ok(resolved) + } + + fn resolve_runtime_only_resume_runner( + &self, + runtime: &str, + recorded_command: Option<&str>, + model: Option<&str>, + effort: Option<&str>, + pool: &DbPool, + ) -> Result { + let definition = router::runtime::runtime_definition(runtime) + .ok_or_else(|| Error::msg(format!("unknown runtime: {runtime}")))?; + let recorded = recorded_command + .map(str::trim) + .filter(|command| !command.is_empty()); + if let Some(command) = recorded { + let path = Path::new(command); + if path.is_absolute() && crate::runtime_status::executable_path_is_valid(path) { + return runtime_direct_runner(runtime, Some(command), model, effort); + } + if !path.is_absolute() && command != definition.command { + return runtime_direct_runner(runtime, Some(command), model, effort); + } + } + let runner = runtime_direct_runner(runtime, None, model, effort)?; + self.resolve_runner_executable(&runner, pool) + } + /// Gate a fresh `claude-code` spawn before calling /// `runtime.spawn()`. No-op for any other runtime — those /// bypass the gate. @@ -42,6 +86,23 @@ impl SessionManager { } *last = Some(Instant::now()); } + + fn seed_codex_project_trust(&self, session_id: &str, runtime: &str, cwd: Option<&Path>) { + if runtime != "codex" { + return; + } + let Some(cwd) = cwd else { + log::debug!("skipping codex project trust seed without cwd: session={session_id}"); + return; + }; + if let Err(e) = crate::session::codex_trust::seed_project_trust(cwd) { + log::warn!( + "failed to seed codex project trust: session={session_id} cwd={} error={e}", + cwd.display() + ); + } + } + /// Build a `SpawnSpec` skeleton with the manager's stable inputs /// (shell PATH, runner env after merging system vars). The /// runtime adapter argv (resume_plan + trailing_runtime_args) @@ -59,11 +120,16 @@ impl SessionManager { initial_size: Option<(u16, u16)>, extra_env: BTreeMap, ) -> SpawnSpec { + let shell_env = self + .shell_env + .read() + .expect("runtime shell environment lock poisoned") + .clone(); // Bottom layer: login-shell vars (proxy quartet, both cases) // captured at app start. A runner row can override any of these // by setting the same name in its own env map — the runner row // is the most specific configuration surface. - let mut env: BTreeMap = self.shell_env.vars.clone(); + let mut env: BTreeMap = shell_env.vars; for (k, v) in &runner.env { env.insert(k.clone(), v.clone()); } @@ -86,7 +152,7 @@ impl SessionManager { mission, shim_dir, bundled_bin_dir, - shell_path: self.shell_env.path.clone(), + shell_path: shell_env.path, initial_size, } } @@ -139,12 +205,12 @@ impl SessionManager { delivered_via_argv } - fn codex_capture_prompt_marker( + pub(super) fn codex_capture_prompt_marker( runtime: &str, session_id: &str, first_turn: Option, ) -> (Option, Option) { - if runtime != "codex" { + if !matches!(runtime, "codex" | "trae") { return (first_turn, None); } let Some(first_turn) = first_turn else { @@ -187,12 +253,20 @@ impl SessionManager { // Slot-level runtime override (feature 41): the effective // runtime is `slot.runtime_override ?? runner.runtime`. On a - // differing override the spawn uses registry command/default - // args and drops model/effort; persona fields carry over. A - // matching override spawns byte-identically but still pins. - let resolution = resolve_runtime_override(runner, slot.runtime_override.as_deref())?; + // differing override the spawn uses registry command, default + // args, model, and effort; persona fields carry over. Slot + // model/effort overrides apply last and do not themselves pin + // the engine. A matching runtime override still pins. + let resolution = resolve_runtime_override( + runner, + slot.runtime_override.as_deref(), + slot.model_override.as_deref(), + slot.effort_override.as_deref(), + )?; let pinned = resolution.pinned; - let runner = resolution.effective.as_ref().unwrap_or(runner); + let agent_options_overridden = resolution.effective.is_some(); + let runner = + self.resolve_runner_executable(resolution.effective.as_ref().unwrap_or(runner), &pool)?; // Agent-native session resume: this is a *fresh* session row, so // there's no prior key to inherit. The runtime adapter still @@ -248,7 +322,7 @@ impl SessionManager { Self::codex_capture_prompt_marker(&runner.runtime, &session_id, first_turn); let mut spec = self.base_spawn_spec( session_id.clone(), - runner, + &runner, resolved_cwd.clone(), true, shim_dir, @@ -260,7 +334,7 @@ impl SessionManager { runner_core::event_log::path::mission_dir(app_data_dir, &mission.crew_id, &mission.id); let first_turn_delivered_via_argv = Self::apply_runtime_args( &mut spec, - runner, + &runner, &plan, first_turn.as_deref(), Some(&mission_bus_dir), @@ -292,6 +366,10 @@ impl SessionManager { row.agent_runtime = Some(runner.runtime.clone()); row.agent_command = Some(runner.command.clone()); } + if agent_options_overridden { + row.agent_model = runner.model.clone(); + row.agent_effort = runner.effort.clone(); + } crate::repo::session::insert(&conn, &row)?; } @@ -393,6 +471,7 @@ impl SessionManager { let spawn_started_at_dt = Utc::now(); let initial_size = spec.initial_size; + self.seed_codex_project_trust(&session_id, &runner.runtime, spec.cwd.as_deref()); let (rt_session, output) = self .runtime .spawn(spec) @@ -438,20 +517,26 @@ impl SessionManager { ); } - let codex_capture = if runner.runtime == "codex" && plan.assigned_key.is_none() { - capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { - mission_id: Some(mission.id.clone()), - spawn_cwd: cwd, - started_at: spawn_started_at_dt, - row_started_at: row_started_at.clone(), - spawn_pid, - prompt_marker: codex_prompt_marker.clone(), - pool: Arc::clone(&pool), - events: Arc::clone(&events), - }) - } else { - None - }; + let codex_capture = + if matches!(runner.runtime.as_str(), "codex" | "trae") && plan.assigned_key.is_none() { + crate::session::codex_capture::sessions_root_for(&runner.runtime).and_then( + |sessions_root| { + capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { + mission_id: Some(mission.id.clone()), + sessions_root, + spawn_cwd: cwd, + started_at: spawn_started_at_dt, + row_started_at: row_started_at.clone(), + spawn_pid, + prompt_marker: codex_prompt_marker.clone(), + pool: Arc::clone(&pool), + events: Arc::clone(&events), + }) + }, + ) + } else { + None + }; let spawn_emit_ctx = open_mission_event_log(&app_data_dir, &mission.crew_id, &mission.id) .map(|event_log| ForwarderEmitCtx { @@ -499,8 +584,10 @@ impl SessionManager { } emit_runner_activity(&pool, &runner, events.as_ref()); - if matches!(runner.runtime.as_str(), "claude-code" | "codex") - && !plan.resuming + if matches!( + runner.runtime.as_str(), + "claude-code" | "codex" | "qoder" | "trae" + ) && !plan.resuming && !first_turn_delivered_via_argv { log::warn!( @@ -626,11 +713,15 @@ impl SessionManager { /// `None` spawns the runner's own runtime unchanged; a differing /// registry runtime spawns that engine with registry command / /// default args while the runner's persona fields carry over. + /// `model_override` / `effort_override` apply after runtime + /// resolution and can also customize the runner's own engine. #[allow(clippy::too_many_arguments)] pub fn spawn_direct( self: &Arc, runner: &Runner, runtime_override: Option<&str>, + model_override: Option<&str>, + effort_override: Option<&str>, project_id: Option<&str>, cwd: Option<&str>, cols: Option, @@ -643,6 +734,8 @@ impl SessionManager { self.spawn_direct_inner( runner, runtime_override, + model_override, + effort_override, Some(runner.id.as_str()), project_id, cwd, @@ -672,6 +765,8 @@ impl SessionManager { runner, None, None, + None, + None, project_id, cwd, cols, @@ -689,6 +784,8 @@ impl SessionManager { self: &Arc, runner: &Runner, runtime_override: Option<&str>, + model_override: Option<&str>, + effort_override: Option<&str>, persisted_runner_id: Option<&str>, project_id: Option<&str>, cwd: Option<&str>, @@ -704,9 +801,12 @@ impl SessionManager { // Chat-level runtime override (feature 41) — same resolution // rule as mission spawns. - let resolution = resolve_runtime_override(runner, runtime_override)?; + let resolution = + resolve_runtime_override(runner, runtime_override, model_override, effort_override)?; let pinned = resolution.pinned; - let runner = resolution.effective.as_ref().unwrap_or(runner); + let agent_options_overridden = resolution.effective.is_some(); + let runner = + self.resolve_runner_executable(resolution.effective.as_ref().unwrap_or(runner), &pool)?; // Agent-native session resume: `spawn_direct` always opens a *new* // chat. The runtime adapter self-assigns a fresh @@ -734,7 +834,7 @@ impl SessionManager { let mut spec = self.base_spawn_spec( session_id.clone(), - runner, + &runner, resolved_cwd.clone(), false, None, // shim_dir — off-bus @@ -743,7 +843,7 @@ impl SessionManager { direct_env, ); let first_turn_delivered_via_argv = - Self::apply_runtime_args(&mut spec, runner, &plan, first_turn.as_deref(), None); + Self::apply_runtime_args(&mut spec, &runner, &plan, first_turn.as_deref(), None); // Insert the row first so a fast-failing spawn doesn't leave // a half-row. Runtime-only chats (no persisted runner template) @@ -766,6 +866,10 @@ impl SessionManager { row.agent_runtime = Some(runner.runtime.clone()); row.agent_command = Some(runner.command.clone()); } + if persisted_runner_id.is_none() || agent_options_overridden { + row.agent_model = runner.model.clone(); + row.agent_effort = runner.effort.clone(); + } crate::repo::session::insert(&conn, &row)?; } @@ -786,6 +890,7 @@ impl SessionManager { } let spawn_started_at_dt = Utc::now(); + self.seed_codex_project_trust(&session_id, &runner.runtime, spec.cwd.as_deref()); let (rt_session, output) = match self.runtime.spawn(spec) { Ok(p) => p, Err(e) => { @@ -824,20 +929,26 @@ impl SessionManager { ); } - let codex_capture = if runner.runtime == "codex" && plan.assigned_key.is_none() { - capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { - mission_id: None, - spawn_cwd: cwd, - started_at: spawn_started_at_dt, - row_started_at: started_at.clone(), - spawn_pid, - prompt_marker: codex_prompt_marker.clone(), - pool: Arc::clone(&pool), - events: Arc::clone(&events), - }) - } else { - None - }; + let codex_capture = + if matches!(runner.runtime.as_str(), "codex" | "trae") && plan.assigned_key.is_none() { + crate::session::codex_capture::sessions_root_for(&runner.runtime).and_then( + |sessions_root| { + capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { + mission_id: None, + sessions_root, + spawn_cwd: cwd, + started_at: spawn_started_at_dt, + row_started_at: started_at.clone(), + spawn_pid, + prompt_marker: codex_prompt_marker.clone(), + pool: Arc::clone(&pool), + events: Arc::clone(&events), + }) + }, + ) + } else { + None + }; self.install_handle( &session_id, @@ -882,10 +993,12 @@ impl SessionManager { } if emit_activity { - emit_runner_activity(&pool, runner, events.as_ref()); + emit_runner_activity(&pool, &runner, events.as_ref()); } - if matches!(runner.runtime.as_str(), "claude-code" | "codex") - && !plan.resuming + if matches!( + runner.runtime.as_str(), + "claude-code" | "codex" | "qoder" | "trae" + ) && !plan.resuming && !first_turn_delivered_via_argv { log::warn!( @@ -994,10 +1107,9 @@ impl SessionManager { // and new PTY chunks continue above `last` and replay merge // filters don't drop them. // - // Whether the ring itself survives is per-runtime: claude-code - // keeps it (a terminal emulator would; scrolling up after - // resume shows the prior conversation), codex/shells purge — - // see `runtime_purges_on_resume`. + // Whether the ring itself survives is per-runtime: Claude and Qoder + // keep it because their resume flows do not replay the transcript; + // Codex, TRAE, and shells purge it. See `runtime_purges_on_resume`. self.set_resume_watermark(session_id); let purges_on_resume = output::runtime_purges_on_resume(session_id, &pool); if purges_on_resume { @@ -1063,46 +1175,64 @@ impl SessionManager { let runner = if let Some(runner_id) = snap.runner_id.as_deref() { let conn = pool.get()?; let runner = crate::ops::runner::get(&conn, runner_id)?; - match resolve_runtime_override(&runner, snap.agent_runtime.as_deref())?.effective { + let runner = match resolve_runtime_override( + &runner, + snap.agent_runtime.as_deref(), + snap.agent_model.as_deref(), + snap.agent_effort.as_deref(), + )? + .effective + { Some(effective) => effective, None => runner, - } + }; + self.resolve_runner_executable(&runner, &pool)? } else { let runtime = snap.agent_runtime.as_deref().ok_or_else(|| { Error::msg(format!( "runtime-only session {session_id} missing agent_runtime" )) })?; - runtime_direct_runner(runtime, snap.agent_command.as_deref())? + self.resolve_runtime_only_resume_runner( + runtime, + snap.agent_command.as_deref(), + snap.agent_model.as_deref(), + snap.agent_effort.as_deref(), + &pool, + )? }; // Resume plan: hand the prior agent_session_key back to the // runtime adapter so claude-code uses `--resume ` and // codex (once capture lands) uses `codex resume `. // - // claude-code only: if the conversation file for this + // claude-code / qoder only: if the conversation file for this // (cwd, uuid) was never persisted, `--resume ` would // print "No conversation found" and leave the TUI half-broken. // Detect the missing file up front and degrade to a fresh - // spawn that *keeps* the same uuid via `--session-id`. + // spawn with a newly self-assigned uuid via `--session-id`. let resolved_cwd_for_check: Option = snap.cwd.clone().or_else(|| { snap.runner_id .as_ref() .and_then(|_| runner.working_dir.clone()) }); let is_lead_slot = mission_ctx.as_ref().is_some_and(|c| c.lead); - let conversation_missing = matches!( - (runner.runtime.as_str(), snap.agent_session_key.as_deref()), - ("claude-code", Some(key)) - if !router::runtime::claude_code_conversation_exists( + let conversation_missing = + match (runner.runtime.as_str(), snap.agent_session_key.as_deref()) { + ("claude-code", Some(key)) => !router::runtime::claude_code_conversation_exists( resolved_cwd_for_check.as_deref(), key, - ) - ); + ), + ("qoder", Some(key)) => !router::runtime::qoder_conversation_exists( + resolved_cwd_for_check.as_deref(), + key, + ), + _ => false, + }; let fresh_fallback_lead = conversation_missing && is_lead_slot; let effective_prior_key = match (runner.runtime.as_str(), snap.agent_session_key.as_deref()) { - ("claude-code", Some(_)) if conversation_missing => None, + ("claude-code" | "qoder", Some(_)) if conversation_missing => None, (_, k) => k, }; let plan = router::runtime::resume_plan(&runner.runtime, effective_prior_key); @@ -1214,6 +1344,7 @@ impl SessionManager { // N stopped slots can spawn as fast as the runtime allows. // See issue #171. let spawn_started_at_dt = Utc::now(); + self.seed_codex_project_trust(session_id, &runner.runtime, spec.cwd.as_deref()); let (rt_session, output) = match self.runtime.spawn(spec) { Ok(p) => p, Err(e) => { @@ -1242,20 +1373,26 @@ impl SessionManager { ); } - let codex_capture = if runner.runtime == "codex" && plan.assigned_key.is_none() { - capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { - mission_id: snap.mission_id.clone(), - spawn_cwd: cwd, - started_at: spawn_started_at_dt, - row_started_at: started_at.clone(), - spawn_pid, - prompt_marker: None, - pool: Arc::clone(&pool), - events: Arc::clone(&events), - }) - } else { - None - }; + let codex_capture = + if matches!(runner.runtime.as_str(), "codex" | "trae") && plan.assigned_key.is_none() { + crate::session::codex_capture::sessions_root_for(&runner.runtime).and_then( + |sessions_root| { + capture_cwd(resolved_cwd.clone()).map(|cwd| CodexCaptureContext { + mission_id: snap.mission_id.clone(), + sessions_root, + spawn_cwd: cwd, + started_at: spawn_started_at_dt, + row_started_at: started_at.clone(), + spawn_pid, + prompt_marker: None, + pool: Arc::clone(&pool), + events: Arc::clone(&events), + }) + }, + ) + } else { + None + }; let resume_emit_ctx = mission_ctx.as_ref().and_then(|ctx| { open_mission_event_log(app_data_dir, &ctx.crew_id, &ctx.mission_id).map(|event_log| { @@ -1331,7 +1468,11 @@ impl SessionManager { // returned SpawnedSession. For direct-chat resume there's no // slot/lead concept; if that degrades to fresh and argv // delivery was unavailable, we log the skipped injection. - if matches!(runner.runtime.as_str(), "claude-code" | "codex") && !plan.resuming { + if matches!( + runner.runtime.as_str(), + "claude-code" | "codex" | "qoder" | "trae" + ) && !plan.resuming + { if mission_ctx.is_some() { log::warn!( "first-turn argv not delivered for {session_id} (runtime {}); skipping post-spawn injection", diff --git a/crates/runner-backend/src/session/manager/tests.rs b/crates/runner-backend/src/session/manager/tests.rs index e8b3a395..c87b5119 100644 --- a/crates/runner-backend/src/session/manager/tests.rs +++ b/crates/runner-backend/src/session/manager/tests.rs @@ -51,6 +51,19 @@ fn inert_runtime() -> Arc { Arc::new(InertRuntime) } +fn manager_with_runtime( + shell_env: crate::shell_path::LoginShellEnv, + runtime: Arc, +) -> Arc { + SessionManager::new( + Arc::new(std::sync::RwLock::new(shell_env)), + Arc::new(std::sync::RwLock::new( + crate::shell_path::DiscoveryState::startup(None, None), + )), + runtime, + ) +} + /// Test stand-in that captures every call so assertions can read /// back what the manager handed to the runtime layer (env vars, /// argv, byte writes, key names, resize dimensions). Lets @@ -304,7 +317,7 @@ fn fake_runtime() -> Arc { /// Build a manager backed by the supplied FakeRuntime. Returns /// the Arc so tests can introspect the captured calls. fn mgr_with_fake(shell: Option, fake: Arc) -> Arc { - SessionManager::new( + manager_with_runtime( crate::shell_path::LoginShellEnv { path: shell, vars: Default::default(), @@ -355,6 +368,16 @@ fn runner(command: &str, args: &[&str]) -> Runner { } } +fn assert_effective_command(command: &str, catalog_name: &str) { + assert_eq!( + std::path::Path::new(command) + .file_name() + .and_then(|name| name.to_str()), + Some(catalog_name), + "expected {catalog_name} or an absolute path ending in {catalog_name}, got {command}", + ); +} + fn slot_for(runner: &Runner) -> crate::model::Slot { crate::model::Slot { id: ulid::Ulid::new().to_string(), @@ -1098,7 +1121,7 @@ fn inject_stdin_roundtrip_routes_through_runtime() { #[test] fn inject_stdin_on_unknown_session_errors_cleanly() { - let mgr = SessionManager::new(crate::shell_path::LoginShellEnv::default(), inert_runtime()); + let mgr = manager_with_runtime(crate::shell_path::LoginShellEnv::default(), inert_runtime()); let err = mgr.inject_stdin("nope", b"x").unwrap_err(); assert!(format!("{err}").contains("session not found")); } @@ -1226,6 +1249,8 @@ fn direct_chat_persona_lands_as_trailing_positional_argv_without_worker_preamble &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -1439,6 +1464,22 @@ fn codex_mission_spawn_grants_event_log_dir_to_sandbox() { mgr.kill(&spawned.id).unwrap(); } +#[test] +fn trae_first_turn_gets_capture_prompt_marker() { + let (first_turn, marker) = SessionManager::codex_capture_prompt_marker( + "trae", + "session-id", + Some("first turn".to_string()), + ); + let marker = marker.expect("trae must use the codex-lineage capture marker"); + assert_eq!( + marker, + crate::session::codex_capture::prompt_marker("session-id") + ); + let expected = format!("first turn\n\n{marker}"); + assert_eq!(first_turn.as_deref(), Some(expected.as_str())); +} + #[test] fn mission_registration_preserves_initial_terminal_size() { let pool = pool_with_schema(); @@ -1682,7 +1723,7 @@ fn spawn_failure_after_spawn_command_reaps_the_child() { .execute("DROP TABLE sessions", []) .unwrap(); - let mgr = SessionManager::new(crate::shell_path::LoginShellEnv::default(), inert_runtime()); + let mgr = manager_with_runtime(crate::shell_path::LoginShellEnv::default(), inert_runtime()); let slot = slot_for(&runner); let err = mgr .spawn( @@ -1881,6 +1922,8 @@ fn spawn_direct_writes_session_with_null_mission_id_and_emits_activity() { .spawn_direct( &runner, None, + None, + None, Some(&project.id), Some(&project.cwd), None, @@ -2036,6 +2079,8 @@ fn direct_chat_status_transition_emits_session_status_busy() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2099,6 +2144,8 @@ fn direct_chat_status_transition_emits_session_status_idle() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2159,6 +2206,8 @@ fn direct_chat_typing_stays_idle_until_submit() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2515,7 +2564,7 @@ fn login_shell_proxy_env_reaches_spawn_with_runner_env_taking_precedence() { vars.insert("HTTPS_PROXY".into(), "http://login-shell:7890".into()); vars.insert("https_proxy".into(), "http://login-shell:7890".into()); vars.insert("NO_PROXY".into(), "localhost,127.0.0.1,*.byted.org".into()); - let mgr = SessionManager::new( + let mgr = manager_with_runtime( crate::shell_path::LoginShellEnv { path: None, vars }, Arc::clone(&fake) as Arc, ); @@ -2523,6 +2572,8 @@ fn login_shell_proxy_env_reaches_spawn_with_runner_env_taking_precedence() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2581,6 +2632,8 @@ fn output_snapshot_replays_live_session_and_clears_after_forget() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2666,6 +2719,8 @@ fn resume_reuses_row_and_preserves_agent_session_key() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2817,9 +2872,8 @@ fn wait_for_db_stop(pool: &DbPool, session_id: &str) { } } -#[test] -fn resume_keeps_scrollback_for_claude_code() { - // Impl 0024: resuming a claude-code session must NOT drop the +fn assert_resume_keeps_scrollback(runtime: &str) { + // Impl 0024: resuming an inline-repaint runtime must NOT drop the // output ring — a terminal emulator keeps pre-resume scrollback // above the resume repaint, and the ring has to agree with the // mounted xterm so a later remount replay doesn't lose what the @@ -2835,16 +2889,16 @@ fn resume_keeps_scrollback_for_claude_code() { (id, handle, display_name, runtime, command, args_json, working_dir, system_prompt, env_json, created_at, updated_at) - VALUES (?1, 'keeper', 'K', 'claude-code', '/bin/sh', - NULL, NULL, NULL, NULL, ?2, ?2)", - params![runner_id, now], + VALUES (?1, 'keeper', 'K', ?2, '/bin/sh', + NULL, NULL, NULL, NULL, ?3, ?3)", + params![runner_id, runtime, now], ) .unwrap(); } let mut runner = runner("/bin/sh", &[]); runner.id = runner_id; runner.handle = "keeper".into(); - runner.runtime = "claude-code".into(); + runner.runtime = runtime.into(); let fake = fake_runtime(); let mgr = mgr_with_fake(None, Arc::clone(&fake)); @@ -2854,6 +2908,8 @@ fn resume_keeps_scrollback_for_claude_code() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -2905,7 +2961,7 @@ fn resume_keeps_scrollback_for_claude_code() { let after_resume = mgr.output_snapshot(&session_id); assert!( after_resume.iter().any(|ev| ev.data == kept), - "claude-code resume must keep pre-resume chunks in the ring" + "{runtime} resume must keep pre-resume chunks in the ring" ); assert_ne!( after_resume.first().map(|ev| ev.seq), @@ -2959,8 +3015,17 @@ fn resume_keeps_scrollback_for_claude_code() { } #[test] -fn resume_purges_scrollback_for_codex() { - // Codex keeps the pre-0024 behavior: its full-frame repaint over +fn resume_keeps_scrollback_for_claude_code() { + assert_resume_keeps_scrollback("claude-code"); +} + +#[test] +fn resume_keeps_scrollback_for_qoder() { + assert_resume_keeps_scrollback("qoder"); +} + +fn assert_resume_purges_scrollback(runtime: &str) { + // Codex-lineage runtimes keep the pre-0024 behavior: their full-frame repaint over // retained scrollback is the stacking artifact the purge was // added for, so resume still drops the ring — while the watermark // is stamped uniformly (equal to the post-purge floor here). @@ -2974,16 +3039,16 @@ fn resume_purges_scrollback_for_codex() { (id, handle, display_name, runtime, command, args_json, working_dir, system_prompt, env_json, created_at, updated_at) - VALUES (?1, 'purger', 'P', 'codex', '/bin/sh', - NULL, NULL, NULL, NULL, ?2, ?2)", - params![runner_id, now], + VALUES (?1, 'purger', 'P', ?2, '/bin/sh', + NULL, NULL, NULL, NULL, ?3, ?3)", + params![runner_id, runtime, now], ) .unwrap(); } let mut runner = runner("/bin/sh", &[]); runner.id = runner_id; runner.handle = "purger".into(); - runner.runtime = "codex".into(); + runner.runtime = runtime.into(); let fake = fake_runtime(); let mgr = mgr_with_fake(None, Arc::clone(&fake)); @@ -2993,6 +3058,8 @@ fn resume_purges_scrollback_for_codex() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -3024,7 +3091,7 @@ fn resume_purges_scrollback_for_codex() { assert_eq!( reset_snapshot.len(), 1, - "codex resume must replace prior output with one reset chunk" + "{runtime} resume must replace prior output with one reset chunk" ); assert_eq!( BASE64.decode(&reset_snapshot[0].data).unwrap(), @@ -3053,6 +3120,16 @@ fn resume_purges_scrollback_for_codex() { mgr.kill(&session_id).unwrap(); } +#[test] +fn resume_purges_scrollback_for_codex() { + assert_resume_purges_scrollback("codex"); +} + +#[test] +fn resume_purges_scrollback_for_trae() { + assert_resume_purges_scrollback("trae"); +} + #[test] fn first_spawn_without_dims_uses_and_persists_default_size() { let pool = pool_with_schema(); @@ -3080,6 +3157,8 @@ fn first_spawn_without_dims_uses_and_persists_default_size() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -3135,6 +3214,8 @@ fn resume_size_resolution_prefers_explicit_then_persisted_after_manager_restart( &runner, None, None, + None, + None, Some("/tmp"), Some(120), Some(30), @@ -3212,7 +3293,7 @@ fn resume_size_resolution_prefers_explicit_then_persisted_after_manager_restart( #[test] fn resize_rejects_unknown_session_without_creating_state() { let pool = pool_with_schema(); - let mgr = SessionManager::new(crate::shell_path::LoginShellEnv::default(), inert_runtime()); + let mgr = manager_with_runtime(crate::shell_path::LoginShellEnv::default(), inert_runtime()); let err = mgr .resize("missing-session", 120, 30, &pool, capture()) @@ -3260,7 +3341,7 @@ fn resume_refuses_running_and_archived_rows() { ) .unwrap(); } - let mgr = SessionManager::new(crate::shell_path::LoginShellEnv::default(), inert_runtime()); + let mgr = manager_with_runtime(crate::shell_path::LoginShellEnv::default(), inert_runtime()); for (sid, needle) in [ ("running-sid", "already running"), ("archived-sid", "archived"), @@ -3613,7 +3694,7 @@ fn scan_mouse_modes_detects_enable_disable_and_full_reset() { fn output_snapshot_prepends_alt_screen_enter_when_session_in_alt_screen() { let pool = pool_with_schema(); let fake = fake_runtime(); - let mgr = SessionManager::new( + let mgr = manager_with_runtime( crate::shell_path::LoginShellEnv::default(), Arc::clone(&fake) as Arc, ); @@ -3637,6 +3718,8 @@ fn output_snapshot_prepends_alt_screen_enter_when_session_in_alt_screen() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -3680,7 +3763,7 @@ fn output_snapshot_prepends_alt_screen_enter_when_session_in_alt_screen() { fn output_snapshot_prepends_bracketed_paste_enable_when_session_has_it_enabled() { let pool = pool_with_schema(); let fake = fake_runtime(); - let mgr = SessionManager::new( + let mgr = manager_with_runtime( crate::shell_path::LoginShellEnv::default(), Arc::clone(&fake) as Arc, ); @@ -3704,6 +3787,8 @@ fn output_snapshot_prepends_bracketed_paste_enable_when_session_has_it_enabled() &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -3740,7 +3825,7 @@ fn output_snapshot_prepends_bracketed_paste_enable_when_session_has_it_enabled() fn output_snapshot_combines_alt_screen_and_bracketed_paste_prefixes() { let pool = pool_with_schema(); let fake = fake_runtime(); - let mgr = SessionManager::new( + let mgr = manager_with_runtime( crate::shell_path::LoginShellEnv::default(), Arc::clone(&fake) as Arc, ); @@ -3764,6 +3849,8 @@ fn output_snapshot_combines_alt_screen_and_bracketed_paste_prefixes() { &runner, None, None, + None, + None, Some("/tmp"), None, None, @@ -3895,7 +3982,12 @@ fn runtime_clears_on_resize_resolves_runner_backed_runtimes() { let pool = db::open_in_memory().unwrap(); let now = chrono::Utc::now().to_rfc3339(); let conn = pool.get().unwrap(); - for (runner_id, runtime) in [("r-codex", "codex"), ("r-shell", "shell")] { + for (runner_id, runtime) in [ + ("r-codex", "codex"), + ("r-qoder", "qoder"), + ("r-trae", "trae"), + ("r-shell", "shell"), + ] { conn.execute( "INSERT INTO runners (id, handle, display_name, runtime, command, @@ -3914,6 +4006,18 @@ fn runtime_clears_on_resize_resolves_runner_backed_runtimes() { params![now], ) .unwrap(); + conn.execute( + "INSERT INTO sessions (id, mission_id, runner_id, cwd, status, started_at) + VALUES ('s-qoder-runner', NULL, 'r-qoder', '/tmp', 'running', ?1)", + params![now], + ) + .unwrap(); + conn.execute( + "INSERT INTO sessions (id, mission_id, runner_id, cwd, status, started_at) + VALUES ('s-trae-runner', NULL, 'r-trae', '/tmp', 'running', ?1)", + params![now], + ) + .unwrap(); conn.execute( "INSERT INTO sessions (id, mission_id, runner_id, cwd, status, started_at) VALUES ('s-shell-runner', NULL, 'r-shell', '/tmp', 'running', ?1)", @@ -3938,6 +4042,22 @@ fn runtime_clears_on_resize_resolves_runner_backed_runtimes() { "s-claude-runtime", &pool )); + assert!(super::output::runtime_clears_on_resize( + "s-qoder-runner", + &pool + )); + assert!(super::output::runtime_clears_on_resize( + "s-trae-runner", + &pool + )); + assert!(super::output::runtime_purges_on_resume( + "s-trae-runner", + &pool + )); + assert!(!super::output::runtime_purges_on_resume( + "s-qoder-runner", + &pool + )); assert!(!super::output::runtime_clears_on_resize( "s-shell-runner", &pool @@ -3976,6 +4096,8 @@ fn resize_purges_ring_only_on_cols_change() { &runner, None, None, + None, + None, Some("/tmp"), Some(120), Some(30), @@ -4070,6 +4192,8 @@ fn spawn_claude_for_resize( &runner, None, None, + None, + None, Some("/tmp"), Some(120), Some(30), @@ -4266,6 +4390,193 @@ fn resize_settle_thread_applies_without_manual_nudge() { // Feature 41 — runtime override (per slot / per direct chat) // --------------------------------------------------------------------- +#[test] +fn runtime_direct_runner_applies_model_and_effort() { + let configured = + runtime_direct_runner("codex", None, Some(" gpt-5.6-sol "), Some(" max ")).unwrap(); + assert_eq!(configured.model.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(configured.effort.as_deref(), Some("max")); + + let defaults = runtime_direct_runner("codex", None, Some(" "), Some("")).unwrap(); + assert_eq!(defaults.model, None); + assert_eq!(defaults.effort, None); +} + +#[test] +fn runtime_direct_spawn_persists_model_and_effort() { + let pool = pool_with_schema(); + let configured = + runtime_direct_runner("codex", Some("/bin/sh"), Some("gpt-5.6-sol"), Some("max")).unwrap(); + let mgr = mgr_with_fake(None, fake_runtime()); + let spawned = mgr + .spawn_runtime_direct( + &configured, + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + ) + .unwrap(); + + let stored: ( + Option, + Option, + Option, + Option, + ) = pool + .get() + .unwrap() + .query_row( + "SELECT agent_runtime, agent_command, agent_model, agent_effort + FROM sessions WHERE id = ?1", + params![spawned.id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(stored.0.as_deref(), Some("codex")); + assert_eq!(stored.1.as_deref(), Some("/bin/sh")); + assert_eq!(stored.2.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(stored.3.as_deref(), Some("max")); + + mgr.kill(&spawned.id).unwrap(); +} + +#[test] +fn pinned_direct_spawn_records_override_model_and_effort() { + let pool = pool_with_schema(); + let now = Utc::now().to_rfc3339(); + let runner_id = ulid::Ulid::new().to_string(); + { + let conn = pool.get().unwrap(); + conn.execute( + "INSERT INTO runners + (id, handle, display_name, runtime, command, + args_json, working_dir, system_prompt, env_json, + created_at, updated_at) + VALUES (?1, 'pin-me', 'Pin', 'codex', '/bin/sh', + '[]', NULL, NULL, NULL, ?2, ?2)", + params![runner_id, now], + ) + .unwrap(); + } + let mut r = runner("/bin/sh", &[]); + r.id = runner_id; + r.runtime = "codex".into(); + r.model = Some("runner-model".into()); + r.effort = Some("runner-effort".into()); + let mgr = mgr_with_fake(None, fake_runtime()); + let spawned = mgr + .spawn_direct( + &r, + Some("codex"), + Some("gpt-5.6-sol"), + Some("ultra"), + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + + let stored: (Option, Option, Option) = pool + .get() + .unwrap() + .query_row( + "SELECT agent_runtime, agent_model, agent_effort + FROM sessions WHERE id = ?1", + params![spawned.id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(stored.0.as_deref(), Some("codex")); + assert_eq!(stored.1.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(stored.2.as_deref(), Some("ultra")); + + mgr.kill(&spawned.id).unwrap(); +} + +#[test] +fn unpinned_direct_spawn_persists_options_without_pinning_runtime() { + let pool = pool_with_schema(); + let now = Utc::now().to_rfc3339(); + let runner_id = ulid::Ulid::new().to_string(); + { + let conn = pool.get().unwrap(); + conn.execute( + "INSERT INTO runners + (id, handle, display_name, runtime, command, + args_json, created_at, updated_at) + VALUES (?1, 'options-only', 'Options', 'codex', '/bin/sh', + '[]', ?2, ?2)", + params![runner_id, now], + ) + .unwrap(); + } + let mut r = runner("/bin/sh", &[]); + r.id = runner_id; + r.runtime = "codex".into(); + let fake = fake_runtime(); + let mgr = mgr_with_fake(None, Arc::clone(&fake)); + let spawned = mgr + .spawn_direct( + &r, + None, + Some("gpt-5.6-sol"), + Some("ultra"), + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + + let stored: (Option, Option, Option) = pool + .get() + .unwrap() + .query_row( + "SELECT agent_runtime, agent_model, agent_effort + FROM sessions WHERE id = ?1", + params![spawned.id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(stored.0, None, "options-only direct chats must not pin"); + assert_eq!(stored.1.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(stored.2.as_deref(), Some("ultra")); + + mgr.kill(&spawned.id).unwrap(); + mgr.resume( + &spawned.id, + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + ) + .unwrap(); + let resumed = fake.last_spawn_spec().expect("resume should spawn"); + assert!(resumed + .args + .windows(2) + .any(|w| w[0] == "--model" && w[1] == "gpt-5.6-sol")); + assert!(resumed + .args + .windows(2) + .any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=ultra")); + mgr.kill(&spawned.id).unwrap(); +} + #[test] fn runtime_override_helper_distinguishes_absent_matching_and_differing() { let mut r = runner("codex-custom", &["--custom"]); @@ -4273,7 +4584,7 @@ fn runtime_override_helper_distinguishes_absent_matching_and_differing() { // Absent / blank: no rebuild, no pin. for value in [None, Some(" ")] { - let res = resolve_runtime_override(&r, value).unwrap(); + let res = resolve_runtime_override(&r, value, None, None).unwrap(); assert!(res.effective.is_none()); assert!(!res.pinned, "absent/blank override must not pin"); } @@ -4281,14 +4592,19 @@ fn runtime_override_helper_distinguishes_absent_matching_and_differing() { // Matching: no rebuild (spawn stays byte-identical), but pinned — // the session row must record the engine so a later runner- // template edit can't re-engine its resume. - let matching = resolve_runtime_override(&r, Some("codex")).unwrap(); + let matching = resolve_runtime_override(&r, Some("codex"), None, None).unwrap(); assert!(matching.effective.is_none()); assert!(matching.pinned, "explicit matching override must pin"); - // Differing: rebuild + pin. - let differing = resolve_runtime_override(&r, Some("claude-code")).unwrap(); - assert!(differing.effective.is_some()); - assert!(differing.pinned); + // Differing: rebuild + pin for every other catalog runtime. + for runtime in ["claude-code", "qoder", "trae"] { + let differing = resolve_runtime_override(&r, Some(runtime), None, None).unwrap(); + assert_eq!( + differing.effective.as_ref().map(|r| r.runtime.as_str()), + Some(runtime), + ); + assert!(differing.pinned); + } } #[test] @@ -4301,7 +4617,7 @@ fn runtime_override_helper_resets_engine_fields_and_keeps_persona() { r.working_dir = Some("/work".into()); r.env.insert("FOO".into(), "bar".into()); - let effective = resolve_runtime_override(&r, Some("claude-code")) + let effective = resolve_runtime_override(&r, Some("claude-code"), None, None) .unwrap() .effective .expect("differing runtime must produce an effective runner"); @@ -4328,10 +4644,84 @@ fn runtime_override_helper_resets_engine_fields_and_keeps_persona() { assert_eq!(effective.handle, r.handle); } +#[test] +fn runtime_override_helper_applies_slot_model_to_selected_runtime() { + let mut r = runner("codex-custom", &["--custom"]); + r.runtime = "codex".into(); + r.model = Some("runner-model".into()); + + let differing = resolve_runtime_override(&r, Some("trae"), Some("trae-slot-model"), None) + .unwrap() + .effective + .expect("differing runtime must produce an effective runner"); + assert_eq!(differing.runtime, "trae"); + assert_eq!(differing.model.as_deref(), Some("trae-slot-model")); + + let matching = resolve_runtime_override(&r, Some("codex"), Some("codex-slot-model"), None) + .unwrap() + .effective + .expect("a model override must rebuild even for a matching runtime"); + assert_eq!(matching.runtime, "codex"); + assert_eq!(matching.model.as_deref(), Some("codex-slot-model")); + assert_eq!(matching.args, r.args); + + let unpinned = resolve_runtime_override(&r, None, Some("codex-slot-model"), None).unwrap(); + let effective = unpinned + .effective + .expect("a model-only override must rebuild the runner config"); + assert_eq!(effective.runtime, "codex"); + assert_eq!(effective.model.as_deref(), Some("codex-slot-model")); + assert_eq!(effective.effort, r.effort); + assert!(!unpinned.pinned, "model-only overrides must not pin"); +} + +#[test] +fn runtime_override_helper_applies_effort_to_selected_runtime() { + let mut r = runner("codex-custom", &["--custom"]); + r.runtime = "codex".into(); + r.model = Some("runner-model".into()); + r.effort = Some("runner-effort".into()); + + let differing = resolve_runtime_override(&r, Some("claude-code"), Some("fable"), Some("max")) + .unwrap() + .effective + .expect("differing runtime must produce an effective runner"); + assert_eq!(differing.runtime, "claude-code"); + assert_eq!(differing.model.as_deref(), Some("fable")); + assert_eq!(differing.effort.as_deref(), Some("max")); + + let cleared = resolve_runtime_override(&r, Some("claude-code"), None, None) + .unwrap() + .effective + .expect("differing runtime must produce an effective runner"); + assert_eq!(cleared.model, None); + assert_eq!(cleared.effort, None); + + let matching = resolve_runtime_override(&r, Some("codex"), None, Some("xhigh")) + .unwrap() + .effective + .expect("an effort override must rebuild even for a matching runtime"); + assert_eq!(matching.model.as_deref(), Some("runner-model")); + assert_eq!(matching.effort.as_deref(), Some("xhigh")); + + let unpinned = resolve_runtime_override(&r, None, None, Some("high")).unwrap(); + let effective = unpinned + .effective + .expect("an effort-only override must rebuild the runner config"); + assert_eq!(effective.runtime, "codex"); + assert_eq!(effective.model.as_deref(), Some("runner-model")); + assert_eq!(effective.effort.as_deref(), Some("high")); + assert!(!unpinned.pinned, "effort-only overrides must not pin"); + + let blank = resolve_runtime_override(&r, Some("codex"), Some(" "), Some("")).unwrap(); + assert!(blank.effective.is_none()); + assert!(blank.pinned); +} + #[test] fn runtime_override_helper_rejects_unknown_runtime() { let r = runner("/bin/sh", &[]); - let err = resolve_runtime_override(&r, Some("aider-future")).unwrap_err(); + let err = resolve_runtime_override(&r, Some("aider-future"), None, None).unwrap_err(); assert!(err.to_string().contains("unknown runtime"), "got: {err}",); } @@ -4343,7 +4733,8 @@ fn mission_spawn_with_slot_override_uses_registry_engine_and_records_runtime() { let slot_id = insert_crew_runner(&pool, &mission_row.id, &runner_id); // Runner row is a codex engine with custom flags + pinned - // model/effort; the slot overrides to claude-code. + // model/effort; the slot overrides to claude-code and selects + // its own model and effort. let mut runner = runner("codex-custom", &["--custom-flag"]); runner.id = runner_id.clone(); runner.runtime = "codex".into(); @@ -4353,6 +4744,8 @@ fn mission_spawn_with_slot_override_uses_registry_engine_and_records_runtime() { let mut slot = slot_for(&runner); slot.id = slot_id; slot.runtime_override = Some("claude-code".into()); + slot.model_override = Some("opus".into()); + slot.effort_override = Some("max".into()); let fake = fake_runtime(); let mgr = mgr_with_fake(None, Arc::clone(&fake)); @@ -4370,20 +4763,20 @@ fn mission_spawn_with_slot_override_uses_registry_engine_and_records_runtime() { .unwrap(); let spec = fake.last_spawn_spec().expect("spawn was called"); - assert_eq!( - spec.command, "claude", - "override must use the registry command" - ); + assert_effective_command(&spec.command, "claude"); assert!( !spec.args.contains(&"--custom-flag".to_string()), "runner args are engine flags and must not carry across runtimes: {:?}", spec.args, ); - assert!( - !spec.args.contains(&"--model".to_string()), - "pinned model must be dropped on override: {:?}", - spec.args, - ); + assert!(spec + .args + .windows(2) + .any(|w| w[0] == "--model" && w[1] == "opus")); + assert!(spec + .args + .windows(2) + .any(|w| w[0] == "--effort" && w[1] == "max")); assert!( spec.args .windows(2) @@ -4403,19 +4796,122 @@ fn mission_spawn_with_slot_override_uses_registry_engine_and_records_runtime() { ); // Session row records the effective runtime for respawn/resume. - let (agent_runtime, agent_command): (Option, Option) = pool + let (agent_runtime, agent_command, agent_model, agent_effort): ( + Option, + Option, + Option, + Option, + ) = pool .get() .unwrap() .query_row( - "SELECT agent_runtime, agent_command FROM sessions WHERE id = ?1", + "SELECT agent_runtime, agent_command, agent_model, agent_effort + FROM sessions WHERE id = ?1", params![spawned.id], - |r| Ok((r.get(0)?, r.get(1)?)), + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .unwrap(); assert_eq!(agent_runtime.as_deref(), Some("claude-code")); - assert_eq!(agent_command.as_deref(), Some("claude")); + assert_effective_command(agent_command.as_deref().unwrap(), "claude"); + assert_eq!(agent_model.as_deref(), Some("opus")); + assert_eq!(agent_effort.as_deref(), Some("max")); + + mgr.kill(&spawned.id).unwrap(); +} + +#[test] +fn mission_spawn_with_model_only_slot_override_uses_runner_runtime_without_pinning() { + let pool = pool_with_schema(); + let mission_row = mission(); + let runner_id = ulid::Ulid::new().to_string(); + let slot_id = insert_crew_runner(&pool, &mission_row.id, &runner_id); + + let mut runner = runner("codex-custom", &["--custom-flag"]); + runner.id = runner_id; + runner.runtime = "codex".into(); + runner.model = Some("runner-model".into()); + runner.effort = Some("high".into()); + pool.get() + .unwrap() + .execute( + "UPDATE runners + SET runtime = 'codex', command = 'codex-custom', + args_json = '[\"--custom-flag\"]', + model = 'runner-model', effort = 'high' + WHERE id = ?1", + params![runner.id], + ) + .unwrap(); + let mut slot = slot_for(&runner); + slot.id = slot_id; + slot.model_override = Some("slot-model".into()); + + let fake = fake_runtime(); + let mgr = mgr_with_fake(None, Arc::clone(&fake)); + let spawned = mgr + .spawn( + &mission_row, + &runner, + &slot, + std::path::Path::new("/tmp"), + PathBuf::from("/dev/null"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + + let spec = fake.last_spawn_spec().expect("spawn was called"); + assert_eq!(spec.command, "codex-custom"); + assert!(spec.args.contains(&"--custom-flag".to_string())); + assert!(spec + .args + .windows(2) + .any(|w| w[0] == "--model" && w[1] == "slot-model")); + assert!(spec + .args + .windows(2) + .any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=high")); + + let (agent_runtime, agent_model, agent_effort): ( + Option, + Option, + Option, + ) = pool + .get() + .unwrap() + .query_row( + "SELECT agent_runtime, agent_model, agent_effort + FROM sessions WHERE id = ?1", + params![spawned.id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(agent_runtime, None, "model-only overrides must not pin"); + assert_eq!(agent_model.as_deref(), Some("slot-model")); + assert_eq!(agent_effort.as_deref(), Some("high")); mgr.kill(&spawned.id).unwrap(); + mgr.resume( + &spawned.id, + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + ) + .unwrap(); + let resumed = fake.last_spawn_spec().expect("resume should spawn"); + assert_eq!(resumed.command, "codex-custom"); + assert!(resumed + .args + .windows(2) + .any(|w| w[0] == "--model" && w[1] == "slot-model")); + assert!(resumed + .args + .windows(2) + .any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=high")); + mgr.kill(&spawned.id).unwrap(); } #[test] @@ -4558,10 +5054,7 @@ fn resume_keeps_pinned_runtime_after_runner_template_edit() { .unwrap(); let spec = fake.last_spawn_spec().expect("resume should have spawned"); - assert_eq!( - spec.command, "codex", - "resume must stay on the pinned engine (registry command), not the edited template's", - ); + assert_effective_command(&spec.command, "codex"); assert!( !spec.args.contains(&"--custom-flag".to_string()) && spec.command != "claude-custom", "neither the template's new engine nor its old flags may leak in: {:?}", @@ -4595,6 +5088,8 @@ fn direct_spawn_with_override_uses_registry_engine_and_records_runtime() { &runner, Some("claude-code"), None, + None, + None, Some("/tmp"), None, None, @@ -4606,7 +5101,7 @@ fn direct_spawn_with_override_uses_registry_engine_and_records_runtime() { .unwrap(); let spec = fake.last_spawn_spec().expect("spawn was called"); - assert_eq!(spec.command, "claude"); + assert_effective_command(&spec.command, "claude"); assert!(!spec.args.contains(&"--custom-flag".to_string())); let (row_runner_id, agent_runtime, agent_command): ( @@ -4628,7 +5123,7 @@ fn direct_spawn_with_override_uses_registry_engine_and_records_runtime() { "overridden chats stay runner-backed", ); assert_eq!(agent_runtime.as_deref(), Some("claude-code")); - assert_eq!(agent_command.as_deref(), Some("claude")); + assert_effective_command(agent_command.as_deref().unwrap(), "claude"); mgr.kill(&spawned.id).unwrap(); } @@ -4677,10 +5172,7 @@ fn resume_respawns_recorded_override_runtime() { .unwrap(); let spec = fake.last_spawn_spec().expect("resume should have spawned"); - assert_eq!( - spec.command, "claude", - "resume must respawn the recorded effective runtime", - ); + assert_effective_command(&spec.command, "claude"); assert!( !spec.args.contains(&"--custom-flag".to_string()), "runner engine flags must not leak into an overridden resume: {:?}", @@ -4696,3 +5188,196 @@ fn resume_respawns_recorded_override_runtime() { mgr.kill("ovr-sid").unwrap(); } + +#[test] +fn catalog_default_runner_uses_detected_command_while_custom_command_stays_untouched() { + use std::os::unix::fs::PermissionsExt; + + let pool = pool_with_schema(); + let bin = tempfile::tempdir().unwrap(); + let detected = bin.path().join("codex"); + std::fs::write(&detected, "#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(&detected).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&detected, permissions).unwrap(); + + let now = Utc::now().to_rfc3339(); + let default_id = ulid::Ulid::new().to_string(); + let custom_id = ulid::Ulid::new().to_string(); + { + let conn = pool.get().unwrap(); + for (id, handle, command) in [ + (&default_id, "default-runtime", "codex"), + (&custom_id, "custom-runtime", "codex-wrapper"), + ] { + conn.execute( + "INSERT INTO runners + (id, handle, display_name, runtime, command, created_at, updated_at) + VALUES (?1, ?2, ?2, 'codex', ?3, ?4, ?4)", + params![id, handle, command, now], + ) + .unwrap(); + } + } + + let fake = fake_runtime(); + let mgr = mgr_with_fake(Some(bin.path().display().to_string()), Arc::clone(&fake)); + let mut default_runner = runner("codex", &[]); + default_runner.id = default_id; + default_runner.handle = "default-runtime".into(); + default_runner.runtime = "codex".into(); + let default_session = mgr + .spawn_direct( + &default_runner, + None, + None, + None, + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + assert_eq!( + fake.last_spawn_spec().unwrap().command, + detected.display().to_string() + ); + + let mut custom_runner = runner("codex-wrapper", &[]); + custom_runner.id = custom_id; + custom_runner.handle = "custom-runtime".into(); + custom_runner.runtime = "codex".into(); + let custom_session = mgr + .spawn_direct( + &custom_runner, + None, + None, + None, + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + assert_eq!(fake.last_spawn_spec().unwrap().command, "codex-wrapper"); + + mgr.shell_env.write().unwrap().path = Some("/swapped/bin".into()); + let swapped_session = mgr + .spawn_direct( + &custom_runner, + None, + None, + None, + None, + Some("/tmp"), + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + None, + ) + .unwrap(); + assert_eq!( + fake.last_spawn_spec().unwrap().shell_path.as_deref(), + Some("/swapped/bin") + ); + + mgr.kill(&default_session.id).unwrap(); + mgr.kill(&custom_session.id).unwrap(); + mgr.kill(&swapped_session.id).unwrap(); +} + +#[test] +fn runtime_only_resume_keeps_live_recorded_path_and_reresolves_dead_path() { + use std::os::unix::fs::PermissionsExt; + + let pool = pool_with_schema(); + let recorded_dir = tempfile::tempdir().unwrap(); + let detected_dir = tempfile::tempdir().unwrap(); + let make_executable = |path: &Path| { + std::fs::write(path, "#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).unwrap(); + }; + let recorded = recorded_dir.path().join("codex-recorded"); + let detected = detected_dir.path().join("codex"); + make_executable(&recorded); + make_executable(&detected); + + let now = Utc::now().to_rfc3339(); + { + let conn = pool.get().unwrap(); + for (id, command) in [ + ("runtime-live-path", recorded.display().to_string()), + ("runtime-dead-path", "/definitely/missing/codex".to_string()), + ] { + conn.execute( + "INSERT INTO sessions + (id, status, started_at, agent_runtime, agent_command, + agent_model, agent_effort) + VALUES (?1, 'stopped', ?2, 'codex', ?3, + 'gpt-5.6-sol', 'max')", + params![id, now, command], + ) + .unwrap(); + } + } + + let fake = fake_runtime(); + let mgr = mgr_with_fake( + Some(detected_dir.path().display().to_string()), + Arc::clone(&fake), + ); + mgr.resume( + "runtime-live-path", + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + ) + .unwrap(); + assert_eq!( + fake.last_spawn_spec().unwrap().command, + recorded.display().to_string() + ); + assert!(fake + .last_spawn_spec() + .unwrap() + .args + .windows(2) + .any(|args| args[0] == "--model" && args[1] == "gpt-5.6-sol")); + assert!(fake + .last_spawn_spec() + .unwrap() + .args + .windows(2) + .any(|args| args[0] == "-c" && args[1] == "model_reasoning_effort=max")); + + mgr.resume( + "runtime-dead-path", + None, + None, + std::path::Path::new("/tmp"), + Arc::clone(&pool), + capture(), + ) + .unwrap(); + assert_eq!( + fake.last_spawn_spec().unwrap().command, + detected.display().to_string() + ); + + mgr.kill("runtime-live-path").unwrap(); + mgr.kill("runtime-dead-path").unwrap(); +} diff --git a/crates/runner-backend/src/session/mod.rs b/crates/runner-backend/src/session/mod.rs index 8952fc6e..08377453 100644 --- a/crates/runner-backend/src/session/mod.rs +++ b/crates/runner-backend/src/session/mod.rs @@ -9,6 +9,7 @@ // for the rationale. pub mod codex_capture; +pub(crate) mod codex_trust; pub mod launch; pub mod manager; #[cfg(unix)] diff --git a/crates/runner-backend/src/session/pty_runtime.rs b/crates/runner-backend/src/session/pty_runtime.rs index 532719b8..4a7e153e 100644 --- a/crates/runner-backend/src/session/pty_runtime.rs +++ b/crates/runner-backend/src/session/pty_runtime.rs @@ -1512,6 +1512,16 @@ mod tests { Some("codex"), Some("/custom/bin/codex"), )); + assert!(command_line_matches_recorded_agent( + "/Applications/Agent Tools/claude --resume abc", + Some("claude-code"), + Some("/Applications/Agent Tools/claude"), + )); + assert!(command_line_matches_recorded_agent( + "/opt/homebrew/bin/claude --resume abc", + Some("claude-code"), + Some("/opt/homebrew/bin/claude"), + )); assert!(!command_line_matches_recorded_agent( "python worker.py", Some("claude-code"), diff --git a/crates/runner-terminal/src/terminal.rs b/crates/runner-terminal/src/terminal.rs index d02ab30d..b8302ce4 100644 --- a/crates/runner-terminal/src/terminal.rs +++ b/crates/runner-terminal/src/terminal.rs @@ -408,13 +408,22 @@ mod tests { Arc::new(runner_backend::session::pty_runtime::PtyRuntime::new()); let windows = Arc::new(runner_backend::windows::WindowRegistry::new()); windows.register("main"); + let runtime_shell_env = Arc::new(std::sync::RwLock::new( + runner_backend::shell_path::LoginShellEnv::default(), + )); + let runtime_discovery = Arc::new(std::sync::RwLock::new( + runner_backend::shell_path::DiscoveryState::startup(None, None), + )); AppCore { db: pool, app_data_dir, sessions: runner_backend::session::SessionManager::new( - runner_backend::shell_path::LoginShellEnv::default(), + Arc::clone(&runtime_shell_env), + Arc::clone(&runtime_discovery), runtime, ), + runtime_shell_env, + runtime_discovery, buses: runner_backend::event_bus::BusRegistry::new(), routers: runner_backend::router::RouterRegistry::new(), mcp: Arc::new(runner_backend::mcp::McpHandle::new()), diff --git a/docs/impls/gpui-rewrite/impl_log.md b/docs/impls/gpui-rewrite/impl_log.md index f60dbdd3..31194186 100644 --- a/docs/impls/gpui-rewrite/impl_log.md +++ b/docs/impls/gpui-rewrite/impl_log.md @@ -4,9 +4,9 @@ Progress record for the whole gpui-rewrite program ([README](README.md)), from t ## Current state (update with each entry) -- **Branch**: `gpui-nightly` (M0+M1 merged via PR #407, M2 via PR #408, both 2026-08-17; M3.1 via PR #409, M3.2 via PR #410, M3.3 via PR #411, M3.4 via the `feat/0046-m3-input-latch` PR, all 2026-08-18). Session-hardening slice complete. -- **Done**: 0046 M0 (gpui-ce swap), M1 (repo-and-below at `main` parity, node tree adopted, protocol crates wholesale; human-verified 2026-08-17), M2 (terminal split + shell modularization), M3.1–M3.4 (session-hardening slice: reaping, resume seams + geometry, resize storms + owner gating, input latch + quit stamping), terminal-pane IME (M4 pull-forward; Pinyin human-verified 2026-08-18), direct-chat composer removal (parity restore, smoke-tested 2026-08-18). -- **Next**: M3.5 (runtimes slice: discovery, Qoder/TRAE, model catalog, model/effort overrides, codex trust preseed) → start-chat modal + pane controls (M4 pull-forward: `main`'s `StartChatModal` + pane close button replacing the sidebar-swap flow; needs M3.5's backend) → remaining M3 slices → M4 (UI parity) → nightly channel (plan decision 10) → M5 (sweep + watermark). Parity references are `main`'s React frontend (`src/`) and the `design/*.pen` files via the pencil MCP. +- **Branch**: `gpui-nightly` (M0+M1 merged via PR #407, M2 via PR #408, both 2026-08-17; M3.1 via PR #409, M3.2 via PR #410, M3.3 via PR #411, M3.4 via PR #414, crate renames via PR #415, docs sync via PR #416, M3.5 via the `feat/0046-m3-runtimes` PR, all 2026-08-18). +- **Done**: 0046 M0 (gpui-ce swap), M1 (repo-and-below at `main` parity, node tree adopted, protocol crates wholesale; human-verified 2026-08-17), M2 (terminal split + shell modularization), M3.1–M3.4 (session-hardening slice), M3.5 (runtimes: discovery, Qoder/TRAE, model catalog, model/effort overrides, codex trust preseed; human test deferred to the modal task), crate renames (decision 7), terminal-pane IME (M4 pull-forward; Pinyin human-verified 2026-08-18), direct-chat composer removal (parity restore, smoke-tested 2026-08-18), single-canvas design merge (`design/runner.pen`, bands + `cmp/SettingsNav`). +- **Next**: start-chat modal + pane controls (M4 pull-forward: `main`'s `StartChatModal` + pane close button replacing the sidebar-swap flow, on M3.5's backend — its smoke test covers M3.5's surface) → remaining M3 slices (node sidebar polish + pinned, router/inbox, mission feed, pagination/misc) → M4 (UI parity) → nightly channel (plan decision 10) → M5 (sweep + watermark). Parity references are `main`'s React frontend (`src/`) and `design/runner.pen` via the pencil MCP. - **Parity watermark**: `origin/main` fully ported for repo-and-below as of `1b7ee92` (v0.5.2 line, 2026-08-17); feature-logic lag is M3 scope. ## 2026-07-18 — Phase 1 kickoff @@ -187,3 +187,10 @@ M3's slices (0046 §Sequencing) run as serial codex-peer missions, one task at a - One mechanical commit right after M3.4, per the revised decision 7: `crates/runner-app` → `crates/runner-backend` (package `runner-backend`, lib `runner_backend`), `crates/runner-native` → `crates/runner-app` (package/bin `runner-app`, pulse's `-app` convention). Imports, workspace members, Makefile `run` target, and normative doc references updated; historical log/plan narrative keeps the old names. - From here the port mapping is `src-tauri/src/*` ↔ `crates/runner-backend/src/*`; **`runner-app` now names the binary** — every future mission goal states this to defuse the name-reuse hazard. - Gates: `make verify` green. + +## 2026-08-18 — M3.5: runtime discovery, agent catalog, overrides, and Codex trust + +- Ported main's executable discovery slice into `runner-backend`: startup is non-blocking and seeded from the persisted login-shell last-known-good environment, refresh/override/status ops emit `runtime/changed`, and every mission, runner-backed direct, runtime-only, and resume spawn resolves catalog commands while preserving custom runner commands and still-live recorded absolute paths. +- Added Qoder and TRAE end to end in the backend runtime registry, permission/model/effort argv adapters, resume and rollout-capture paths, MCP configuration, resize/resume output policies, and selectable availability-aware catalog. Main's model and effort option tables now live in backend ops for the next native Start Chat modal; Codex project trust is pre-seeded best-effort before each Codex spawn. +- Direct chats accept runtime/model/effort overrides, slot model/effort layering matches main, runtime-only and options-only sessions persist enough state for resume, and changing a runner's runtime clears only inheriting slots' stale model/effort values. The React settings/picker components remain deferred because no native settings or Start Chat modal exists yet; M3's next modal task can consume the backend catalog and ops without reconstructing the tables. +- Gates: `make verify` green (workspace check, 496 backend tests plus app/CLI/core/terminal suites, clippy `-D warnings`, fmt-check); the 10-test terminal fixture corpus stays green. The sandboxed run hit the expected Unix-socket `EPERM`; the identical permitted run passed with exit 0. diff --git a/docs/impls/gpui-rewrite/plan.md b/docs/impls/gpui-rewrite/plan.md index 388d8beb..e0ae735b 100644 --- a/docs/impls/gpui-rewrite/plan.md +++ b/docs/impls/gpui-rewrite/plan.md @@ -161,6 +161,10 @@ Each milestone is a task branch off `gpui-nightly`, human-verified before merge. Criteria: 2+ weeks daily-driving the native app exclusively, M3–M5 done, updater proven by shipping at least one native-to-native update, fixture corpus green. Then: `gpui-nightly`'s tree replaces `main` (branch promotion), the native binary becomes `Runner.app`, major version bump. (The crate renames of decision 7 land earlier, after the M3 session-hardening slice.) +### Post-cutover direction: session daemon (recorded 2026-08-18, Jason) + +Live PTY sessions dying with the app is a process-ownership fact in both stacks — the rewrite keeps that behavior identical (parity only; agent-session-key resume remains the continuity story). But the Phase 2 extraction made the fix buildable: `runner-backend` is UI-free, so it can be promoted into a long-lived session daemon (tmux-server model — daemon owns PTYs + seq'd output buffers, the app attaches/detaches over a socket; M3.2/M3.3's replay seams are exactly the attach discipline). The concrete motivator is the nightly/Sparkle loop: every update restarts the app and kills running agents; a daemon makes updates and crashes invisible to live missions. Scope it as the flagship post-cutover project, after M5 — it forks the architecture away from `main`, so it must not happen mid-parity. Known work when designed: ops become RPC, daemon/UI version skew across updates, reaping semantics (M3.1's sweep assumes app-owned processes), MCP moves into the daemon. + ## Verification - Fixture corpus replay green at every milestone (the terminal regression floor).