diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index 784277c24..691234133 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::{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. @@ -336,7 +338,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, @@ -864,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()); @@ -1329,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, @@ -1347,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 @@ -1360,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; } @@ -1976,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 @@ -1989,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, @@ -2031,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 { @@ -3676,48 +3710,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: &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) { - Ok(()) => format!( - "saved to {} — applies to runs started from now on", - 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 @@ -3763,54 +3755,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: &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) { - Ok(status) => status, - 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) — @@ -3935,7 +3879,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 +4005,7 @@ async fn run_deck_command( Ok(Err(e)) | Err(e) => say(format!("export 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 new file mode 100644 index 000000000..261c00583 --- /dev/null +++ b/crates/stella-cli/src/command_deck/settings_io.rs @@ -0,0 +1,168 @@ +//! 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. +//! +//! **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; + +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. +/// +/// `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: &Config, + stale: &mut bool, + 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) { + Ok(()) => { + *stale = true; + format!( + "saved to {} — applies to runs started from now on", + 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. `stale` carries +/// the reload the caller owes — see the module docs. +pub(super) fn handle_tools_input( + input: &WorkspaceInput, + cfg: &Config, + names: &[String], + stale: &mut bool, + 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) { + Ok(status) => { + *stale = true; + status + } + Err(e) => format!("save failed: {e}"), + } + } + }; + let _ = in_tx.send(tool_policy_inbound(cfg, names, Some(status))); + true + } + _ => false, + } +} + +/// 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. +/// +/// 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. 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!( + "settings saved, but reloading them failed: {e} — restart to pick them up." + ))); + } +} 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 c7bef80a0..3284fc833 100644 --- a/crates/stella-cli/src/config.rs +++ b/crates/stella-cli/src/config.rs @@ -133,6 +133,7 @@ fn interactive_allowed() -> bool { mod aux; mod listing; 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. diff --git a/crates/stella-cli/src/config/reload.rs b/crates/stella-cli/src/config/reload.rs new file mode 100644 index 000000000..a13f50cdc --- /dev/null +++ b/crates/stella-cli/src/config/reload.rs @@ -0,0 +1,76 @@ +//! 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. + /// + /// **All-or-nothing.** A failure leaves `self` byte-for-byte as it was, so + /// the reported error and the session's actual posture agree. That is not + /// a nicety here: the callers + /// (`command_deck::settings_io::{reload_command, apply_pending_reload}`) + /// both tell the user a failed reload kept the previous values and a + /// restart will pick the files up, and a live agent turn reads these + /// fields — a half-applied reload would run the *next* turn under a tool + /// policy from disk and an authority from session start, a combination no + /// scope chain ever produced. + pub fn reload_from_disk(&mut self) -> Result<(), String> { + // Phase 1 — derive everything, touching no field of `self`. Every + // fallible call belongs above the commit block; a new one added below + // it reintroduces the partial-update bug this split exists to prevent. + 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); + } + let 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, + ) + }; + let tool_policy = settings.tool_policy(); + let enable_recap = settings.recap_enabled(); + let trace_capture = settings.trace_capture_enabled(); + let reward_policy = settings.reward_policy()?; + let create_worktrees = settings.create_worktrees(); + let hooks = if crate::enterprise_telemetry::process_free_authority_active() { + None + } else { + settings.hooks.clone() + }; + + // Phase 2 — commit. Infallible from here down. + self.engine_settings = engine_settings; + self.engine_settings_trusted = engine_is_trusted; + self.authority = settings.authority_policy; + self.tool_policy = tool_policy; + self.enable_recap = enable_recap; + self.trace_capture = trace_capture; + self.reward_policy = reward_policy; + self.create_worktrees = create_worktrees; + self.hooks = hooks; + Ok(()) + } +} diff --git a/crates/stella-cli/src/config/tests.rs b/crates/stella-cli/src/config/tests.rs index e2fc27946..02ebcd237 100644 --- a/crates/stella-cli/src/config/tests.rs +++ b/crates/stella-cli/src/config/tests.rs @@ -265,6 +265,138 @@ fn resolved_config_carries_the_authority_computed_during_settings_load() { assert_eq!(cfg.authority, authority); } +/// A redirected user home plus a `Config` whose reloadable fields all sit at +/// their defaults, so any one of them moving is visible to a witness. +/// +/// 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 the test reads their actual +/// `~/.stella/settings.json`. +/// +/// `tag` keeps concurrent tests off each other's directory. The returned guard +/// must be held for as long as the `Config` is used — dropping it restores the +/// real home mid-test. +fn reload_fixture(tag: &str) -> (std::path::PathBuf, crate::paths::TestPathsGuard, Config) { + let home = std::env::temp_dir().join(format!("stella-test-{tag}-{}", std::process::id())); + let workspace = home.join("ws"); + std::fs::create_dir_all(home.join(".stella")).unwrap(); + std::fs::create_dir_all(&workspace).unwrap(); + let paths = crate::paths::test_user_home(home.clone()); + + let 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, + cache_ttl: 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" + ); + assert!(!cfg.enable_recap, "premise: recap starts off"); + (home, paths, cfg) +} + +/// 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() { + // `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 (home, _paths, mut cfg) = reload_fixture("reload-from-disk"); + + // The edit a running session would previously only see after a restart. + std::fs::write( + home.join(".stella").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" + ); + + let _ = std::fs::remove_dir_all(&home); +} + +/// Witness for the all-or-nothing half of `Config::reload_from_disk`: a scope +/// chain that loads but does not *resolve* leaves the live `Config` exactly as +/// it was. +/// +/// The settings file below is well-formed — it parses, and every switch in it +/// is individually legal — but `verifier_weight: 2.0` puts the judged weight +/// above the deterministic one, which `reward_policy()` refuses by name rather +/// than clamping. That is the only fallible step downstream of the load, so it +/// is the lever that separates "derive, then commit" from "assign as you go": +/// with the assignments interleaved, `enable_recap` and the `bash` switch are +/// already written by the time the reward weights are rejected, and the +/// session runs its next turn on a posture no scope chain ever produced — +/// while both callers in `command_deck::settings_io` tell the user the reload +/// failed and the previous values were kept. +#[test] +fn a_failed_reload_leaves_every_field_untouched() { + let _env = crate::test_env::lock(); + let (home, _paths, mut cfg) = reload_fixture("reload-atomicity"); + + std::fs::write( + home.join(".stella").join("settings.json"), + r#"{"enable_recap": "on", "tools": {"bash": "off"}, "reward": {"verifier_weight": 2.0}}"#, + ) + .unwrap(); + + let error = cfg + .reload_from_disk() + .expect_err("a verifier outranking a test must not resolve"); + assert!(error.contains("verifier_weight"), "{error}"); + + assert!( + !cfg.enable_recap, + "a failed reload must not leave the recap toggle applied — the callers \ + report the previous values were kept" + ); + assert!( + cfg.tool_policy.allows("bash"), + "a failed reload must not leave the tool switches applied — a turn \ + would run under a policy the operator was told was not adopted" + ); + + let _ = std::fs::remove_dir_all(&home); +} + /// 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. diff --git a/crates/stella-cli/src/main.rs b/crates/stella-cli/src/main.rs index b9b4e3c40..bb02a0c11 100644 --- a/crates/stella-cli/src/main.rs +++ b/crates/stella-cli/src/main.rs @@ -1196,7 +1196,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, @@ -1246,7 +1246,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), 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.