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
This commit is contained in:
Yeachan-Heo
2026-04-02 10:04:54 +00:00
parent 93d98ab33f
commit 5d8e131c14

View File

@@ -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<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
runtime: BuiltRuntime,
session: SessionHandle,
}
struct RuntimePluginState {
feature_config: runtime::RuntimeFeatureConfig,
tool_registry: GlobalToolRegistry,
plugin_registry: PluginRegistry,
}
struct BuiltRuntime {
runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
plugin_registry: PluginRegistry,
plugins_active: bool,
}
impl BuiltRuntime {
fn new(
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
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<dyn std::error::Error>> {
if self.plugins_active {
self.plugin_registry.shutdown()?;
self.plugins_active = false;
}
Ok(())
}
}
impl Deref for BuiltRuntime {
type Target = ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>;
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<Sender<()>>,
join_handle: Option<JoinHandle<()>>,
@@ -1625,13 +1699,7 @@ impl LiveCli {
fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<
(
ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
HookAbortMonitor,
),
Box<dyn std::error::Error>,
> {
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
self.runtime.shutdown_plugins()?;
self.runtime = runtime;
Ok(())
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<String>, Box<dyn std::error::Error>> {
)?)
}
fn build_runtime_plugin_state(
) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> {
fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
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<RuntimePluginState, Box<dyn std::error::Error>> {
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<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
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<String>,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
runtime_plugin_state: RuntimePluginState,
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
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)]