Yeachan-Heo преди 2 месеца
родител
ревизия
ddbfcb4be9
променени са 4 файла, в които са добавени 413 реда и са изтрити 31 реда
  1. 273 19
      rust/crates/plugins/src/lib.rs
  2. 1 0
      rust/crates/runtime/Cargo.toml
  3. 126 3
      rust/crates/runtime/src/conversation.rs
  4. 13 9
      rust/crates/rusty-claude-cli/src/main.rs

+ 273 - 19
rust/crates/plugins/src/lib.rs

@@ -72,6 +72,21 @@ impl PluginHooks {
     }
 }
 
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginLifecycle {
+    #[serde(rename = "Init", default)]
+    pub init: Vec<String>,
+    #[serde(rename = "Shutdown", default)]
+    pub shutdown: Vec<String>,
+}
+
+impl PluginLifecycle {
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.init.is_empty() && self.shutdown.is_empty()
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct PluginManifest {
     pub name: String,
@@ -81,6 +96,8 @@ pub struct PluginManifest {
     pub default_enabled: bool,
     #[serde(default)]
     pub hooks: PluginHooks,
+    #[serde(default)]
+    pub lifecycle: PluginLifecycle,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -112,24 +129,30 @@ pub struct InstalledPluginRegistry {
 pub struct BuiltinPlugin {
     metadata: PluginMetadata,
     hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct BundledPlugin {
     metadata: PluginMetadata,
     hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ExternalPlugin {
     metadata: PluginMetadata,
     hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
 }
 
 pub trait Plugin {
     fn metadata(&self) -> &PluginMetadata;
     fn hooks(&self) -> &PluginHooks;
+    fn lifecycle(&self) -> &PluginLifecycle;
     fn validate(&self) -> Result<(), PluginError>;
+    fn initialize(&self) -> Result<(), PluginError>;
+    fn shutdown(&self) -> Result<(), PluginError>;
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -148,9 +171,21 @@ impl Plugin for BuiltinPlugin {
         &self.hooks
     }
 
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
     fn validate(&self) -> Result<(), PluginError> {
         Ok(())
     }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        Ok(())
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        Ok(())
+    }
 }
 
 impl Plugin for BundledPlugin {
@@ -162,8 +197,26 @@ impl Plugin for BundledPlugin {
         &self.hooks
     }
 
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
     fn validate(&self) -> Result<(), PluginError> {
-        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)
+        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+        validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "shutdown",
+            &self.lifecycle.shutdown,
+        )
     }
 }
 
@@ -176,8 +229,26 @@ impl Plugin for ExternalPlugin {
         &self.hooks
     }
 
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
     fn validate(&self) -> Result<(), PluginError> {
-        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)
+        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+        validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "shutdown",
+            &self.lifecycle.shutdown,
+        )
     }
 }
 
@@ -198,6 +269,14 @@ impl Plugin for PluginDefinition {
         }
     }
 
+    fn lifecycle(&self) -> &PluginLifecycle {
+        match self {
+            Self::Builtin(plugin) => plugin.lifecycle(),
+            Self::Bundled(plugin) => plugin.lifecycle(),
+            Self::External(plugin) => plugin.lifecycle(),
+        }
+    }
+
     fn validate(&self) -> Result<(), PluginError> {
         match self {
             Self::Builtin(plugin) => plugin.validate(),
@@ -205,6 +284,22 @@ impl Plugin for PluginDefinition {
             Self::External(plugin) => plugin.validate(),
         }
     }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        match self {
+            Self::Builtin(plugin) => plugin.initialize(),
+            Self::Bundled(plugin) => plugin.initialize(),
+            Self::External(plugin) => plugin.initialize(),
+        }
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        match self {
+            Self::Builtin(plugin) => plugin.shutdown(),
+            Self::Bundled(plugin) => plugin.shutdown(),
+            Self::External(plugin) => plugin.shutdown(),
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -241,6 +336,14 @@ impl RegisteredPlugin {
         self.definition.validate()
     }
 
+    pub fn initialize(&self) -> Result<(), PluginError> {
+        self.definition.initialize()
+    }
+
+    pub fn shutdown(&self) -> Result<(), PluginError> {
+        self.definition.shutdown()
+    }
+
     #[must_use]
     pub fn summary(&self) -> PluginSummary {
         PluginSummary {
@@ -299,6 +402,21 @@ impl PluginRegistry {
                 Ok(acc.merged_with(plugin.hooks()))
             })
     }
+
+    pub fn initialize(&self) -> Result<(), PluginError> {
+        for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
+            plugin.validate()?;
+            plugin.initialize()?;
+        }
+        Ok(())
+    }
+
+    pub fn shutdown(&self) -> Result<(), PluginError> {
+        for plugin in self.plugins.iter().rev().filter(|plugin| plugin.is_enabled()) {
+            plugin.shutdown()?;
+        }
+        Ok(())
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -687,6 +805,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> {
             root: None,
         },
         hooks: PluginHooks::default(),
+        lifecycle: PluginLifecycle::default(),
     })]
 }
 
