diff --git a/Cargo.lock b/Cargo.lock index 717e872ef5..34c2b47361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7417,6 +7417,7 @@ dependencies = [ "secrecy 0.8.0", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/chat/src/service/chat_impl.rs b/crates/chat/src/service/chat_impl.rs index 228e8f0ca0..72d7c0a5ac 100644 --- a/crates/chat/src/service/chat_impl.rs +++ b/crates/chat/src/service/chat_impl.rs @@ -1011,9 +1011,9 @@ impl ChatService for LiveChatService { runtime_context.mode = resolve_prompt_mode_context(&persona.config, session_entry.as_ref()); apply_request_runtime_context(&mut runtime_context.host, ¶ms); - // Resolve project context. + // Resolve project context plus optional command-generated context. let project_context = self - .resolve_project_context(&session_key, conn_id.as_deref()) + .resolve_turn_context(&session_key, conn_id.as_deref()) .await; // Discover skills (gated on `[skills] enabled` — see #655). @@ -1151,9 +1151,9 @@ impl ChatService for LiveChatService { runtime_context.mode = resolve_prompt_mode_context(&persona.config, session_entry.as_ref()); apply_request_runtime_context(&mut runtime_context.host, ¶ms); - // Resolve project context. + // Resolve project context plus optional command-generated context. let project_context = self - .resolve_project_context(&session_key, conn_id.as_deref()) + .resolve_turn_context(&session_key, conn_id.as_deref()) .await; // Discover skills (gated on `[skills] enabled` — see #655). diff --git a/crates/chat/src/service/chat_impl/send.rs b/crates/chat/src/service/chat_impl/send.rs index 76285bd34f..b8d33a0e34 100644 --- a/crates/chat/src/service/chat_impl/send.rs +++ b/crates/chat/src/service/chat_impl/send.rs @@ -605,9 +605,9 @@ impl LiveChatService { } } - // Resolve project context for this connection's active project. + // Resolve project context plus optional command-generated context. let project_context = self - .resolve_project_context(&session_key, conn_id.as_deref()) + .resolve_turn_context(&session_key, conn_id.as_deref()) .await; // Generate run_id early so we can link the user message to its agent run. diff --git a/crates/chat/src/service/types.rs b/crates/chat/src/service/types.rs index 66bf175a73..c9431ffa17 100644 --- a/crates/chat/src/service/types.rs +++ b/crates/chat/src/service/types.rs @@ -2,7 +2,7 @@ use std::{ collections::{HashMap, HashSet}, - path::Path, + path::{Path, PathBuf}, sync::Arc, }; @@ -537,12 +537,17 @@ impl LiveChatService { self.session_key_for(conn_id).await } - /// Resolve the project context prompt section for a session. + /// Resolve the project context prompt section and effective working + /// directory for a session. + /// + /// The working directory is the session worktree when present, otherwise + /// the bound project directory; it is `None` when no project is bound. It + /// is used to run the configured `context_command` in the expected place. pub(in crate::service) async fn resolve_project_context( &self, session_key: &str, conn_id: Option<&str>, - ) -> Option { + ) -> (Option, Option) { let project_id = if let Some(cid) = conn_id { self.state.active_project_id(cid).await } else { @@ -558,22 +563,30 @@ impl LiveChatService { .and_then(|e| e.project_id), }; - let pid = project_id?; - let val = self + let Some(pid) = project_id else { + return (None, None); + }; + let Ok(val) = self .state .project_service() .get(serde_json::json!({"id": pid})) .await - .ok()?; - let dir = val.get("directory").and_then(|v| v.as_str())?; + else { + return (None, None); + }; + let Some(dir) = val.get("directory").and_then(|v| v.as_str()) else { + return (None, None); + }; let files = match moltis_projects::context::load_context_files(Path::new(dir)) { Ok(f) => f, Err(e) => { warn!("failed to load project context: {e}"); - return None; + return (None, None); }, }; - let project: moltis_projects::Project = serde_json::from_value(val.clone()).ok()?; + let Ok(project) = serde_json::from_value::(val.clone()) else { + return (None, None); + }; let worktree_dir = self .session_metadata .get(session_key) @@ -587,21 +600,53 @@ impl LiveChatService { None } }); + // The command runs in the session worktree when present, else the + // project root — matching where the operator's scripts expect to be. + let working_dir = worktree_dir.clone().unwrap_or_else(|| PathBuf::from(dir)); let ctx = moltis_projects::ProjectContext { project, context_files: files, worktree_dir, }; - Some(ctx.to_prompt_section()) + (Some(ctx.to_prompt_section()), Some(working_dir)) + } + + /// Resolve all dynamic prompt context for a turn. + pub(in crate::service) async fn resolve_turn_context( + &self, + session_key: &str, + conn_id: Option<&str>, + ) -> Option { + let (project_context, working_dir) = + self.resolve_project_context(session_key, conn_id).await; + let command_context = moltis_common::context_command::run_context_command( + self.config.chat.context_command.as_deref(), + working_dir.as_deref(), + ) + .await; + merge_context_sections(project_context, command_context) + } +} + +pub(in crate::service) fn merge_context_sections( + project_context: Option, + command_context: Option, +) -> Option { + match (project_context, command_context) { + (Some(project), Some(command)) => Some(format!("{project}\n\n{command}")), + (Some(project), None) => Some(project), + (None, Some(command)) => Some(command), + (None, None) => None, } } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use { super::{ ActiveAssistantDraft, build_persisted_assistant_message, - build_tool_call_assistant_message, + build_tool_call_assistant_message, merge_context_sections, }, crate::types::AssistantTurnOutput, moltis_sessions::PersistedMessage, @@ -636,6 +681,26 @@ mod tests { } } + #[test] + fn merge_context_sections_combines_project_and_command_context() { + let merged = merge_context_sections(Some("project".into()), Some("dynamic".into())) + .expect("merged context"); + assert_eq!(merged, "project\n\ndynamic"); + } + + #[test] + fn merge_context_sections_keeps_single_context() { + assert_eq!( + merge_context_sections(Some("project".into()), None).as_deref(), + Some("project") + ); + assert_eq!( + merge_context_sections(None, Some("dynamic".into())).as_deref(), + Some("dynamic") + ); + assert_eq!(merge_context_sections(None, None), None); + } + #[test] fn tool_call_assistant_message_omits_cache_usage_fields() { let message = build_tool_call_assistant_message( diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 8d9f1ef4d5..563753665b 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -18,6 +18,9 @@ tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [features] default = [] metrics = ["dep:moltis-metrics"] diff --git a/crates/common/src/context_command.rs b/crates/common/src/context_command.rs new file mode 100644 index 0000000000..a7d02b12a0 --- /dev/null +++ b/crates/common/src/context_command.rs @@ -0,0 +1,200 @@ +use std::{path::Path, process::Stdio, time::Duration}; + +use { + tokio::{io::AsyncReadExt, process::Command}, + tracing::{debug, warn}, +}; + +const CONTEXT_COMMAND_TIMEOUT_SECS: u64 = 30; + +/// Maximum number of stdout bytes buffered and forwarded to the prompt. +/// +/// Mirrors the 32k default of `workspace_file_max_chars` used for file-based +/// context. Output beyond this cap is truncated so a misconfigured command +/// (e.g. `cat /var/log/app.log`) cannot exhaust server memory or blow past the +/// model context window. stderr is bounded to the same limit so a chatty +/// failing command cannot balloon memory via its logs either. +const CONTEXT_COMMAND_MAX_BYTES: usize = 32_000; + +/// Run a configured context command and return stdout when it succeeds. +/// +/// The command is operator-configured trusted input. When `working_dir` is +/// provided the command runs in that directory (typically the active project +/// or session worktree); otherwise it inherits the server process's current +/// directory. Failures are logged and treated as missing context so a broken +/// context generator does not block chat. +pub async fn run_context_command( + command: Option<&str>, + working_dir: Option<&Path>, +) -> Option { + let command = command.map(str::trim).filter(|value| !value.is_empty())?; + + let mut cmd = shell_command(command); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + // Ensure the child is reaped if we abandon it on timeout below. + cmd.kill_on_drop(true); + if let Some(dir) = working_dir { + cmd.current_dir(dir); + } + + match tokio::time::timeout( + Duration::from_secs(CONTEXT_COMMAND_TIMEOUT_SECS), + run_capped(cmd), + ) + .await + { + Ok(result) => result, + Err(_) => { + warn!( + timeout_secs = CONTEXT_COMMAND_TIMEOUT_SECS, + "context_command timed out" + ); + None + }, + } +} + +/// Spawn the command, reading at most [`CONTEXT_COMMAND_MAX_BYTES`] of stdout so +/// unbounded output cannot exhaust memory or the model context window. +async fn run_capped(mut cmd: Command) -> Option { + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(error) => { + warn!(%error, "context_command failed to start"); + return None; + }, + }; + + let (Some(mut stdout), Some(mut stderr)) = (child.stdout.take(), child.stderr.take()) else { + warn!("context_command stdio pipes unavailable"); + return None; + }; + + // Drain stderr concurrently in a bounded task so a command that is chatty on + // stderr cannot fill its pipe and deadlock while we read stdout — and so it + // cannot balloon memory via its logs either. + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::new(); + let _ = (&mut stderr) + .take(CONTEXT_COMMAND_MAX_BYTES as u64) + .read_to_end(&mut buf) + .await; + buf + }); + + // Read one byte past the cap so truncation is detectable. + let mut stdout_buf = Vec::new(); + let stdout_res = (&mut stdout) + .take(CONTEXT_COMMAND_MAX_BYTES as u64 + 1) + .read_to_end(&mut stdout_buf) + .await; + if let Err(error) = stdout_res { + warn!(%error, "context_command failed to read stdout"); + return None; + } + + let truncated = stdout_buf.len() > CONTEXT_COMMAND_MAX_BYTES; + if truncated { + stdout_buf.truncate(CONTEXT_COMMAND_MAX_BYTES); + // We already have all we will use; stop the process so it cannot keep + // running (and blocking on the full pipe) until the outer timeout. + let _ = child.start_kill(); + warn!( + max_bytes = CONTEXT_COMMAND_MAX_BYTES, + "context_command output truncated" + ); + } + + let status = match child.wait().await { + Ok(status) => status, + Err(error) => { + warn!(%error, "context_command failed to wait"); + return None; + }, + }; + + // A truncated run was killed deliberately, so its exit status is meaningless. + if !truncated && !status.success() { + // The process has exited, so stderr has closed and the drain completes + // promptly; use its output to explain the failure. + let stderr_buf = stderr_task.await.unwrap_or_default(); + let stderr = String::from_utf8_lossy(&stderr_buf); + warn!( + exit_code = status.code(), + stderr = %stderr, + "context_command failed" + ); + return None; + } + + let text = String::from_utf8_lossy(&stdout_buf).to_string(); + if text.trim().is_empty() { + debug!("context_command produced no output"); + None + } else { + debug!(len = text.len(), "context_command produced dynamic context"); + Some(text) + } +} + +fn shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", command]); + cmd + } + + #[cfg(not(windows))] + { + let mut cmd = Command::new("sh"); + cmd.args(["-c", command]); + cmd + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_context_command_is_none() { + assert_eq!(run_context_command(Some(" "), None).await, None); + assert_eq!(run_context_command(None, None).await, None); + } + + #[tokio::test] + async fn successful_context_command_returns_stdout() { + let output = run_context_command(Some("printf 'dynamic context'"), None) + .await + .expect("context output"); + assert_eq!(output, "dynamic context"); + } + + #[tokio::test] + async fn failed_context_command_is_none() { + assert_eq!(run_context_command(Some("exit 12"), None).await, None); + } + + #[tokio::test] + async fn working_dir_is_respected() { + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::write(dir.path().join("ctx.txt"), "from-working-dir").expect("write file"); + let output = run_context_command(Some("cat ctx.txt"), Some(dir.path())) + .await + .expect("context output"); + assert_eq!(output, "from-working-dir"); + } + + #[tokio::test] + async fn large_output_is_truncated() { + // Emit far more than the cap; output must be truncated, not unbounded. + let output = run_context_command(Some("yes aaaaaaaaaa | head -c 200000"), None) + .await + .expect("context output"); + assert_eq!(output.len(), CONTEXT_COMMAND_MAX_BYTES); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b5f2c0888a..5279d4d63e 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,5 +1,6 @@ //! Shared types, error definitions, and utilities used across all moltis crates. +pub mod context_command; pub mod error; pub mod hooks; pub mod http_client; diff --git a/crates/config/src/schema/chat.rs b/crates/config/src/schema/chat.rs index 28693fb7d8..5617daf91a 100644 --- a/crates/config/src/schema/chat.rs +++ b/crates/config/src/schema/chat.rs @@ -16,6 +16,12 @@ pub struct ChatConfig { /// Maximum characters from each workspace prompt file (`AGENTS.md`, `TOOLS.md`). #[serde(default = "default_workspace_file_max_chars")] pub workspace_file_max_chars: usize, + /// Command run before each turn to generate additional prompt context. + /// + /// Stdout is appended to project context for normal Moltis chat runs and + /// external-agent context snapshots. Failures are logged and ignored. + #[serde(default)] + pub context_command: Option, /// Preferred model IDs to show first in selectors (full or raw model IDs). pub priority_models: Vec, /// Legacy model allowlist. Kept for backward compatibility. @@ -51,6 +57,7 @@ impl Default for ChatConfig { message_queue_mode: default_message_queue_mode(), prompt_memory_mode: default_prompt_memory_mode(), workspace_file_max_chars: default_workspace_file_max_chars(), + context_command: None, priority_models: Vec::new(), allowed_models: Vec::new(), compaction: CompactionConfig::default(), diff --git a/crates/config/src/schema/tests.rs b/crates/config/src/schema/tests.rs index d289974ccf..21b529457d 100644 --- a/crates/config/src/schema/tests.rs +++ b/crates/config/src/schema/tests.rs @@ -524,6 +524,12 @@ fn chat_config_toml_parses_workspace_file_limit() { assert_eq!(cfg.workspace_file_max_chars, 12_345); } +#[test] +fn chat_config_toml_parses_context_command() { + let cfg: ChatConfig = toml::from_str(r#"context_command = "thomas context""#).unwrap(); + assert_eq!(cfg.context_command.as_deref(), Some("thomas context")); +} + #[test] fn providers_config_local_alias_maps_local_llm_to_local() { let mut config = ProvidersConfig::default(); diff --git a/crates/config/src/template.rs b/crates/config/src/template.rs index af99df62bd..eec43463b3 100644 --- a/crates/config/src/template.rs +++ b/crates/config/src/template.rs @@ -285,6 +285,9 @@ port = {port} # Port number (auto-generated for this i # "live-reload" - Re-read MEMORY.md before each turn # "frozen-at-session-start" - Freeze the first MEMORY.md snapshot per session # workspace_file_max_chars = 32000 # Optional: per-file prompt cap for AGENTS.md / TOOLS.md before truncation. +# context_command = "" # Optional command run before each turn; stdout is appended to prompt context. + # Runs in the active project/worktree dir when set, else the server cwd. + # Times out after 30s; stdout capped at 32,000 bytes. # priority_models = ["claude-opus-4-5", "gpt-5.6-sol", "gemini-3-flash"] # Optional: models to pin first in selectors # ── Compaction ───────────────────────────────────────────────────────────── diff --git a/crates/config/src/validate/schema_map.rs b/crates/config/src/validate/schema_map.rs index b5db4d5f99..6416856ea3 100644 --- a/crates/config/src/validate/schema_map.rs +++ b/crates/config/src/validate/schema_map.rs @@ -393,6 +393,7 @@ pub(super) fn build_schema_map() -> KnownKeys { ("message_queue_mode", Leaf), ("prompt_memory_mode", Leaf), ("workspace_file_max_chars", Leaf), + ("context_command", Leaf), ("priority_models", Leaf), ("allowed_models", Leaf), ( diff --git a/crates/config/src/validate/tests/common.rs b/crates/config/src/validate/tests/common.rs index a71805782c..009a2a2c9b 100644 --- a/crates/config/src/validate/tests/common.rs +++ b/crates/config/src/validate/tests/common.rs @@ -140,6 +140,23 @@ rate_limit_max = 10 ); } +#[test] +fn chat_context_command_is_known_field() { + let toml = r#" +[chat] +context_command = "echo context" +"#; + let result = validate_toml_str(toml); + assert!( + !result + .diagnostics + .iter() + .any(|d| d.category == "unknown-field" && d.path == "chat.context_command"), + "chat.context_command should be accepted, got: {:?}", + result.diagnostics + ); +} + #[test] fn schema_drift_guard() { let config = MoltisConfig::default(); diff --git a/crates/external-agents/src/runtimes/codex.rs b/crates/external-agents/src/runtimes/codex.rs index 3ad7d6e492..8556c44461 100644 --- a/crates/external-agents/src/runtimes/codex.rs +++ b/crates/external-agents/src/runtimes/codex.rs @@ -16,6 +16,7 @@ use { }; use crate::{ + runtimes::process::build_process_input, transport::{ExternalAgentSession, ExternalAgentTransport}, types::{ AgentTransportKind, ContextSnapshot, ExternalAgentEvent, ExternalAgentSpec, @@ -220,15 +221,17 @@ impl ExternalAgentSession for CodexAppServerSession { async fn send_prompt( &mut self, prompt: &str, - _context: Option<&ContextSnapshot>, + context: Option<&ContextSnapshot>, ) -> anyhow::Result + Send>>> { self.status = ExternalAgentStatus::Running; let request_id = self.next_request_id; self.next_request_id = self.next_request_id.saturating_add(1); - let result = async { + let input = build_process_input(prompt, context); + let timeout = self.timeout; + let turn = async { let mut params = json!({ "threadId": self.thread_id, - "input": [{"type": "text", "text": prompt}], + "input": [{"type": "text", "text": input}], "title": "Moltis chat turn", }); if let Some(working_dir) = &self.working_dir { @@ -252,8 +255,11 @@ impl ExternalAgentSession for CodexAppServerSession { ) .await?; self.consume_turn().await - } - .await; + }; + let result = match tokio::time::timeout(timeout, turn).await { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("codex app-server turn timed out")), + }; match result { Ok(events) => { self.status = ExternalAgentStatus::Idle; @@ -350,6 +356,12 @@ fn extract_message(value: &Value) -> Option { .pointer("/params/message") .and_then(Value::as_str) .map(ToOwned::to_owned) + .or_else(|| { + value + .pointer("/params/delta") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) .or_else(|| { value .pointer("/params/text") @@ -410,6 +422,10 @@ mod tests { extract_message(&json!({"params": {"text": "delta"}})).as_deref(), Some("delta") ); + assert_eq!( + extract_message(&json!({"params": {"delta": "streamed"}})).as_deref(), + Some("streamed") + ); assert_eq!( extract_message(&json!({"result": {"message": "ok"}})).as_deref(), Some("ok") diff --git a/crates/gateway/src/external_agents.rs b/crates/gateway/src/external_agents.rs index 94c1179c13..96adc8c698 100644 --- a/crates/gateway/src/external_agents.rs +++ b/crates/gateway/src/external_agents.rs @@ -1,4 +1,9 @@ -use std::{collections::HashMap, sync::Arc, time::SystemTime}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, + time::SystemTime, +}; use { async_trait::async_trait, @@ -484,6 +489,28 @@ impl ExternalAgentChatService { Some(self.send_external(params.clone(), session_key, kind).await) } + /// Resolve the directory the configured `context_command` should run in for + /// this session: the session worktree when present, else the bound project + /// directory. Returns `None` when no project is bound, in which case the + /// command inherits the server process's current directory. + async fn resolve_context_working_dir(&self, session_key: &str) -> Option { + let entry = self.session_metadata.get(session_key).await?; + let pid = entry.project_id?; + let val = self + .state + .services + .project + .get(serde_json::json!({ "id": pid })) + .await + .ok()?; + let dir = val.get("directory").and_then(|v| v.as_str())?; + let worktree = entry.worktree_branch.as_ref().and_then(|_| { + let wt = Path::new(dir).join(".moltis-worktrees").join(session_key); + wt.exists().then_some(wt) + }); + Some(worktree.unwrap_or_else(|| PathBuf::from(dir))) + } + async fn send_external( &self, params: Value, @@ -537,7 +564,13 @@ impl ExternalAgentChatService { ) .await; - let context = context_from_history(&history); + let context_working_dir = self.resolve_context_working_dir(&session_key).await; + let context_command_output = moltis_common::context_command::run_context_command( + self.state.config.chat.context_command.as_deref(), + context_working_dir.as_deref(), + ) + .await; + let context = context_from_history_with_project_context(&history, context_command_output); let start = std::time::Instant::now(); let live_session = self .external_agents @@ -791,7 +824,7 @@ impl ExternalAgentChatService { .read(&session_key) .await .unwrap_or_default(); - let context = context_from_history(&history); + let context = context_from_history_with_project_context(&history, None); let messages: Vec = context .recent_turns .iter() @@ -865,7 +898,10 @@ async fn resolve_session_key(params: &Value, state: &GatewayState) -> String { "main".to_string() } -fn context_from_history(history: &[Value]) -> ContextSnapshot { +fn context_from_history_with_project_context( + history: &[Value], + project_context: Option, +) -> ContextSnapshot { let recent_turns = history .iter() .rev() @@ -892,6 +928,7 @@ fn context_from_history(history: &[Value]) -> ContextSnapshot { .collect(); ContextSnapshot { recent_turns, + project_context, ..ContextSnapshot::default() } } @@ -1050,6 +1087,28 @@ mod tests { } } + #[test] + fn context_from_history_includes_project_context() { + let history = vec![ + PersistedMessage::User { + content: MessageContent::Text("hello".to_string()), + created_at: None, + audio: None, + documents: None, + channel: None, + seq: None, + run_id: None, + } + .to_value(), + ]; + + let context = + context_from_history_with_project_context(&history, Some("dynamic context".into())); + + assert_eq!(context.project_context.as_deref(), Some("dynamic context")); + assert_eq!(context.recent_turns.len(), 1); + } + async fn sqlite_pool() -> sqlx::SqlitePool { let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); moltis_projects::run_migrations(&pool).await.unwrap(); diff --git a/docs/src/configuration-reference.md b/docs/src/configuration-reference.md index e3b4cb0146..f457efb407 100644 --- a/docs/src/configuration-reference.md +++ b/docs/src/configuration-reference.md @@ -285,6 +285,7 @@ User profile collected during onboarding. | `message_queue_mode` | enum: `followup`, `collect` | `"followup"` | How to handle messages that arrive while an agent run is active. `followup` queues each message and replays them one-by-one; `collect` concatenates and processes as a single message. | | `prompt_memory_mode` | enum: `live-reload`, `frozen-at-session-start` | `"live-reload"` | How `MEMORY.md` is loaded into the prompt for an ongoing session. `live-reload` reloads from disk before each turn; `frozen-at-session-start` freezes the initial content for the session lifetime. | | `workspace_file_max_chars` | integer | `32000` | Maximum characters from each workspace prompt file (`AGENTS.md`, `TOOLS.md`). | +| `context_command` | optional string | `null` | Command run before each turn to generate additional prompt context. Stdout is appended to normal chat project context and external-agent context snapshots. Runs in the session worktree or bound project directory when a project is active, otherwise the server's working directory. Times out after 30s; stdout is capped at 32,000 bytes (truncated beyond that). | | `priority_models` | array | `[]` | Preferred model IDs to show first in selectors (full or raw model IDs). | | `allowed_models` | array | `[]` | ⚠️ **Deprecated.** Legacy model allowlist kept for backward compatibility; currently ignored (model visibility is provider-driven). Will be removed in a future release. |