From 5d8e131c14f6df3d9831ce1ca33c74f7dc784c01 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 2 Apr 2026 10:04:54 +0000 Subject: [PATCH] Wire plugin hooks and lifecycle into runtime startup PARITY.md is stale relative to the current Rust plugin pipeline: plugin manifests, tool loading, and lifecycle primitives already exist, but runtime construction only consumed plugin tools. This change routes enabled plugin hooks into the runtime feature config, initializes plugin lifecycle commands when a runtime is built, and shuts plugins down when runtimes are replaced or dropped.\n\nThe test coverage exercises the new runtime plugin-state builder and verifies init/shutdown execution without relying on global cwd or config-home mutation, so the existing CLI suite stays stable under parallel execution.\n\nConstraint: Keep the change inside the current worktree and avoid touching unrelated pre-existing edits\nRejected: Add plugin hook execution inside the tools crate directly | runtime feature merging is the existing execution boundary\nRejected: Use process-global CLAW_CONFIG_HOME/current_dir in tests | races with the existing parallel CLI test suite\nConfidence: high\nScope-risk: moderate\nReversibility: clean\nDirective: Preserve plugin runtime shutdown when rebuilding LiveCli runtimes or temporary turn runtimes\nTested: cargo test -p rusty-claude-cli build_runtime_\nTested: cargo test -p rusty-claude-cli\nNot-tested: End-to-end live REPL session with a real plugin outside the test harness --- rust/crates/rusty-claude-cli/src/main.rs | 336 ++++++++++++++++++++--- 1 file changed, 305 insertions(+), 31 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f23fa60..878607b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,4 +1,11 @@ -#![allow(dead_code, unused_imports, unused_variables, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self)] +#![allow( + dead_code, + unused_imports, + unused_variables, + clippy::unneeded_struct_pattern, + clippy::unnecessary_wraps, + clippy::unused_self +)] mod init; mod input; mod render; @@ -8,6 +15,7 @@ use std::env; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; @@ -28,7 +36,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginManager, PluginManagerConfig}; +use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -1475,10 +1483,76 @@ struct LiveCli { allowed_tools: Option, permission_mode: PermissionMode, system_prompt: Vec, - runtime: ConversationRuntime, + runtime: BuiltRuntime, session: SessionHandle, } +struct RuntimePluginState { + feature_config: runtime::RuntimeFeatureConfig, + tool_registry: GlobalToolRegistry, + plugin_registry: PluginRegistry, +} + +struct BuiltRuntime { + runtime: Option>, + plugin_registry: PluginRegistry, + plugins_active: bool, +} + +impl BuiltRuntime { + fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + } + } + + fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_plugins(); + } +} + struct HookAbortMonitor { stop_tx: Option>, join_handle: Option>, @@ -1625,13 +1699,7 @@ impl LiveCli { fn prepare_turn_runtime( &self, emit_output: bool, - ) -> Result< - ( - ConversationRuntime, - HookAbortMonitor, - ), - Box, - > { + ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { let hook_abort_signal = runtime::HookAbortSignal::new(); let runtime = build_runtime( self.runtime.session().clone(), @@ -1650,6 +1718,12 @@ impl LiveCli { Ok((runtime, hook_abort_monitor)) } + fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box> { + self.runtime.shutdown_plugins()?; + self.runtime = runtime; + Ok(()) + } + fn run_turn(&mut self, input: &str) -> Result<(), Box> { let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let mut spinner = Spinner::new(); @@ -1662,9 +1736,9 @@ impl LiveCli { let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); - self.runtime = runtime; match result { Ok(summary) => { + self.replace_runtime(runtime)?; spinner.finish( "✨ Done", TerminalRenderer::new().color_theme(), @@ -1681,6 +1755,7 @@ impl LiveCli { Ok(()) } Err(error) => { + runtime.shutdown_plugins()?; spinner.fail( "❌ Request failed", TerminalRenderer::new().color_theme(), @@ -1708,7 +1783,7 @@ impl LiveCli { let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); let summary = result?; - self.runtime = runtime; + self.replace_runtime(runtime)?; self.persist_session()?; println!( "{}", @@ -1903,7 +1978,7 @@ impl LiveCli { let previous = self.model.clone(); let session = self.runtime.session().clone(); let message_count = session.messages.len(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &self.session.id, model.clone(), @@ -1914,6 +1989,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.model.clone_from(&model); println!( "{}", @@ -1948,7 +2024,7 @@ impl LiveCli { let previous = self.permission_mode.as_str().to_string(); let session = self.runtime.session().clone(); self.permission_mode = permission_mode_from_label(normalized); - self.runtime = build_runtime( + let runtime = build_runtime( session, &self.session.id, self.model.clone(), @@ -1959,6 +2035,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; println!( "{}", format_permissions_switch_report(&previous, normalized) @@ -1976,7 +2053,7 @@ impl LiveCli { let session_state = Session::new(); self.session = create_managed_session_handle(&session_state.session_id)?; - self.runtime = build_runtime( + let runtime = build_runtime( session_state.with_persistence_path(self.session.path.clone()), &self.session.id, self.model.clone(), @@ -1987,6 +2064,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, @@ -2014,7 +2092,7 @@ impl LiveCli { let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &handle.id, self.model.clone(), @@ -2025,6 +2103,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = SessionHandle { id: session_id, path: handle.path, @@ -2104,7 +2183,7 @@ impl LiveCli { let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &handle.id, self.model.clone(), @@ -2115,6 +2194,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = SessionHandle { id: session_id, path: handle.path, @@ -2138,7 +2218,7 @@ impl LiveCli { let forked = forked.with_persistence_path(handle.path.clone()); let message_count = forked.messages.len(); forked.save_to_path(&handle.path)?; - self.runtime = build_runtime( + let runtime = build_runtime( forked, &handle.id, self.model.clone(), @@ -2149,6 +2229,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = handle; println!( "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", @@ -2187,7 +2268,7 @@ impl LiveCli { } fn reload_runtime_features(&mut self) -> Result<(), Box> { - self.runtime = build_runtime( + let runtime = build_runtime( self.runtime.session().clone(), &self.session.id, self.model.clone(), @@ -2198,6 +2279,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.persist_session() } @@ -2206,7 +2288,7 @@ impl LiveCli { let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); let skipped = removed == 0; - self.runtime = build_runtime( + let runtime = build_runtime( result.compacted_session, &self.session.id, self.model.clone(), @@ -2217,6 +2299,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) @@ -2242,7 +2325,9 @@ impl LiveCli { )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; - Ok(final_assistant_text(&summary).trim().to_string()) + let text = final_assistant_text(&summary).trim().to_string(); + runtime.shutdown_plugins()?; + Ok(text) } fn run_internal_prompt_text( @@ -3270,14 +3355,32 @@ fn build_system_prompt() -> Result, Box> { )?) } -fn build_runtime_plugin_state( -) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box> { +fn build_runtime_plugin_state() -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?; - Ok((runtime_config.feature_config().clone(), tool_registry)) + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + }) } fn build_plugin_manager( @@ -3316,6 +3419,14 @@ fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { } } +fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { + runtime::RuntimeHookConfig::new( + hooks.pre_tool_use, + hooks.post_tool_use, + hooks.post_tool_use_failure, + ) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct InternalPromptProgressState { command_label: &'static str, @@ -3656,9 +3767,42 @@ fn build_runtime( allowed_tools: Option, permission_mode: PermissionMode, progress_reporter: Option, -) -> Result, Box> -{ - let (feature_config, tool_registry) = build_runtime_plugin_state()?; +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +fn build_runtime_with_plugin_state( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + let RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + } = runtime_plugin_state; + plugin_registry.initialize()?; let mut runtime = ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new( @@ -3679,7 +3823,7 @@ fn build_runtime( if emit_output { runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); } - Ok(runtime) + Ok(BuiltRuntime::new(runtime, plugin_registry)) } struct CliHookProgressReporter; @@ -4847,6 +4991,7 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ + build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state, create_managed_session_handle, describe_tool_progress, filter_tool_specs, format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report, format_compact_report, format_cost_report, format_internal_prompt_progress_line, @@ -4865,9 +5010,12 @@ mod tests { InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; - use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; + use plugins::{ + PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission, + }; use runtime::{ - AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session, + AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, + PermissionMode, Session, }; use serde_json::json; use std::fs; @@ -4936,6 +5084,49 @@ mod tests { std::env::set_current_dir(previous).expect("cwd should restore"); result } + + fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + if include_hooks { + fs::create_dir_all(root.join("hooks")).expect("hooks dir"); + fs::write( + root.join("hooks").join("pre.sh"), + "#!/bin/sh\nprintf 'plugin pre hook'\n", + ) + .expect("write hook"); + } + if include_lifecycle { + fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); + fs::write( + root.join("lifecycle").join("init.sh"), + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", + ) + .expect("write init lifecycle"); + fs::write( + root.join("lifecycle").join("shutdown.sh"), + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", + ) + .expect("write shutdown lifecycle"); + } + + let hooks = if include_hooks { + ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" + } else { + "" + }; + let lifecycle = if include_lifecycle { + ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" + } else { + "" + }; + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}" + ), + ) + .expect("write plugin manifest"); + } #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -6384,6 +6575,89 @@ UU conflicted.rs", )); assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); } + + #[test] + fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let pre_hooks = state.feature_config.hooks().pre_tool_use(); + assert_eq!(pre_hooks.len(), 1); + assert!( + pre_hooks[0].ends_with("hooks/pre.sh"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + + #[test] + fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + let install = manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let log_path = install.install_path.join("lifecycle.log"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let runtime_plugin_state = + build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let mut runtime = build_runtime_with_plugin_state( + Session::new(), + "runtime-plugin-lifecycle", + DEFAULT_MODEL.to_string(), + vec!["test system prompt".to_string()], + true, + false, + None, + PermissionMode::DangerFullAccess, + None, + runtime_plugin_state, + ) + .expect("runtime should build"); + + assert_eq!( + fs::read_to_string(&log_path).expect("init log should exist"), + "init\n" + ); + + runtime + .shutdown_plugins() + .expect("plugin shutdown should succeed"); + + assert_eq!( + fs::read_to_string(&log_path).expect("shutdown log should exist"), + "init\nshutdown\n" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } } #[cfg(test)]