@@ -708,10 +827,23 @@ fn load_plugin_definition(
         root: Some(root.to_path_buf()),
     };
     let hooks = resolve_hooks(root, &manifest.hooks);
+    let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
     Ok(match kind {
-        PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }),
-        PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }),
-        PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }),
+        PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+        }),
+        PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+        }),
+        PluginKind::External => PluginDefinition::External(ExternalPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+        }),
     })
 }
 
@@ -719,6 +851,7 @@ fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, Plug
     let manifest = load_manifest_from_root(root)?;
     validate_manifest(&manifest)?;
     validate_hook_paths(Some(root), &manifest.hooks)?;
+    validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
     Ok(manifest)
 }
 
@@ -767,25 +900,58 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
     }
 }
 
+fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
+    PluginLifecycle {
+        init: lifecycle
+            .init
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+        shutdown: lifecycle
+            .shutdown
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+    }
+}
+
 fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
     let Some(root) = root else {
         return Ok(());
     };
     for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
-        if is_literal_command(entry) {
-            continue;
-        }
-        let path = if Path::new(entry).is_absolute() {
-            PathBuf::from(entry)
-        } else {
-            root.join(entry)
-        };
-        if !path.exists() {
-            return Err(PluginError::InvalidManifest(format!(
-                "hook path `{}` does not exist",
-                path.display()
-            )));
-        }
+        validate_command_path(root, entry, "hook")?;
+    }
+    Ok(())
+}
+
+fn validate_lifecycle_paths(
+    root: Option<&Path>,
+    lifecycle: &PluginLifecycle,
+) -> Result<(), PluginError> {
+    let Some(root) = root else {
+        return Ok(());
+    };
+    for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
+        validate_command_path(root, entry, "lifecycle command")?;
+    }
+    Ok(())
+}
+
+fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
+    if is_literal_command(entry) {
+        return Ok(());
+    }
+    let path = if Path::new(entry).is_absolute() {
+        PathBuf::from(entry)
+    } else {
+        root.join(entry)
+    };
+    if !path.exists() {
+        return Err(PluginError::InvalidManifest(format!(
+            "{kind} path `{}` does not exist",
+            path.display()
+        )));
     }
     Ok(())
 }
@@ -802,6 +968,48 @@ fn is_literal_command(entry: &str) -> bool {
     !entry.starts_with("./") && !entry.starts_with("../")
 }
 
