feat: plugin subsystem progress

This commit is contained in:
Yeachan-Heo
2026-04-01 06:50:18 +00:00
parent 4769353b30
commit bea025b585
9 changed files with 1173 additions and 276 deletions

View File

@@ -1,13 +1,13 @@
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use plugins::PluginRegistry;
use plugins::{HookRunner as PluginHookRunner, PluginRegistry};
use crate::compact::{
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
};
use crate::config::RuntimeFeatureConfig;
use crate::hooks::{HookRunResult, HookRunner};
use crate::hooks::HookRunner;
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::usage::{TokenUsage, UsageTracker};
@@ -99,6 +99,7 @@ pub struct ConversationRuntime<C, T> {
max_iterations: usize,
usage_tracker: UsageTracker,
hook_runner: HookRunner,
plugin_hook_runner: Option<PluginHookRunner>,
plugin_registry: Option<PluginRegistry>,
plugins_shutdown: bool,
}
@@ -161,6 +162,7 @@ where
max_iterations: usize::MAX,
usage_tracker,
hook_runner: HookRunner::from_feature_config(&feature_config),
plugin_hook_runner: None,
plugin_registry: None,
plugins_shutdown: false,
}
@@ -176,11 +178,8 @@ where
feature_config: RuntimeFeatureConfig,
plugin_registry: PluginRegistry,
) -> Result<Self, RuntimeError> {
let hook_runner =
HookRunner::from_feature_config_and_plugins(&feature_config, &plugin_registry)
.map_err(|error| {
RuntimeError::new(format!("plugin hook registration failed: {error}"))
})?;
let plugin_hook_runner = PluginHookRunner::from_registry(&plugin_registry)
.map_err(|error| RuntimeError::new(format!("plugin hook registration failed: {error}")))?;
plugin_registry
.initialize()
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
@@ -192,7 +191,7 @@ where
system_prompt,
feature_config,
);
runtime.hook_runner = hook_runner;
runtime.plugin_hook_runner = Some(plugin_hook_runner);
runtime.plugin_registry = Some(plugin_registry);
Ok(runtime)
}
@@ -267,16 +266,36 @@ where
ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(&pre_hook_result, &deny_message),
format_hook_message(pre_hook_result.messages(), &deny_message),
true,
)
} else {
let plugin_pre_hook_result =
self.run_plugin_pre_tool_use(&tool_name, &input);
if plugin_pre_hook_result.is_denied() {
let deny_message =
format!("PreToolUse hook denied tool `{tool_name}`");
ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(
plugin_pre_hook_result.messages(),
&deny_message,
),
true,
)
} else {
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &input) {
Ok(output) => (output, false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
output = merge_hook_feedback(
plugin_pre_hook_result.messages(),
output,
false,
);
let post_hook_result = self
.hook_runner
@@ -289,6 +308,16 @@ where
output,
post_hook_result.is_denied(),
);
let plugin_post_hook_result =
self.run_plugin_post_tool_use(&tool_name, &input, &output, is_error);
if plugin_post_hook_result.is_denied() {
is_error = true;
}
output = merge_hook_feedback(
plugin_post_hook_result.messages(),
output,
plugin_post_hook_result.is_denied(),
);
ConversationMessage::tool_result(
tool_use_id,
@@ -296,6 +325,7 @@ where
output,
is_error,
)
}
}
}
PermissionOutcome::Deny { reason } => {
@@ -344,6 +374,26 @@ where
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
self.shutdown_registered_plugins()
}
fn run_plugin_pre_tool_use(&self, tool_name: &str, input: &str) -> plugins::HookRunResult {
self.plugin_hook_runner.as_ref().map_or_else(
|| plugins::HookRunResult::allow(Vec::new()),
|runner| runner.run_pre_tool_use(tool_name, input),
)
}
fn run_plugin_post_tool_use(
&self,
tool_name: &str,
input: &str,
output: &str,
is_error: bool,
) -> plugins::HookRunResult {
self.plugin_hook_runner.as_ref().map_or_else(
|| plugins::HookRunResult::allow(Vec::new()),
|runner| runner.run_post_tool_use(tool_name, input, output, is_error),
)
}
}
impl<C, T> Drop for ConversationRuntime<C, T> {

View File

@@ -2,7 +2,6 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use plugins::{PluginError, PluginRegistry};
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
@@ -64,19 +63,6 @@ impl HookRunner {
Self::new(feature_config.hooks().clone())
}
pub fn from_feature_config_and_plugins(
feature_config: &RuntimeFeatureConfig,
plugin_registry: &PluginRegistry,
) -> Result<Self, PluginError> {
let mut config = feature_config.hooks().clone();
let plugin_hooks = plugin_registry.aggregated_hooks()?;
config.extend(&RuntimeHookConfig::new(
plugin_hooks.pre_tool_use,
plugin_hooks.post_tool_use,
));
Ok(Self::new(config))
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
@@ -313,50 +299,6 @@ impl CommandWithStdin {
mod tests {
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use plugins::{PluginManager, PluginManagerConfig};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
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!("hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
"#!/bin/sh\nprintf 'plugin pre'\n",
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
"#!/bin/sh\nprintf 'plugin post'\n",
)
.expect("write post hook");
#[cfg(unix)]
{
let exec_mode = fs::Permissions::from_mode(0o755);
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
.expect("chmod pre hook");
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
.expect("chmod post hook");
}
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
@@ -401,40 +343,6 @@ mod tests {
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
fn collects_hooks_from_enabled_plugins() {
let config_home = temp_dir("config");
let source_root = temp_dir("source");
write_hook_plugin(&source_root, "hooked");
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");
let runner = HookRunner::from_feature_config_and_plugins(
&RuntimeFeatureConfig::default(),
&registry,
)
.expect("plugin hooks should load");
let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false);
assert_eq!(
pre_result,
HookRunResult::allow(vec!["plugin pre".to_string()])
);
assert_eq!(
post_result,
HookRunResult::allow(vec!["plugin post".to_string()])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")