Compare commits

...
1 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 86db50729e feat(plugins): apply installs, enables and MCP adds made by other processes when idle
The plugin catalog stamp now includes a SHA-256 of the plugin state file, so an
install, enable or disable from another terminal is a catalog change by content
rather than by mtime. The registry refreshes its own stamp after its own state
writes, so a session's /plugin enable never looks like an outside change.

The TUI idle poll also digests the configured MCP config file. When a turn is
not running, an outside plugin or MCP change is applied through the existing
PluginRegistryChanged handler (transcript kept via SyncSession) and the
existing MCP reload; the transcript records the applied change and any added or
removed skills, and the engine's existing new-turn context_update appends the
skill delta and declares the tool-surface change, so the pinned prefix is not
spliced. While a turn runs, the toast stays and the change applies at the next
idle poll. A change to an active provider-declaring plugin says provider
changes apply in a new session.

The MCP digest is tracked in the TUI (not inside PluginCatalogStamp) because
the MCP config path is a per-session setting the plugins crate does not know.

Checks: see the review packet; hosted ci.yml on this SHA.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:25:39 -08:00
12 changed files with 283 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -182,7 +182,7 @@ impl PluginDiscoveryContext {
super::discovery::default_workspace_plugins_dir(workspace),
];
roots.extend(self.builtin_plugin_dirs.iter().cloned());
super::discovery::PluginCatalogStamp::capture(roots)
super::discovery::PluginCatalogStamp::capture(roots, &self.state_path)
}
}
+34 -7
View File
@@ -27,20 +27,40 @@ pub struct DiscoveryConfig {
pub state_path: PathBuf,
}
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
/// content of the plugin state file.
///
/// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
/// rewritten without consulting process environment or auto-applying trust.
/// `state.json` writes are ignored so enable/trust does not look like a
/// catalog change.
/// Used to apply or nudge a reload when a bundle appears, disappears, or is
/// rewritten, or when another process installs, enables or disables a plugin.
/// The state file is compared by content digest, never by mtime, so a rewrite
/// with identical bytes is not a change and a same-second rewrite is.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PluginCatalogStamp {
entries: BTreeMap<PathBuf, Option<SystemTime>>,
state: Option<[u8; 32]>,
}
const MAX_STAMPED_FILE_BYTES: u64 = 8 * 1024 * 1024;
/// SHA-256 of a small file's bytes, or `None` when it is absent, unreadable or
/// larger than the stamp bound.
#[must_use]
pub fn file_content_digest(path: &Path) -> Option<[u8; 32]> {
use std::io::Read;
let file = fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_STAMPED_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_STAMPED_FILE_BYTES {
return None;
}
Some(Sha256::digest(&bytes).into())
}
impl PluginCatalogStamp {
#[must_use]
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
pub fn capture(roots: impl IntoIterator<Item = PathBuf>, state_path: &Path) -> Self {
let mut entries = BTreeMap::new();
for root in roots {
let Ok(read) = fs::read_dir(&root) else {
@@ -57,7 +77,14 @@ impl PluginCatalogStamp {
entries.insert(path, metadata.modified().ok());
}
}
Self { entries }
Self {
entries,
state: file_content_digest(state_path),
}
}
pub fn refresh_state(&mut self, state_path: &Path) {
self.state = file_content_digest(state_path);
}
}
+31 -1
View File
@@ -27,11 +27,41 @@ pub mod test_fixture;
#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
pub use context::{HostEnvironment, PluginDiscoveryContext};
pub use discovery::PluginCatalogStamp;
pub use registry::PluginRegistry;
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
pub const PLUGIN_RELOAD_NUDGE: &str =
"Plugins changed on disk. They apply when the session is idle, or run /plugin reload.";
pub const PLUGIN_PROVIDER_NEW_SESSION_NOTICE: &str =
"Plugin provider changes apply in a new session.";
fn active_provider_declarations(
registry: &PluginRegistry,
) -> BTreeMap<String, (String, Vec<String>)> {
registry
.active_plugins()
.into_iter()
.filter(|plugin| !plugin.manifest.providers.is_empty())
.map(|plugin| {
(
plugin.id.as_str().to_string(),
(
plugin.content_hash.clone(),
plugin.manifest.providers.keys().cloned().collect(),
),
)
})
.collect()
}
#[must_use]
pub fn provider_declarations_changed(before: &PluginRegistry, after: &PluginRegistry) -> bool {
active_provider_declarations(before) != active_provider_declarations(after)
}
/// Returns the reload nudge once per distinct on-disk catalog stamp.
#[must_use]
+1
View File
@@ -1326,6 +1326,7 @@ impl PluginRegistry {
let mut next = load_state_unlocked(path)?;
mutate(&mut next)?;
save_state(path, &next)?;
self.catalog_stamp.refresh_state(path);
self.state = next;
// Runtime selection rechecks the persisted review through its own
// read lock. Publish the completed state transaction before deriving
+20
View File
@@ -35,6 +35,26 @@ fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> Path
plugin
}
#[test]
fn catalog_stamp_changes_when_only_state_bytes_change() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(&config, "");
let registry = discover_with_config(&config);
assert!(!registry.on_disk_catalog_changed());
std::fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(registry.on_disk_catalog_changed());
let reloaded = registry.rediscover_for_workspace(&config.workspace);
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":2}").unwrap();
assert!(reloaded.on_disk_catalog_changed());
}
#[test]
fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
let tmp = tempfile::tempdir().unwrap();
+1
View File
@@ -1736,6 +1736,7 @@ pub struct App {
pub plugin_reload_nudge_stamp: Option<codewhale_plugins::PluginCatalogStamp>,
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
pub last_plugin_catalog_poll: Option<Instant>,
pub mcp_config_watch: Option<crate::tui::plugin_suggestions::McpConfigWatch>,
/// Live composer plugin CTA (debounce + one match, never auto-install).
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
pub model: String,
+1
View File
@@ -858,6 +858,7 @@ impl App {
context_pressure_warning_dismissed: None,
plugin_reload_nudge_stamp: None,
last_plugin_catalog_poll: None,
mcp_config_watch: None,
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
model,
provider_models,
+107 -11
View File
@@ -31,6 +31,22 @@ use codewhale_plugins::recommend::{
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub const MCP_CONFIG_NUDGE: &str =
"MCP servers changed on disk. They apply when the session is idle, or run /mcp reload.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExternalCatalogChange {
pub plugins: bool,
pub mcp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfigWatch {
pub digest: Option<[u8; 32]>,
pub generation: u64,
pub notified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginCtaPhase {
Hidden,
@@ -124,24 +140,63 @@ impl App {
true
}
/// Cheap idle poll so on-disk plugin changes can surface between turns,
/// not only on send. Fingerprints directories; never auto-reloads.
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
pub fn poll_external_catalog_changes(
&mut self,
engine_busy: bool,
) -> Option<ExternalCatalogChange> {
let now = Instant::now();
if self
.last_plugin_catalog_poll
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
{
return;
return None;
}
self.last_plugin_catalog_poll = Some(now);
if let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
) {
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
let digest = codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path);
let generation = self.mcp_snapshot_generation;
let watch = self.mcp_config_watch.get_or_insert(McpConfigWatch {
digest,
generation,
notified: false,
});
if watch.generation != generation {
*watch = McpConfigWatch {
digest,
generation,
notified: false,
};
}
let mcp = watch.digest != digest;
let mcp_unannounced = mcp && !std::mem::replace(&mut watch.notified, true);
let plugins = self.plugin_registry.on_disk_catalog_changed();
if !plugins && !mcp {
return None;
}
if engine_busy || self.pending_fresh_session.is_some() {
if plugins
&& let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
)
{
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
if mcp_unannounced {
self.push_status_toast(MCP_CONFIG_NUDGE, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
return None;
}
Some(ExternalCatalogChange { plugins, mcp })
}
pub fn mark_external_catalog_applied(&mut self) {
self.mcp_config_watch = Some(McpConfigWatch {
digest: codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path),
generation: self.mcp_snapshot_generation,
notified: false,
});
}
#[must_use]
@@ -417,12 +472,53 @@ mod tests {
);
}
#[test]
fn external_state_and_mcp_writes_apply_when_idle_and_toast_while_busy() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
assert_eq!(app.poll_external_catalog_changes(false), None);
let state_path = app.plugin_registry.state_path().unwrap().to_path_buf();
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
fs::write(&state_path, "{\"version\":1,\"plugins\":{}}").unwrap();
fs::write(&app.mcp_config_path, "{\"servers\":{}}").unwrap();
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(true), None);
let toasts = app
.status_toasts
.iter()
.map(|toast| toast.text.to_string())
.collect::<Vec<_>>();
assert!(toasts.iter().any(|text| text.contains("Plugins changed")));
assert!(
toasts
.iter()
.any(|text| text.contains("MCP servers changed"))
);
app.last_plugin_catalog_poll = None;
assert_eq!(
app.poll_external_catalog_changes(false),
Some(ExternalCatalogChange {
plugins: true,
mcp: true
})
);
app.mark_external_catalog_applied();
app.plugin_registry = app
.plugin_registry
.rediscover_for_workspace(&app.workspace.clone());
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(false), None);
}
#[test]
fn typing_a_matching_draft_never_shows_a_row() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
app.input = "add supabase auth to login".to_string();
app.maybe_poll_plugin_catalog_idle();
let _ = app.poll_external_catalog_changes(false);
assert!(!app.plugin_cta.phase.is_visible());
assert_eq!(app.plugin_cta_row_height(), 0);
}
+2
View File
@@ -981,7 +981,9 @@ pub(crate) use compaction_flow::*;
pub(crate) use provider_setup::*;
mod dispatch;
mod dispatch_prepare;
mod external_catalog;
pub(crate) use dispatch_prepare::*;
pub(crate) use external_catalog::*;
pub(crate) mod fatal_signal_guard;
// #6169: runtime half of the foreground-ownership contract — restore on stop,
// rebuild on continue. Sits next to the fatal guard because both write the same
+11 -1
View File
@@ -2640,7 +2640,17 @@ pub(crate) async fn run_event_loop(
// #1830/#2317: service any already-arrived terminal keys before a
// potentially long engine batch so composer/modal input stays live.
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
app.maybe_poll_plugin_catalog_idle();
if let Some(change) = app.poll_external_catalog_changes(mcp_engine_busy(app)) {
apply_external_catalog_change(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
change,
)
.await?;
}
if drain_remote_control_events(app, config, &engine_handle).await? {
app.needs_redraw = true;
+73
View File
@@ -0,0 +1,73 @@
use super::*;
use crate::tui::plugin_suggestions::ExternalCatalogChange;
fn skill_delta(before: &[(String, String)], after: &[(String, String)]) -> String {
let names = |skills: &[(String, String)]| {
skills
.iter()
.map(|(name, _)| name.clone())
.collect::<std::collections::BTreeSet<_>>()
};
let (before, after) = (names(before), names(after));
let added = after.difference(&before).cloned().collect::<Vec<_>>();
let removed = before.difference(&after).cloned().collect::<Vec<_>>();
let mut parts = Vec::new();
if !added.is_empty() {
parts.push(format!("skills added: {}", added.join(", ")));
}
if !removed.is_empty() {
parts.push(format!("skills removed: {}", removed.join(", ")));
}
parts.join("; ")
}
pub(crate) async fn apply_external_catalog_change(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
change: ExternalCatalogChange,
) -> Result<()> {
app.mark_external_catalog_applied();
if change.plugins {
let before = app.plugin_registry.clone();
let skills_before = app.cached_skills.clone();
let workspace = app.workspace.clone();
app.plugin_registry = before.rediscover_for_workspace(&workspace);
app.refresh_skill_cache();
let mut message = String::from("Plugins changed outside this session and were applied");
let delta = skill_delta(&skills_before, &app.cached_skills);
if !delta.is_empty() {
message.push_str(&format!(" ({delta})"));
}
message.push('.');
if codewhale_plugins::provider_declarations_changed(
before.as_ref(),
app.plugin_registry.as_ref(),
) {
message.push(' ');
message.push_str(codewhale_plugins::PLUGIN_PROVIDER_NEW_SESSION_NOTICE);
}
let result = commands::CommandResult::with_message_and_action(
message,
AppAction::PluginRegistryChanged,
);
apply_command_result(terminal, app, engine_handle, task_manager, config, result).await?;
}
if change.mcp {
let extensions_was_open = app.view_stack.extensions_is_top();
handle_mcp_ui_action(
app,
engine_handle,
config,
crate::tui::app::McpUiAction::Reload,
)
.await;
if !extensions_was_open && app.view_stack.extensions_is_top() {
app.view_stack.pop();
}
}
app.needs_redraw = true;
Ok(())
}
+1 -1
View File
@@ -740,7 +740,7 @@ fn mcp_scoped_config_path(
/// Whether a turn (or its compaction work) owns the engine. The engine
/// services ops only between turns, so an op sent now waits for that turn.
fn mcp_engine_busy(app: &App) -> bool {
pub(super) fn mcp_engine_busy(app: &App) -> bool {
app.is_loading
|| app.dispatch_in_flight
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))