+fn run_lifecycle_commands(
+    metadata: &PluginMetadata,
+    lifecycle: &PluginLifecycle,
+    phase: &str,
+    commands: &[String],
+) -> Result<(), PluginError> {
+    if lifecycle.is_empty() || commands.is_empty() {
+        return Ok(());
+    }
+
+    for command in commands {
+        let output = if Path::new(command).exists() {
+            if cfg!(windows) {
+                Command::new("cmd").arg("/C").arg(command).output()?
+            } else {
+                Command::new("sh").arg(command).output()?
+            }
+        } else if cfg!(windows) {
+            Command::new("cmd").arg("/C").arg(command).output()?
+        } else {
+            Command::new("sh").arg("-lc").arg(command).output()?
+        };
+
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+            return Err(PluginError::CommandFailed(format!(
+                "plugin `{}` {} failed for `{}`: {}",
+                metadata.id,
+                phase,
+                command,
+                if stderr.is_empty() {
+                    format!("exit status {}", output.status)
+                } else {
+                    stderr
+                }
+            )));
+        }
+    }
+
+    Ok(())
+}
+
 fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
     let path = PathBuf::from(source);
     if path.exists() {
@@ -992,6 +1200,30 @@ mod tests {
         .expect("write broken manifest");
     }
 
+    fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
+        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
+        fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
+        let log_path = root.join("lifecycle.log");
+        fs::write(
+            root.join("lifecycle").join("init.sh"),
+            "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
+        )
+        .expect("write init hook");
+        fs::write(
+            root.join("lifecycle").join("shutdown.sh"),
+            "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
+        )
+        .expect("write shutdown hook");
+        fs::write(
+            root.join(MANIFEST_RELATIVE_PATH),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"lifecycle plugin\",\n  \"lifecycle\": {{\n    \"Init\": [\"./lifecycle/init.sh\"],\n    \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n  }}\n}}"
+            ),
+        )
+        .expect("write manifest");
+        log_path
+    }
+
     #[test]
     fn validates_manifest_shape() {
         let error = validate_manifest(&PluginManifest {
@@ -1127,4 +1359,26 @@ mod tests {
         let _ = fs::remove_dir_all(config_home);
         let _ = fs::remove_dir_all(source_root);
     }
+
+    #[test]
+    fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
+        let config_home = temp_dir("lifecycle-home");
+        let source_root = temp_dir("lifecycle-source");
+        let log_path = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+
+        let registry = manager.plugin_registry().expect("registry should build");
+        registry.initialize().expect("init should succeed");
+        registry.shutdown().expect("shutdown should succeed");
+
+        let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
+        assert_eq!(log, "init\nshutdown\n");
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
 }

+ 1 - 0
rust/crates/runtime/Cargo.toml

@@ -8,6 +8,7 @@ publish.workspace = true
 [dependencies]
 sha2 = "0.10"
 glob = "0.3"
+plugins = { path = "../plugins" }
 regex = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"

+ 126 - 3
rust/crates/runtime/src/conversation.rs

@@ -1,6 +1,8 @@
 use std::collections::BTreeMap;
 use std::fmt::{Display, Formatter};
 
+use plugins::PluginRegistry;
+
 use crate::compact::{
     compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
 };
@@ -107,6 +109,8 @@ pub struct ConversationRuntime<C, T> {
     usage_tracker: UsageTracker,
     hook_runner: HookRunner,
     auto_compaction_input_tokens_threshold: u32,
+    plugin_registry: Option<PluginRegistry>,
+    plugins_shutdown: bool,
 }
 
 impl<C, T> ConversationRuntime<C, T>
@@ -140,7 +144,7 @@ where
         tool_executor: T,
         permission_policy: PermissionPolicy,
         system_prompt: Vec<String>,
-        feature_config: RuntimeFeatureConfig,
+            feature_config: RuntimeFeatureConfig,
     ) -> Self {
         let usage_tracker = UsageTracker::from_session(&session);
         Self {
@@ -153,9 +157,36 @@ where
             usage_tracker,
             hook_runner: HookRunner::from_feature_config(&feature_config),
             auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
+            plugin_registry: None,
+            plugins_shutdown: false,
         }
     }
 
+    #[allow(clippy::needless_pass_by_value)]
+    pub fn new_with_plugins(
+        session: Session,
+        api_client: C,
+        tool_executor: T,
+        permission_policy: PermissionPolicy,
+        system_prompt: Vec<String>,
+        feature_config: RuntimeFeatureConfig,
+        plugin_registry: PluginRegistry,
+    ) -> Result<Self, RuntimeError> {
+        plugin_registry
+            .initialize()
+            .map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
+        let mut runtime = Self::new_with_features(
+            session,
+            api_client,
+            tool_executor,
+            permission_policy,
+            system_prompt,
+            feature_config,
+        );
+        runtime.plugin_registry = Some(plugin_registry);
+        Ok(runtime)
+    }
+
     #[must_use]
     pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
         self.max_iterations = max_iterations;
@@ -304,8 +335,22 @@ where
     }
 
     #[must_use]
-    pub fn into_session(self) -> Session {
-        self.session
+    pub fn into_session(mut self) -> Session {
+        let _ = self.shutdown_plugins();
+        std::mem::take(&mut self.session)
+    }
+
+    pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
+        if self.plugins_shutdown {
+            return Ok(());
+        }
+        if let Some(registry) = &self.plugin_registry {
+            registry
+                .shutdown()
+                .map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
+        }
+        self.plugins_shutdown = true;
+        Ok(())
     }
 
     fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
@@ -334,6 +379,12 @@ where
     }
 }
 
