From 59eadd22fd6f76a37ff5539b1323c17fed86d28a Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 18:09:53 -0700 Subject: [PATCH 1/7] feat(stella-cli): add /reload and make SETTINGS/tools saves apply live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new /reload deck command re-reads the settings scope chain (user + project, managed ceiling folded in) via Config::reload_from_disk and re-applies everything it derives — engine posture, tool policy, authority, recap/trace/reward/worktree switches — to the live session. Provider/model/credential resolution is deliberately untouched. Saving from the SETTINGS tab or the tools overlay now reloads the live config immediately, closing the save-then-restart surprise. --- crates/stella-cli/src/command_deck.rs | 55 ++++++++++++++++---- crates/stella-cli/src/command_deck/skills.rs | 1 + crates/stella-cli/src/config.rs | 44 ++++++++++++++++ crates/stella-cli/src/main.rs | 4 +- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index eb4fa5945..df62a1697 100644 --- a/crates/stella-cli/src/command_deck.rs +++ b/crates/stella-cli/src/command_deck.rs @@ -336,7 +336,7 @@ fn requeue_front( /// driver inline. Returns when the user quits (Ctrl-C) or the deck's input /// stream ends. pub async fn run_deck_session( - cfg: &Config, + cfg: &mut Config, budget_limit: Option, presentation: crate::term_policy::DeckPresentation, resume: Option, @@ -3682,7 +3682,7 @@ fn engine_config_inbound(cfg: &Config, status: Option) -> Inbound { /// input was one of the overlay's. fn handle_engine_config_input( input: &WorkspaceInput, - cfg: &Config, + cfg: &mut Config, in_tx: &UnboundedSender, ) -> bool { match input { @@ -3701,10 +3701,21 @@ fn handle_engine_config_input( let status = match path { None => "save failed: cannot determine $HOME for user settings".to_string(), Some(path) => match engine.save_to(&path) { - Ok(()) => format!( - "saved to {} — applies to runs started from now on", - path.display() - ), + // A save is immediately live: reload this session's + // `Config` from the same scope chain the write just + // landed in, the same effect `/reload` has. Saving and + // then needing a second manual step to make it count + // was exactly the surprise this closes. + Ok(()) => match cfg.reload_from_disk() { + Ok(()) => format!( + "saved to {} and reloaded — applies to runs started from now on", + path.display() + ), + Err(e) => format!( + "saved to {} but reload failed: {e} (restart to pick it up)", + path.display() + ), + }, Err(e) => format!("save failed: {e}"), }, }; @@ -3773,7 +3784,7 @@ fn tool_policy_inbound(cfg: &Config, names: &[String], status: Option) - /// different (and much larger) change than editing settings. fn handle_tools_input( input: &WorkspaceInput, - cfg: &Config, + cfg: &mut Config, names: &[String], in_tx: &UnboundedSender, ) -> bool { @@ -3799,7 +3810,12 @@ fn handle_tools_input( None => "save failed: cannot determine $HOME for user settings".to_string(), Some(path) => { match crate::tool_switches::save_switches(&path, switches, &ceiling) { - Ok(status) => status, + // Live the moment it lands, same as `/reload` — see + // the identical seam in `handle_engine_config_input`. + Ok(status) => match cfg.reload_from_disk() { + Ok(()) => format!("{status} (reloaded)"), + Err(e) => format!("{status} (reload failed: {e})"), + }, Err(e) => format!("save failed: {e}"), } } @@ -3935,7 +3951,7 @@ async fn run_deck_command( system_prompt: &str, provider: &dyn Provider, registry: &ToolRegistry, - cfg: &Config, + cfg: &mut Config, custom: &crate::extensions::CustomExtensions, pipeline_on: &mut bool, budget_limit: Option, @@ -4061,6 +4077,27 @@ async fn run_deck_command( Ok(Err(e)) | Err(e) => say(format!("export failed: {e}")), } } + "/reload" => { + // Re-read the settings scope chain (user + project, managed + // ceiling folded in) and re-apply everything it derives — + // engine posture, tool policy, authority, recap/trace/reward/ + // worktree switches — to THIS session's live `Config`, without + // restarting. Provider/model/credential resolution is + // deliberately untouched (see `Config::reload_from_disk`); + // `/model` and the SETTINGS tab are the seam for that. + match cfg.reload_from_disk() { + Ok(()) => { + say("configuration reloaded — engine, tools, and authority settings \ + re-read from disk." + .to_string()); + // The SETTINGS tab's overlays cache what they last + // rendered; push fresh snapshots so a `/reload` while + // either is open reflects the new values immediately. + let _ = in_tx.send(engine_config_inbound(cfg, None)); + } + Err(e) => say(format!("reload failed: {e}")), + } + } "/donate" => { say("❤️ Support Stella\n\ \n\ diff --git a/crates/stella-cli/src/command_deck/skills.rs b/crates/stella-cli/src/command_deck/skills.rs index a5270f176..59d22f7c3 100644 --- a/crates/stella-cli/src/command_deck/skills.rs +++ b/crates/stella-cli/src/command_deck/skills.rs @@ -88,6 +88,7 @@ pub(super) const DECK_BUILTINS: &[(&str, &str)] = &[ "open the SETTINGS tab — the home of all config (models included)", ), ("/donate", "support stella — become a GitHub Sponsor"), + ("/reload", "refresh your stella configurations"), ]; /// The deck's reserved command names — see [`DECK_BUILTINS`]. diff --git a/crates/stella-cli/src/config.rs b/crates/stella-cli/src/config.rs index 10d734085..ee5698060 100644 --- a/crates/stella-cli/src/config.rs +++ b/crates/stella-cli/src/config.rs @@ -619,6 +619,50 @@ impl Config { Ok(cfg) } + /// Re-read the settings scope chain (user + project, managed ceiling + /// folded in) and re-apply everything [`Config::load_with_settings`] + /// derives from it *except* provider/model/credential resolution — those + /// need the full startup chain (interactive prompt included) and a + /// mid-session change of provider is a much larger step than a config + /// refresh. This is the `/reload` slash command's engine: same fields, + /// same precedence (trusted-launcher engine override still wins), just + /// re-derived from whatever is on disk *now* instead of what it was at + /// session start. + /// + /// Settings unreadable (malformed JSON/TOML) is reported rather than + /// silently keeping the stale in-memory values — the whole point of a + /// manual reload is that it either worked or you were told why not. + pub fn reload_from_disk(&mut self) -> Result<(), String> { + let settings = crate::settings::Settings::load(&self.workspace_root)?; + let mut settings = settings; + let trusted_engine = trusted_engine_config_override()?; + let engine_is_trusted = trusted_engine.is_some(); + if let Some(engine) = trusted_engine { + settings.agent_engine_config = Some(engine); + } + self.engine_settings = if engine_is_trusted { + settings.agent_engine_config.clone() + } else { + crate::engine_config::engine_with_provider_baseline( + self.provider.id, + &settings.agent_engine_config, + ) + }; + self.engine_settings_trusted = engine_is_trusted; + self.authority = settings.authority_policy; + self.tool_policy = settings.tool_policy(); + self.enable_recap = settings.recap_enabled(); + self.trace_capture = settings.trace_capture_enabled(); + self.reward_policy = settings.reward_policy()?; + self.create_worktrees = settings.create_worktrees(); + self.hooks = if crate::enterprise_telemetry::process_free_authority_active() { + None + } else { + settings.hooks.clone() + }; + Ok(()) + } + /// The provider-resolution body of [`Config::load_with_settings`] — /// everything except the hooks stamp, so its many early returns stay /// exactly as they were. diff --git a/crates/stella-cli/src/main.rs b/crates/stella-cli/src/main.rs index 8fbe08992..0e28a8753 100644 --- a/crates/stella-cli/src/main.rs +++ b/crates/stella-cli/src/main.rs @@ -1177,7 +1177,7 @@ fn run(cli: Cli, loaded_env: &env_files::Loaded) -> Result<(), failure::CliFailu signals::block_on_interruptible( rt()?, command_deck::run_deck_session( - &cfg, + &mut cfg, cli.globals.budget, deck_presentation(&cli.globals), None, @@ -1225,7 +1225,7 @@ fn run(cli: Cli, loaded_env: &env_files::Loaded) -> Result<(), failure::CliFailu signals::block_on_interruptible( rt()?, command_deck::run_deck_session( - &cfg, + &mut cfg, cli.globals.budget, deck_presentation(&cli.globals), Some(request), From 72e83e9dc5cda9131fe2a3a65520a19cd72da0b7 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 18:16:18 -0700 Subject: [PATCH 2/7] refactor(stella-cli): split the SETTINGS overlay handlers out of command_deck.rs, witness /reload command_deck.rs is closed to growth and the /reload feature put it 37 lines over its retightened ceiling; move handle_engine_config_input and handle_tools_input to command_deck/settings_io.rs (the skills.rs / authoring.rs pattern). Add the witness test for Config::reload_from_disk: a settings edit made after the Config was resolved flips the recap toggle and the bash tool switch without a restart. --- crates/stella-cli/src/command_deck.rs | 114 +---------------- .../src/command_deck/settings_io.rs | 119 ++++++++++++++++++ crates/stella-cli/src/config/tests.rs | 76 +++++++++++ 3 files changed, 201 insertions(+), 108 deletions(-) create mode 100644 crates/stella-cli/src/command_deck/settings_io.rs diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index 5c50c438a..4c77b93c8 100644 --- a/crates/stella-cli/src/command_deck.rs +++ b/crates/stella-cli/src/command_deck.rs @@ -117,6 +117,7 @@ mod profile_cmd; mod scope_gate; mod session_clear; mod sessions_view; +mod settings_io; mod settle; mod task_tap; mod theme_cmd; @@ -127,6 +128,7 @@ use authoring::{agents_list_creating, agents_list_inbound, handle_agent_create}; pub(crate) use forwarder::spawn_forwarder; use scope_gate::DeckApprovalGate; use sessions_view::sessions_inbound; +use settings_io::{handle_engine_config_input, handle_tools_input}; use task_tap::TaskTap; /// The lead agent's id — the one conversation this driver runs. @@ -3676,59 +3678,6 @@ fn engine_config_inbound(cfg: &Config, status: Option) -> Inbound { } } -/// Handle one ENGINE-overlay op (refresh / save) — cheap local settings -/// I/O, answered with a fresh [`Inbound::EngineConfig`]. Called from BOTH -/// recv sites so the overlay works mid-turn too. Returns `true` when the -/// input was one of the overlay's. -fn handle_engine_config_input( - input: &WorkspaceInput, - cfg: &mut Config, - in_tx: &UnboundedSender, -) -> bool { - match input { - WorkspaceInput::EngineConfigRefresh => { - let _ = in_tx.send(engine_config_inbound(cfg, None)); - true - } - WorkspaceInput::EngineConfigSave { state, scope } => { - let engine = crate::engine_config::settings_from_state(state); - let path = match scope { - AgentScope::User => crate::settings::user_config_path(), - AgentScope::Project => { - Some(crate::settings::project_config_path(&cfg.workspace_root)) - } - }; - let status = match path { - None => "save failed: cannot determine $HOME for user settings".to_string(), - Some(path) => match engine.save_to(&path) { - // A save is immediately live: reload this session's - // `Config` from the same scope chain the write just - // landed in, the same effect `/reload` has. Saving and - // then needing a second manual step to make it count - // was exactly the surprise this closes. - Ok(()) => match cfg.reload_from_disk() { - Ok(()) => format!( - "saved to {} and reloaded — applies to runs started from now on", - path.display() - ), - Err(e) => format!( - "saved to {} but reload failed: {e} (restart to pick it up)", - path.display() - ), - }, - Err(e) => format!("save failed: {e}"), - }, - }; - // The snapshot sent back is the MERGED view — if a project - // scope overrides what was just saved at the user scope, the - // overlay shows the effective value, not the wish. - let _ = in_tx.send(engine_config_inbound(cfg, Some(status))); - true - } - _ => false, - } -} - // ── Tool switches (the SETTINGS tab's TOOLS panel) ───────────────────────── /// Build an [`Inbound::ToolPolicy`] from the session's live tool surface and @@ -3774,59 +3723,6 @@ fn tool_policy_inbound(cfg: &Config, names: &[String], status: Option) - } } -/// Handle one TOOLS-panel op (refresh / save) — cheap local settings I/O, -/// answered with a fresh [`Inbound::ToolPolicy`]. Called from BOTH recv sites -/// so the panel works mid-turn too. Returns `true` when the input was one of -/// the panel's. -/// -/// A save applies to turns started afterwards: the in-flight turn already -/// resolved its tool stack, and rebuilding it under a running engine is a -/// different (and much larger) change than editing settings. -fn handle_tools_input( - input: &WorkspaceInput, - cfg: &mut Config, - names: &[String], - in_tx: &UnboundedSender, -) -> bool { - match input { - WorkspaceInput::ToolsRefresh => { - let _ = in_tx.send(tool_policy_inbound(cfg, names, None)); - true - } - WorkspaceInput::ToolsSave { switches, scope } => { - let path = match scope { - AgentScope::User => crate::settings::user_config_path(), - AgentScope::Project => { - Some(crate::settings::project_config_path(&cfg.workspace_root)) - } - }; - // The ceiling is re-read from disk rather than taken from the - // session's merged policy: the merged map cannot say which - // denials are the org's, and only the org's may refuse a grant. - let ceiling = crate::settings::Settings::load_tool_scopes(&cfg.workspace_root) - .map(|scopes| scopes.managed) - .unwrap_or_default(); - let status = match path { - None => "save failed: cannot determine $HOME for user settings".to_string(), - Some(path) => { - match crate::tool_switches::save_switches(&path, switches, &ceiling) { - // Live the moment it lands, same as `/reload` — see - // the identical seam in `handle_engine_config_input`. - Ok(status) => match cfg.reload_from_disk() { - Ok(()) => format!("{status} (reloaded)"), - Err(e) => format!("{status} (reload failed: {e})"), - }, - Err(e) => format!("save failed: {e}"), - } - } - }; - let _ = in_tx.send(tool_policy_inbound(cfg, names, Some(status))); - true - } - _ => false, - } -} - // ── Installed-agents manager (the AGENTS tab's INSTALLED AGENTS pane) ─────── /// Handle one synchronous installed-agents op (refresh / save / pin) — @@ -4087,9 +3983,11 @@ async fn run_deck_command( // `/model` and the SETTINGS tab are the seam for that. match cfg.reload_from_disk() { Ok(()) => { - say("configuration reloaded — engine, tools, and authority settings \ + say( + "configuration reloaded — engine, tools, and authority settings \ re-read from disk." - .to_string()); + .to_string(), + ); // The SETTINGS tab's overlays cache what they last // rendered; push fresh snapshots so a `/reload` while // either is open reflects the new values immediately. diff --git a/crates/stella-cli/src/command_deck/settings_io.rs b/crates/stella-cli/src/command_deck/settings_io.rs new file mode 100644 index 000000000..e20b80504 --- /dev/null +++ b/crates/stella-cli/src/command_deck/settings_io.rs @@ -0,0 +1,119 @@ +//! The SETTINGS tab's synchronous overlay handlers — the ENGINE overlay and +//! the TOOLS panel's refresh/save ops, split out of `command_deck.rs` (closed +//! to growth) the way `skills.rs` and `authoring.rs` were. +//! +//! Both handlers answer with a fresh snapshot built by the parent module's +//! `engine_config_inbound` / `tool_policy_inbound`, which stay there because +//! the deck's other arms (boot seeding, `/reload`) share them. + +use stella_tui::{AgentScope, Inbound, WorkspaceInput}; +use tokio::sync::mpsc::UnboundedSender; + +use super::{engine_config_inbound, tool_policy_inbound}; +use crate::config::Config; + +/// Handle one ENGINE-overlay op (refresh / save) — cheap local settings +/// I/O, answered with a fresh [`Inbound::EngineConfig`]. Called from BOTH +/// recv sites so the overlay works mid-turn too. Returns `true` when the +/// input was one of the overlay's. +pub(super) fn handle_engine_config_input( + input: &WorkspaceInput, + cfg: &mut Config, + in_tx: &UnboundedSender, +) -> bool { + match input { + WorkspaceInput::EngineConfigRefresh => { + let _ = in_tx.send(engine_config_inbound(cfg, None)); + true + } + WorkspaceInput::EngineConfigSave { state, scope } => { + let engine = crate::engine_config::settings_from_state(state); + let path = match scope { + AgentScope::User => crate::settings::user_config_path(), + AgentScope::Project => { + Some(crate::settings::project_config_path(&cfg.workspace_root)) + } + }; + let status = match path { + None => "save failed: cannot determine $HOME for user settings".to_string(), + Some(path) => match engine.save_to(&path) { + // A save is immediately live: reload this session's + // `Config` from the same scope chain the write just + // landed in, the same effect `/reload` has. Saving and + // then needing a second manual step to make it count + // was exactly the surprise this closes. + Ok(()) => match cfg.reload_from_disk() { + Ok(()) => format!( + "saved to {} and reloaded — applies to runs started from now on", + path.display() + ), + Err(e) => format!( + "saved to {} but reload failed: {e} (restart to pick it up)", + path.display() + ), + }, + Err(e) => format!("save failed: {e}"), + }, + }; + // The snapshot sent back is the MERGED view — if a project + // scope overrides what was just saved at the user scope, the + // overlay shows the effective value, not the wish. + let _ = in_tx.send(engine_config_inbound(cfg, Some(status))); + true + } + _ => false, + } +} + +/// Handle one TOOLS-panel op (refresh / save) — cheap local settings I/O, +/// answered with a fresh [`Inbound::ToolPolicy`]. Called from BOTH recv sites +/// so the panel works mid-turn too. Returns `true` when the input was one of +/// the panel's. +/// +/// A save applies to turns started afterwards: the in-flight turn already +/// resolved its tool stack, and rebuilding it under a running engine is a +/// different (and much larger) change than editing settings. +pub(super) fn handle_tools_input( + input: &WorkspaceInput, + cfg: &mut Config, + names: &[String], + in_tx: &UnboundedSender, +) -> bool { + match input { + WorkspaceInput::ToolsRefresh => { + let _ = in_tx.send(tool_policy_inbound(cfg, names, None)); + true + } + WorkspaceInput::ToolsSave { switches, scope } => { + let path = match scope { + AgentScope::User => crate::settings::user_config_path(), + AgentScope::Project => { + Some(crate::settings::project_config_path(&cfg.workspace_root)) + } + }; + // The ceiling is re-read from disk rather than taken from the + // session's merged policy: the merged map cannot say which + // denials are the org's, and only the org's may refuse a grant. + let ceiling = crate::settings::Settings::load_tool_scopes(&cfg.workspace_root) + .map(|scopes| scopes.managed) + .unwrap_or_default(); + let status = match path { + None => "save failed: cannot determine $HOME for user settings".to_string(), + Some(path) => { + match crate::tool_switches::save_switches(&path, switches, &ceiling) { + // Live the moment it lands, same as `/reload` — see + // the identical seam in `handle_engine_config_input`. + Ok(status) => match cfg.reload_from_disk() { + Ok(()) => format!("{status} (reloaded)"), + Err(e) => format!("{status} (reload failed: {e})"), + }, + Err(e) => format!("save failed: {e}"), + } + } + }; + let _ = in_tx.send(tool_policy_inbound(cfg, names, Some(status))); + true + } + _ => false, + } +} diff --git a/crates/stella-cli/src/config/tests.rs b/crates/stella-cli/src/config/tests.rs index 1a5a15d32..06734287d 100644 --- a/crates/stella-cli/src/config/tests.rs +++ b/crates/stella-cli/src/config/tests.rs @@ -264,6 +264,82 @@ fn resolved_config_carries_the_authority_computed_during_settings_load() { assert_eq!(cfg.authority, authority); } +/// Witness for `/reload` (`Config::reload_from_disk`): a settings edit made +/// *after* the session's `Config` was resolved is re-applied to the live +/// value — the fields the scope chain derives (the recap toggle, the tool +/// switches) flip without a restart. Fails to compile on a build without +/// `reload_from_disk`. +#[test] +fn reload_from_disk_reapplies_the_settings_scope_chain() { + // The scope chain reads `STELLA_CONFIG_DIR` and `reload_from_disk` reads + // the trusted-engine env var; hold the binary-wide env lock and pin the + // chain to a scratch dir so ambient developer state cannot leak in + // (setenv racing any concurrent getenv is UB on POSIX). + let _env = crate::test_env::lock(); + let user_dir = + std::env::temp_dir().join(format!("stella-test-reload-user-{}", std::process::id())); + let workspace = user_dir.join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + // SAFETY: test-only env mutation behind the env lock (see above). + unsafe { + std::env::set_var("STELLA_CONFIG_DIR", &user_dir); + std::env::remove_var("STELLA_MANAGED_SETTINGS"); + std::env::remove_var(TRUSTED_ENGINE_CONFIG_ENV); + } + + let mut cfg = Config { + provider: PROVIDERS[0].clone(), + model_id: "glm-5.2".to_string(), + turn_budget: None, + max_output_tokens: None, + plan_mode: false, + model_pinned_by_flag: false, + durability: Default::default(), + create_worktrees: Default::default(), + api_key: ApiKey::new("k"), + workspace_root: workspace, + base_url_override: None, + hooks: None, + engine_settings: None, + engine_settings_trusted: false, + tool_policy: Default::default(), + enable_recap: false, + trace_capture: false, + reward_policy: Default::default(), + authority: crate::settings::AuthorityPolicy::default(), + credential_source: Some(stella_model::credential::CredentialSource::EnvVar), + credential_advisories: Vec::new(), + aux_credentials: Default::default(), + }; + assert!( + cfg.tool_policy.allows("bash"), + "premise: the default policy allows bash" + ); + + // The edit a running session would previously only see after a restart. + std::fs::write( + user_dir.join("settings.json"), + r#"{"enable_recap": "on", "tools": {"bash": "off"}}"#, + ) + .unwrap(); + + cfg.reload_from_disk().unwrap(); + + assert!( + cfg.enable_recap, + "reload must re-derive the recap toggle from the scope chain on disk" + ); + assert!( + !cfg.tool_policy.allows("bash"), + "reload must re-derive the tool switches from the scope chain on disk" + ); + + unsafe { + std::env::remove_var("STELLA_CONFIG_DIR"); + } + let _ = std::fs::remove_dir_all(&user_dir); +} + /// Helper: a Settings value parsed from JSON, as the scope-merge would /// produce it — the seam for exercising resolution without touching /// `$HOME`, `/etc`, or a real workspace. From 21013bbd466b922c3358b4e842d8868768d11aa2 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 18:26:29 -0700 Subject: [PATCH 3/7] refactor(stella-cli): move reload_from_disk to config/reload.rs; retighten the file-size baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reload_from_disk pushed config.rs (1498 on main) over the 1500 ceiling, and the baseline accepts no new entries — the mutation gets its own submodule instead. Regenerating the baseline also drops the stale stella-protocol/src/event.rs entry (its tests moved to event/tests.rs on main, leaving the file at 1454) and retightens command_deck.rs and the pipeline entries; AGENTS.md's god-file table and the stella-protocol README follow the baseline, as check-god-files requires. --- AGENTS.md | 3 +- crates/stella-cli/src/config.rs | 45 +-------------------- crates/stella-cli/src/config/reload.rs | 54 ++++++++++++++++++++++++++ crates/stella-protocol/README.md | 34 +++++++--------- scripts/file-size-baseline.txt | 7 ++-- 5 files changed, 73 insertions(+), 70 deletions(-) create mode 100644 crates/stella-cli/src/config/reload.rs diff --git a/AGENTS.md b/AGENTS.md index c31890298..2f3c6a574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -424,12 +424,11 @@ a plan needs and the part that rarely changes: | `stella-core` | `src/driver/tests.rs`, `src/driver.rs`, `src/bus.rs` | | `stella-model` | `src/openai.rs`, `src/zai/tests.rs`, `src/anthropic/tests.rs`, `src/zai.rs` | | `stella-pipeline` | `src/pipeline.rs`, `src/pipeline/tests.rs` | -| `stella-protocol` | `src/event.rs` | | `stella-store` | `src/tests.rs`, `src/lib.rs`, `src/usage.rs` | | `stella-tools` | `src/registry.rs`, `src/scripts.rs` | | `stella-tui` | `src/deck_ui.rs`, `src/views/engine.rs`, `src/views/session.rs`, `src/deck_render.rs` | -The other thirteen crates carry no god files — keep it that way. Each crate's +The other fourteen crates carry no god files — keep it that way. Each crate's README repeats its own list under "God files — do not add lines", so the constraint is in view wherever planning starts. diff --git a/crates/stella-cli/src/config.rs b/crates/stella-cli/src/config.rs index ee5698060..c8aad660f 100644 --- a/crates/stella-cli/src/config.rs +++ b/crates/stella-cli/src/config.rs @@ -132,6 +132,7 @@ fn interactive_allowed() -> bool { mod aux; mod providers; +mod reload; // Re-exported at the old paths: the table moved for the line ratchet, not // for callers, and `crate::config::PROVIDERS` stays the one way to reach it. @@ -619,50 +620,6 @@ impl Config { Ok(cfg) } - /// Re-read the settings scope chain (user + project, managed ceiling - /// folded in) and re-apply everything [`Config::load_with_settings`] - /// derives from it *except* provider/model/credential resolution — those - /// need the full startup chain (interactive prompt included) and a - /// mid-session change of provider is a much larger step than a config - /// refresh. This is the `/reload` slash command's engine: same fields, - /// same precedence (trusted-launcher engine override still wins), just - /// re-derived from whatever is on disk *now* instead of what it was at - /// session start. - /// - /// Settings unreadable (malformed JSON/TOML) is reported rather than - /// silently keeping the stale in-memory values — the whole point of a - /// manual reload is that it either worked or you were told why not. - pub fn reload_from_disk(&mut self) -> Result<(), String> { - let settings = crate::settings::Settings::load(&self.workspace_root)?; - let mut settings = settings; - let trusted_engine = trusted_engine_config_override()?; - let engine_is_trusted = trusted_engine.is_some(); - if let Some(engine) = trusted_engine { - settings.agent_engine_config = Some(engine); - } - self.engine_settings = if engine_is_trusted { - settings.agent_engine_config.clone() - } else { - crate::engine_config::engine_with_provider_baseline( - self.provider.id, - &settings.agent_engine_config, - ) - }; - self.engine_settings_trusted = engine_is_trusted; - self.authority = settings.authority_policy; - self.tool_policy = settings.tool_policy(); - self.enable_recap = settings.recap_enabled(); - self.trace_capture = settings.trace_capture_enabled(); - self.reward_policy = settings.reward_policy()?; - self.create_worktrees = settings.create_worktrees(); - self.hooks = if crate::enterprise_telemetry::process_free_authority_active() { - None - } else { - settings.hooks.clone() - }; - Ok(()) - } - /// The provider-resolution body of [`Config::load_with_settings`] — /// everything except the hooks stamp, so its many early returns stay /// exactly as they were. diff --git a/crates/stella-cli/src/config/reload.rs b/crates/stella-cli/src/config/reload.rs new file mode 100644 index 000000000..ee8bcc65b --- /dev/null +++ b/crates/stella-cli/src/config/reload.rs @@ -0,0 +1,54 @@ +//! The `/reload` slash command's engine — re-applying the settings scope +//! chain to a live [`Config`] without restarting the session. +//! +//! Its own module (not more lines in `config.rs`, which sits at the 1500-line +//! ceiling) because it is the one mutation `Config` supports after +//! construction: everything else in `config.rs` is the one-shot startup +//! resolution. + +use super::Config; + +impl Config { + /// Re-read the settings scope chain (user + project, managed ceiling + /// folded in) and re-apply everything [`Config::load_with_settings`] + /// derives from it *except* provider/model/credential resolution — those + /// need the full startup chain (interactive prompt included) and a + /// mid-session change of provider is a much larger step than a config + /// refresh. This is the `/reload` slash command's engine: same fields, + /// same precedence (trusted-launcher engine override still wins), just + /// re-derived from whatever is on disk *now* instead of what it was at + /// session start. + /// + /// Settings unreadable (malformed JSON/TOML) is reported rather than + /// silently keeping the stale in-memory values — the whole point of a + /// manual reload is that it either worked or you were told why not. + pub fn reload_from_disk(&mut self) -> Result<(), String> { + let mut settings = crate::settings::Settings::load(&self.workspace_root)?; + let trusted_engine = super::trusted_engine_config_override()?; + let engine_is_trusted = trusted_engine.is_some(); + if let Some(engine) = trusted_engine { + settings.agent_engine_config = Some(engine); + } + self.engine_settings = if engine_is_trusted { + settings.agent_engine_config.clone() + } else { + crate::engine_config::engine_with_provider_baseline( + self.provider.id, + &settings.agent_engine_config, + ) + }; + self.engine_settings_trusted = engine_is_trusted; + self.authority = settings.authority_policy; + self.tool_policy = settings.tool_policy(); + self.enable_recap = settings.recap_enabled(); + self.trace_capture = settings.trace_capture_enabled(); + self.reward_policy = settings.reward_policy()?; + self.create_worktrees = settings.create_worktrees(); + self.hooks = if crate::enterprise_telemetry::process_free_authority_active() { + None + } else { + settings.hooks.clone() + }; + Ok(()) + } +} diff --git a/crates/stella-protocol/README.md b/crates/stella-protocol/README.md index 3adc0dab2..3b3f62d6c 100644 --- a/crates/stella-protocol/README.md +++ b/crates/stella-protocol/README.md @@ -79,26 +79,20 @@ and the root `Cargo.toml` members list in the same PR. ## God files — do not add lines -The gate's `file-size` guard (`scripts/check-file-size.sh`) enforces a -1500-line ratchet — a NEW file over the limit is a hard failure with no -baseline escape — and this crate has exactly one file grandfathered at a -recorded ceiling in `scripts/file-size-baseline.txt`. It is a god file: already -too big, closed to growth. Plan event work so no new line lands in it: new -supporting vocabulary goes in a new module re-exported from -[`src/lib.rs`](src/lib.rs) — the crate's own precedent is -[`src/ladder.rs`](src/ladder.rs), split out of `event.rs` when the ladder rung -joined it (#1043), with the re-export keeping `stella_protocol::LadderSnapshot` -at its old path — and types you touch there are candidates to extract, taking -their inline round-trip tests with them. A genuinely new `AgentEvent` variant -cannot avoid its lines in `event.rs`; offset them by extracting the variant's -supporting types, or move the ceiling honestly as below. - -- [`src/event.rs`](src/event.rs) - -A ceiling can move only via `make file-size-update`, which lands as a -reviewable baseline diff justified like any other change — treat it as an -escape hatch for an irreducible line (a module declaration in an oversized -`lib.rs`), never as a planning assumption. +This crate has no god files: no file exceeds the gate's 1500-line ratchet +(`scripts/check-file-size.sh`), and none may appear — a new file crossing +1500 lines fails the gate outright, and `scripts/file-size-baseline.txt` +accepts no new entries. When a file here approaches the limit, split it before +it crosses. + +[`src/event.rs`](src/event.rs) was the crate's one god file until its inline +round-trip tests moved to `src/event/tests.rs`; it sits just under the limit, +so plan event work the way its era as a god file demanded: new supporting +vocabulary goes in a new module re-exported from [`src/lib.rs`](src/lib.rs) — +the crate's own precedent is [`src/ladder.rs`](src/ladder.rs), split out of +`event.rs` when the ladder rung joined it (#1043), with the re-export keeping +`stella_protocol::LadderSnapshot` at its old path — never as more lines in +`event.rs` itself. ## Layout diff --git a/scripts/file-size-baseline.txt b/scripts/file-size-baseline.txt index e142f2dbe..9da517b13 100644 --- a/scripts/file-size-baseline.txt +++ b/scripts/file-size-baseline.txt @@ -17,7 +17,7 @@ 1659 bench/terminal_bench_analysis/tests/test_tb21_evidence_contract.py 2266 crates/stella-cli/src/agent.rs 1751 crates/stella-cli/src/agent/tests.rs -4621 crates/stella-cli/src/command_deck.rs +4556 crates/stella-cli/src/command_deck.rs 1504 crates/stella-cli/src/fleet_cmd.rs 2126 crates/stella-core/src/bus.rs 2568 crates/stella-core/src/driver.rs @@ -26,9 +26,8 @@ 2093 crates/stella-model/src/openai.rs 1565 crates/stella-model/src/zai.rs 1895 crates/stella-model/src/zai/tests.rs -3573 crates/stella-pipeline/src/pipeline.rs -2573 crates/stella-pipeline/src/pipeline/tests.rs -2965 crates/stella-protocol/src/event.rs +3462 crates/stella-pipeline/src/pipeline.rs +2533 crates/stella-pipeline/src/pipeline/tests.rs 1996 crates/stella-store/src/lib.rs 2266 crates/stella-store/src/tests.rs 1916 crates/stella-store/src/usage.rs From 583b9ad1de070a4e9bf86ee986f004256478cb5b Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 18:29:31 -0700 Subject: [PATCH 4/7] docs(website): document the /reload deck command in chat.mdx --- website/content/docs/commands/chat.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/content/docs/commands/chat.mdx b/website/content/docs/commands/chat.mdx index 62517eb78..bee2c50e0 100644 --- a/website/content/docs/commands/chat.mdx +++ b/website/content/docs/commands/chat.mdx @@ -229,6 +229,12 @@ Open a tab or run an action: Export session telemetry to a ZIP plus an HTML dashboard. + + Re-read the settings scope chain from disk and apply it to the live + session — engine posture, tool switches, authority. Saving from the + SETTINGS tab does this automatically; `/reload` covers edits made outside + the deck. Provider, model, and credentials are deliberately untouched. + Support stella — become a GitHub Sponsor. From 25a9378aababbdbbc989e09f1c02d7bca15394e4 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 19:11:16 -0700 Subject: [PATCH 5/7] fix(stella-cli): park a mid-turn settings reload instead of borrowing cfg mutably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threading `&mut Config` into the deck did not compile: the in-turn recv site sits in the same select as the turn coroutine, which holds `&Config` and reads the very fields a reload rewrites (tool policy, authority, engine posture). Rust was right — reloading there tears config out from under a running turn. The handlers now report `stale` and the caller re-derives at a safe boundary, the discipline `/budget` already follows with pending_budget: immediately at the idle site, and after the turn ends mid-turn. Both panels re-read the scope chain from disk, so the delay is invisible in the UI; only subsequent turns depend on the live Config. Also fixes the witness, which read the developer's real ~/.stella because UserPaths::test_default keeps the ambient home — it now uses the thread-local paths seam (#1139). --- crates/stella-cli/src/command_deck.rs | 52 +++++++++++--- .../src/command_deck/settings_io.rs | 71 +++++++++++++------ crates/stella-cli/src/config/tests.rs | 37 +++++----- 3 files changed, 111 insertions(+), 49 deletions(-) diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index 4c77b93c8..b46346979 100644 --- a/crates/stella-cli/src/command_deck.rs +++ b/crates/stella-cli/src/command_deck.rs @@ -128,7 +128,7 @@ use authoring::{agents_list_creating, agents_list_inbound, handle_agent_create}; pub(crate) use forwarder::spawn_forwarder; use scope_gate::DeckApprovalGate; use sessions_view::sessions_inbound; -use settings_io::{handle_engine_config_input, handle_tools_input}; +use settings_io::{apply_pending_reload, handle_engine_config_input, handle_tools_input}; use task_tap::TaskTap; /// The lead agent's id — the one conversation this driver runs. @@ -866,6 +866,10 @@ pub async fn run_deck_session( // the guard, so the retarget waits for the settle boundary (invariant // #6 — budget changes act between steps/turns, never mid-flight). let mut pending_budget: Option> = None; + // A SETTINGS save landed mid-turn: the files changed, but the running + // turn holds `&Config` and is reading the fields a reload rewrites, so + // the re-derive waits for the same safe boundary `pending_budget` uses. + let mut pending_settings_reload = false; // Sub-session bookkeeping: live-worker slots, and `task_assign` requests // waiting for one (drained oldest-first as workers end). let mut subs = SubSessions::with_registry_options(registry_options.clone()); @@ -1331,6 +1335,10 @@ pub async fn run_deck_session( // A stray answer/decision/control with no turn in flight // falls through all four no-ops. Some(other) => { + // Set by a SETTINGS save below; applied before the + // loop turns over, so the next turn reads the files + // as they are now. + let mut settings_stale = false; if !crate::deck_mcp::service_mcp_action( &other, cfg, @@ -1349,7 +1357,7 @@ pub async fn run_deck_session( && !service_inspect_action(&other, &store, last_execution_id, &in_tx) && !handle_agents_input(&other, cfg, &in_tx) && !handle_issues_input(&other, cfg, &issue_backend_cache, &in_tx) - && !handle_engine_config_input(&other, cfg, &in_tx) + && !handle_engine_config_input(&other, cfg, &mut settings_stale, &in_tx) { // The tool list is enumerated here rather than // cached: MCP servers join the session @@ -1362,7 +1370,12 @@ pub async fn run_deck_session( }; let names = crate::tool_switches::session_tool_names(base, &custom_tools); - handle_tools_input(&other, cfg, &names, &in_tx); + handle_tools_input(&other, cfg, &names, &mut settings_stale, &in_tx); + } + // No turn is in flight here, so "applies from now on" + // means the very next prompt. + if settings_stale { + apply_pending_reload(cfg, &in_tx); } continue 'session; } @@ -1978,7 +1991,12 @@ pub async fn run_deck_session( input @ (WorkspaceInput::EngineConfigSave { .. } | WorkspaceInput::EngineConfigRefresh), ) => { - handle_engine_config_input(&input, cfg, &in_tx); + handle_engine_config_input( + &input, + cfg, + &mut pending_settings_reload, + &in_tx, + ); } // The TOOLS panel likewise. `base_tools` is the very // stack the running turn is using, so the list the @@ -1991,7 +2009,13 @@ pub async fn run_deck_session( base_tools, &custom_tools, ); - handle_tools_input(&input, cfg, &names, &in_tx); + handle_tools_input( + &input, + cfg, + &names, + &mut pending_settings_reload, + &in_tx, + ); } // The ISSUES tab stays live while a turn runs too — // every op spawns its own task and answers from it, @@ -2033,6 +2057,14 @@ pub async fn run_deck_session( budget.set_session_limit_usd(cap); } + // Likewise a SETTINGS save parked during the turn: the turn that was + // reading `cfg` has ended, so re-deriving it here is both sound and + // the earliest honest moment for "applies to runs started from now + // on" to become true. + if std::mem::take(&mut pending_settings_reload) { + apply_pending_reload(cfg, &in_tx); + } + match end { TurnEnd::Finished(outcome) => { if let Err(reason) = &outcome { @@ -3988,9 +4020,13 @@ async fn run_deck_command( re-read from disk." .to_string(), ); - // The SETTINGS tab's overlays cache what they last - // rendered; push fresh snapshots so a `/reload` while - // either is open reflects the new values immediately. + // Refresh an open SETTINGS tab with the merged view, the + // same courtesy `/model` pays. The deck renders the last + // snapshot it was sent, so a hand edit picked up by + // `/reload` is invisible in an open overlay until one + // arrives. The TOOLS panel is deliberately not refreshed + // here: an accurate row list needs the MCP-inclusive live + // stack, which this function does not hold (#1966). let _ = in_tx.send(engine_config_inbound(cfg, None)); } Err(e) => say(format!("reload failed: {e}")), diff --git a/crates/stella-cli/src/command_deck/settings_io.rs b/crates/stella-cli/src/command_deck/settings_io.rs index e20b80504..9ed1a374e 100644 --- a/crates/stella-cli/src/command_deck/settings_io.rs +++ b/crates/stella-cli/src/command_deck/settings_io.rs @@ -5,6 +5,19 @@ //! Both handlers answer with a fresh snapshot built by the parent module's //! `engine_config_inbound` / `tool_policy_inbound`, which stay there because //! the deck's other arms (boot seeding, `/reload`) share them. +//! +//! **Neither handler reloads the live [`Config`] itself.** A save makes the +//! session's `Config` stale, and re-deriving it is the caller's job at a safe +//! boundary — the same discipline `/budget` already follows with +//! `pending_budget`. Both call sites run this code, and one of them runs it +//! *while a turn is in flight*, holding `&Config` and reading the very fields +//! a reload overwrites (tool policy, authority, engine posture). Mutating +//! there would tear config out from under a running turn, so the handlers +//! report `stale` and let the caller pick the moment. +//! +//! The panels are unaffected by the delay: both snapshot builders re-read the +//! scope chain from disk, so what the overlay shows is what the files say, +//! reloaded or not. Only *subsequent turns* depend on the live `Config`. use stella_tui::{AgentScope, Inbound, WorkspaceInput}; use tokio::sync::mpsc::UnboundedSender; @@ -16,9 +29,14 @@ use crate::config::Config; /// I/O, answered with a fresh [`Inbound::EngineConfig`]. Called from BOTH /// recv sites so the overlay works mid-turn too. Returns `true` when the /// input was one of the overlay's. +/// +/// `stale` is set when a write lands, meaning the live [`Config`] no longer +/// matches the files — see the module docs for why the reload is the +/// caller's to perform. pub(super) fn handle_engine_config_input( input: &WorkspaceInput, - cfg: &mut Config, + cfg: &Config, + stale: &mut bool, in_tx: &UnboundedSender, ) -> bool { match input { @@ -37,21 +55,13 @@ pub(super) fn handle_engine_config_input( let status = match path { None => "save failed: cannot determine $HOME for user settings".to_string(), Some(path) => match engine.save_to(&path) { - // A save is immediately live: reload this session's - // `Config` from the same scope chain the write just - // landed in, the same effect `/reload` has. Saving and - // then needing a second manual step to make it count - // was exactly the surprise this closes. - Ok(()) => match cfg.reload_from_disk() { - Ok(()) => format!( - "saved to {} and reloaded — applies to runs started from now on", + Ok(()) => { + *stale = true; + format!( + "saved to {} — applies to runs started from now on", path.display() - ), - Err(e) => format!( - "saved to {} but reload failed: {e} (restart to pick it up)", - path.display() - ), - }, + ) + } Err(e) => format!("save failed: {e}"), }, }; @@ -72,11 +82,13 @@ pub(super) fn handle_engine_config_input( /// /// A save applies to turns started afterwards: the in-flight turn already /// resolved its tool stack, and rebuilding it under a running engine is a -/// different (and much larger) change than editing settings. +/// different (and much larger) change than editing settings. `stale` carries +/// the reload the caller owes — see the module docs. pub(super) fn handle_tools_input( input: &WorkspaceInput, - cfg: &mut Config, + cfg: &Config, names: &[String], + stale: &mut bool, in_tx: &UnboundedSender, ) -> bool { match input { @@ -101,12 +113,10 @@ pub(super) fn handle_tools_input( None => "save failed: cannot determine $HOME for user settings".to_string(), Some(path) => { match crate::tool_switches::save_switches(&path, switches, &ceiling) { - // Live the moment it lands, same as `/reload` — see - // the identical seam in `handle_engine_config_input`. - Ok(status) => match cfg.reload_from_disk() { - Ok(()) => format!("{status} (reloaded)"), - Err(e) => format!("{status} (reload failed: {e})"), - }, + Ok(status) => { + *stale = true; + status + } Err(e) => format!("save failed: {e}"), } } @@ -117,3 +127,18 @@ pub(super) fn handle_tools_input( _ => false, } } + +/// Re-derive the live [`Config`] from disk after a save, reporting a failure +/// to the deck rather than swallowing it. +/// +/// The one place both call sites converge, so "a save is live" is stated once. +/// A failed reload leaves the session on its previous (still coherent) values +/// — the files are already written, so a restart picks them up regardless, +/// which is what the note says. +pub(super) fn apply_pending_reload(cfg: &mut Config, in_tx: &UnboundedSender) { + if let Err(e) = cfg.reload_from_disk() { + let _ = in_tx.send(super::chrome_note(format!( + "settings saved, but reloading them failed: {e} — restart to pick them up." + ))); + } +} diff --git a/crates/stella-cli/src/config/tests.rs b/crates/stella-cli/src/config/tests.rs index 06734287d..4f80bab60 100644 --- a/crates/stella-cli/src/config/tests.rs +++ b/crates/stella-cli/src/config/tests.rs @@ -271,21 +271,25 @@ fn resolved_config_carries_the_authority_computed_during_settings_load() { /// `reload_from_disk`. #[test] fn reload_from_disk_reapplies_the_settings_scope_chain() { - // The scope chain reads `STELLA_CONFIG_DIR` and `reload_from_disk` reads - // the trusted-engine env var; hold the binary-wide env lock and pin the - // chain to a scratch dir so ambient developer state cannot leak in - // (setenv racing any concurrent getenv is UB on POSIX). + // `reload_from_disk` reads the process-wide trusted-engine-config env + // var, so hold the binary env lock — read-only, exactly as + // `resolved_config_carries_the_authority_computed_during_settings_load` + // does: a concurrent test setting that var malformed would otherwise make + // this load fail. let _env = crate::test_env::lock(); - let user_dir = - std::env::temp_dir().join(format!("stella-test-reload-user-{}", std::process::id())); - let workspace = user_dir.join("ws"); + // The user scope is redirected with the thread-local paths seam (#1139), + // NOT by setting `$HOME`: no environment mutation, no `unsafe`, and no + // race with a test on another thread. Without it `UserPaths::test_default` + // keeps the developer's real home and this test reads their actual + // `~/.stella/settings.json`. + let home = std::env::temp_dir().join(format!( + "stella-test-reload-from-disk-{}", + std::process::id() + )); + let workspace = home.join("ws"); + std::fs::create_dir_all(home.join(".stella")).unwrap(); std::fs::create_dir_all(&workspace).unwrap(); - // SAFETY: test-only env mutation behind the env lock (see above). - unsafe { - std::env::set_var("STELLA_CONFIG_DIR", &user_dir); - std::env::remove_var("STELLA_MANAGED_SETTINGS"); - std::env::remove_var(TRUSTED_ENGINE_CONFIG_ENV); - } + let _paths = crate::paths::test_user_home(home.clone()); let mut cfg = Config { provider: PROVIDERS[0].clone(), @@ -318,7 +322,7 @@ fn reload_from_disk_reapplies_the_settings_scope_chain() { // The edit a running session would previously only see after a restart. std::fs::write( - user_dir.join("settings.json"), + home.join(".stella").join("settings.json"), r#"{"enable_recap": "on", "tools": {"bash": "off"}}"#, ) .unwrap(); @@ -334,10 +338,7 @@ fn reload_from_disk_reapplies_the_settings_scope_chain() { "reload must re-derive the tool switches from the scope chain on disk" ); - unsafe { - std::env::remove_var("STELLA_CONFIG_DIR"); - } - let _ = std::fs::remove_dir_all(&user_dir); + let _ = std::fs::remove_dir_all(&home); } /// Helper: a Settings value parsed from JSON, as the scope-merge would From 85149f86a9794c15e5234739d5f6d7d3e653a04d Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 19:18:15 -0700 Subject: [PATCH 6/7] refactor(stella-cli): move the /reload command body into settings_io.rs command_deck.rs is closed to growth: the parked-reload plumbing pushed it back over its ceiling, so the command body follows the panel handlers into the submodule. Net against main the file shrinks 4621 -> 4566, which is the single line the regenerated baseline now carries. --- crates/stella-cli/src/command_deck.rs | 28 +------------------ .../src/command_deck/settings_io.rs | 22 +++++++++++++++ scripts/file-size-baseline.txt | 2 +- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index b46346979..501aaf81c 100644 --- a/crates/stella-cli/src/command_deck.rs +++ b/crates/stella-cli/src/command_deck.rs @@ -4005,33 +4005,7 @@ async fn run_deck_command( Ok(Err(e)) | Err(e) => say(format!("export failed: {e}")), } } - "/reload" => { - // Re-read the settings scope chain (user + project, managed - // ceiling folded in) and re-apply everything it derives — - // engine posture, tool policy, authority, recap/trace/reward/ - // worktree switches — to THIS session's live `Config`, without - // restarting. Provider/model/credential resolution is - // deliberately untouched (see `Config::reload_from_disk`); - // `/model` and the SETTINGS tab are the seam for that. - match cfg.reload_from_disk() { - Ok(()) => { - say( - "configuration reloaded — engine, tools, and authority settings \ - re-read from disk." - .to_string(), - ); - // Refresh an open SETTINGS tab with the merged view, the - // same courtesy `/model` pays. The deck renders the last - // snapshot it was sent, so a hand edit picked up by - // `/reload` is invisible in an open overlay until one - // arrives. The TOOLS panel is deliberately not refreshed - // here: an accurate row list needs the MCP-inclusive live - // stack, which this function does not hold (#1966). - let _ = in_tx.send(engine_config_inbound(cfg, None)); - } - Err(e) => say(format!("reload failed: {e}")), - } - } + "/reload" => say(settings_io::reload_command(cfg, in_tx)), "/donate" => { say("❤️ Support Stella\n\ \n\ diff --git a/crates/stella-cli/src/command_deck/settings_io.rs b/crates/stella-cli/src/command_deck/settings_io.rs index 9ed1a374e..5242eab1c 100644 --- a/crates/stella-cli/src/command_deck/settings_io.rs +++ b/crates/stella-cli/src/command_deck/settings_io.rs @@ -128,6 +128,28 @@ pub(super) fn handle_tools_input( } } +/// The `/reload` command: re-read the settings scope chain (user + project, +/// managed ceiling folded in) and re-apply everything it derives — engine +/// posture, tool policy, authority, recap/trace/reward/worktree switches — to +/// THIS session's live [`Config`], without restarting. +/// +/// Provider/model/credential resolution is deliberately untouched (see +/// [`Config::reload_from_disk`]); `/model` and the SETTINGS tab are the seam +/// for that. Returns the line to print in the lead transcript. +pub(super) fn reload_command(cfg: &mut Config, in_tx: &UnboundedSender) -> String { + if let Err(e) = cfg.reload_from_disk() { + return format!("reload failed: {e}"); + } + // Refresh an open SETTINGS tab with the merged view, the same courtesy + // `/model` pays. The deck renders the last snapshot it was sent, so a + // hand edit picked up by `/reload` is invisible in an open overlay until + // one arrives. The TOOLS panel is deliberately not refreshed here: an + // accurate row list needs the MCP-inclusive live stack, which this + // function does not hold (#1990). + let _ = in_tx.send(engine_config_inbound(cfg, None)); + "configuration reloaded — engine, tools, and authority settings re-read from disk.".to_string() +} + /// Re-derive the live [`Config`] from disk after a save, reporting a failure /// to the deck rather than swallowing it. /// diff --git a/scripts/file-size-baseline.txt b/scripts/file-size-baseline.txt index 9da517b13..e71783b84 100644 --- a/scripts/file-size-baseline.txt +++ b/scripts/file-size-baseline.txt @@ -17,7 +17,7 @@ 1659 bench/terminal_bench_analysis/tests/test_tb21_evidence_contract.py 2266 crates/stella-cli/src/agent.rs 1751 crates/stella-cli/src/agent/tests.rs -4556 crates/stella-cli/src/command_deck.rs +4566 crates/stella-cli/src/command_deck.rs 1504 crates/stella-cli/src/fleet_cmd.rs 2126 crates/stella-core/src/bus.rs 2568 crates/stella-core/src/driver.rs From 16045166663000c6374dbab9da985a1bdf78f463 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 20:07:53 -0700 Subject: [PATCH 7/7] docs(stella-cli): name where apply_pending_reload's coherence claim is guaranteed --- crates/stella-cli/src/command_deck/settings_io.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/stella-cli/src/command_deck/settings_io.rs b/crates/stella-cli/src/command_deck/settings_io.rs index 5242eab1c..261c00583 100644 --- a/crates/stella-cli/src/command_deck/settings_io.rs +++ b/crates/stella-cli/src/command_deck/settings_io.rs @@ -156,7 +156,9 @@ pub(super) fn reload_command(cfg: &mut Config, in_tx: &UnboundedSender) /// The one place both call sites converge, so "a save is live" is stated once. /// A failed reload leaves the session on its previous (still coherent) values /// — the files are already written, so a restart picks them up regardless, -/// which is what the note says. +/// which is what the note says. That coherence is not this function's doing: +/// it rests on [`Config::reload_from_disk`] being all-or-nothing, so the note +/// below is only honest while that holds. pub(super) fn apply_pending_reload(cfg: &mut Config, in_tx: &UnboundedSender) { if let Err(e) = cfg.reload_from_disk() { let _ = in_tx.send(super::chrome_note(format!(