merge: clawcode-issue-9405-plugins-execution-pipeline into main

This commit is contained in:
Yeachan-Heo
2026-04-02 13:55:42 +00:00

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)]