+impl<C, T> Drop for ConversationRuntime<C, T> {
+    fn drop(&mut self) {
+        let _ = self.shutdown_plugins();
+    }
+}
+
 #[must_use]
 pub fn auto_compaction_threshold_from_env() -> u32 {
     parse_auto_compaction_threshold(
@@ -472,7 +523,11 @@ mod tests {
     use crate::prompt::{ProjectContext, SystemPromptBuilder};
     use crate::session::{ContentBlock, MessageRole, Session};
     use crate::usage::TokenUsage;
+    use plugins::{PluginManager, PluginManagerConfig};
+    use std::fs;
+    use std::path::Path;
     use std::path::PathBuf;
+    use std::time::{SystemTime, UNIX_EPOCH};
 
     struct ScriptedApiClient {
         call_count: usize,
@@ -534,6 +589,38 @@ mod tests {
         }
     }
 
+    fn temp_dir(label: &str) -> PathBuf {
+        let nanos = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .expect("time should be after epoch")
+            .as_nanos();
+        std::env::temp_dir().join(format!("runtime-plugin-{label}-{nanos}"))
+    }
+
+    fn write_lifecycle_plugin(root: &Path, name: &str) -> PathBuf {
+        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
+        fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
+        let log_path = root.join("lifecycle.log");
+        fs::write(
+            root.join("lifecycle").join("init.sh"),
+            "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
+        )
+        .expect("write init script");
+        fs::write(
+            root.join("lifecycle").join("shutdown.sh"),
+            "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
+        )
+        .expect("write shutdown script");
+        fs::write(
+            root.join(".claude-plugin").join("plugin.json"),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"1.0.0\",\n  \"description\": \"runtime lifecycle plugin\",\n  \"lifecycle\": {{\n    \"Init\": [\"./lifecycle/init.sh\"],\n    \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n  }}\n}}"
+            ),
+        )
+        .expect("write plugin manifest");
+        log_path
+    }
+
     #[test]
     fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
         let api_client = ScriptedApiClient { call_count: 0 };
@@ -775,6 +862,42 @@ mod tests {
         );
     }
 
+    #[test]
+    fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
+        let config_home = temp_dir("config");
+        let source_root = temp_dir("source");
+        let log_path = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+        let registry = manager.plugin_registry().expect("registry should load");
+
+        {
+            let runtime = ConversationRuntime::new_with_plugins(
+                Session::new(),
+                ScriptedApiClient { call_count: 0 },
+                StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
+                PermissionPolicy::new(PermissionMode::WorkspaceWrite),
+                vec!["system".to_string()],
+                RuntimeFeatureConfig::default(),
+                registry,
+            )
+            .expect("runtime should initialize plugins");
+
+            let log = fs::read_to_string(&log_path).expect("init log should exist");
+            assert_eq!(log, "init\n");
+            drop(runtime);
+        }
+
+        let log = fs::read_to_string(&log_path).expect("shutdown log should exist");
+        assert_eq!(log, "init\nshutdown\n");
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
     #[test]
     fn reconstructs_usage_tracker_from_restored_session() {
         struct SimpleApi;

+ 13 - 9
rust/crates/rusty-claude-cli/src/main.rs

@@ -23,7 +23,7 @@ use commands::{
 };
 use compat_harness::{extract_manifest, UpstreamPaths};
 use init::initialize_repo;
-use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary};
+use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginRegistry, PluginSummary};
 use render::{MarkdownStreamState, Spinner, TerminalRenderer};
 use runtime::{
     clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
@@ -2456,20 +2456,22 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
     )?)
 }
 
-fn build_runtime_feature_config(
-) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> {
+fn build_runtime_plugin_state(
+) -> Result<(runtime::RuntimeFeatureConfig, PluginRegistry), Box<dyn std::error::Error>> {
     let cwd = env::current_dir()?;
     let loader = ConfigLoader::default_for(&cwd);
     let runtime_config = loader.load()?;
     let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
-    let plugin_hooks = plugin_manager.aggregated_hooks()?;
-    Ok(runtime_config
+    let plugin_registry = plugin_manager.plugin_registry()?;
+    let plugin_hooks = plugin_registry.aggregated_hooks()?;
+    let feature_config = runtime_config
         .feature_config()
         .clone()
         .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new(
             plugin_hooks.pre_tool_use,
             plugin_hooks.post_tool_use,
-        ))))
+        )));
+    Ok((feature_config, plugin_registry))
 }
 
 fn build_plugin_manager(
@@ -2519,14 +2521,16 @@ fn build_runtime(
     permission_mode: PermissionMode,
 ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
 {
-    Ok(ConversationRuntime::new_with_features(
+    let (feature_config, plugin_registry) = build_runtime_plugin_state()?;
+    Ok(ConversationRuntime::new_with_plugins(
         session,
         AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?,
         CliToolExecutor::new(allowed_tools, emit_output),
         permission_policy(permission_mode),
         system_prompt,
-        build_runtime_feature_config()?,
-    ))
+        feature_config,
+        plugin_registry,
+    )?)
 }
 
 struct CliPermissionPrompter {