Skip to content
Merged
12 changes: 6 additions & 6 deletions FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Auth plugins | ✅ | ❌ | |
| Memory plugins | ✅ | ❌ | Custom backends |
| Tool plugins | ✅ | ✅ | WASM tools |
| Hook plugins | ✅ | ❌ | |
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
| Provider plugins | ✅ | ❌ | |
| Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand |
| ClawHub registry | ✅ | ❌ | Discovery |
Expand Down Expand Up @@ -421,10 +421,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
| `transformResponse` hook | ✅ | ✅ | P2 | |
| `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection |
| Bundled hooks | ✅ | ❌ | P2 | |
| Plugin hooks | ✅ | ❌ | P3 | |
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
| Outbound webhooks | ✅ | ❌ | P2 | |
| Bundled hooks | ✅ | ✅ | P2 | Audit + declarative rule/webhook hooks |
| Plugin hooks | ✅ | ✅ | P3 | Registered from WASM `capabilities.json` |
| Workspace hooks | ✅ | ✅ | P2 | `hooks/hooks.json` and `hooks/*.hook.json` |
| Outbound webhooks | ✅ | ✅ | P2 | Fire-and-forget lifecycle event delivery |
| Heartbeat system | ✅ | ✅ | - | Periodic execution |
| Gmail pub/sub | ✅ | ❌ | P3 | |

Expand Down Expand Up @@ -528,7 +528,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
- ❌ WhatsApp channel
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
- ✅ Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse)
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)

### P2 - Medium Priority
- ❌ Media handling (images, PDFs)
Expand Down
9 changes: 7 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ impl AppBuilder {
pub async fn init_extensions(
&self,
tools: &Arc<ToolRegistry>,
hooks: &Arc<HookRegistry>,
) -> Result<
(
Arc<McpSessionManager>,
Expand Down Expand Up @@ -661,6 +662,7 @@ impl AppBuilder {
Arc::clone(&mcp_session_manager),
Arc::clone(secrets),
Arc::clone(tools),
Some(Arc::clone(hooks)),
wasm_tool_runtime.clone(),
self.config.wasm.tools_dir.clone(),
self.config.channels.wasm_channels_dir.clone(),
Expand Down Expand Up @@ -697,8 +699,12 @@ impl AppBuilder {

let (llm, cheap_llm) = self.init_llm()?;
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;

// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());

let (mcp_session_manager, wasm_tool_runtime, extension_manager) =
self.init_extensions(&tools).await?;
self.init_extensions(&tools, &hooks).await?;

// Seed workspace and backfill embeddings
if let Some(ref ws) = workspace {
Expand Down Expand Up @@ -741,7 +747,6 @@ impl AppBuilder {
};

let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs));
let hooks = Arc::new(HookRegistry::new());
let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new(
crate::agent::cost_guard::CostGuardConfig {
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
Expand Down
62 changes: 62 additions & 0 deletions src/extensions/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::extensions::{
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
InstalledExtension, RegistryEntry, ResultSource, SearchResult,
};
use crate::hooks::HookRegistry;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::tools::ToolRegistry;
use crate::tools::mcp::McpClient;
Expand Down Expand Up @@ -52,6 +53,7 @@ pub struct ExtensionManager {
// Shared
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
pending_auth: RwLock<HashMap<String, PendingAuth>>,
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
_tunnel_url: Option<String>,
Expand All @@ -66,6 +68,7 @@ impl ExtensionManager {
mcp_session_manager: Arc<McpSessionManager>,
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
Expand All @@ -83,6 +86,7 @@ impl ExtensionManager {
wasm_channels_dir,
secrets,
tool_registry,
hooks,
pending_auth: RwLock::new(HashMap::new()),
_tunnel_url: tunnel_url,
user_id,
Expand Down Expand Up @@ -320,6 +324,21 @@ impl ExtensionManager {
// Unregister from tool registry
self.tool_registry.unregister(name).await;

// Unregister hooks registered from this plugin source.
let removed_hooks = self
.unregister_hook_prefix(&format!("plugin.tool:{}::", name))
.await
+ self
.unregister_hook_prefix(&format!("plugin.dev_tool:{}::", name))
.await;
if removed_hooks > 0 {
tracing::info!(
extension = name,
removed_hooks = removed_hooks,
"Removed plugin hooks for WASM tool"
);
}

// Delete files
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
let cap_path = self
Expand Down Expand Up @@ -969,6 +988,34 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;

if let Some(ref hooks) = self.hooks
&& let Some(cap_path) = cap_path_option
{
let source = format!("plugin.tool:{}", name);
let registration =
crate::hooks::bootstrap::register_plugin_bundle_from_capabilities_file(
hooks, &source, cap_path,
)
.await;

if registration.total_registered() > 0 {
tracing::info!(
extension = name,
hooks = registration.hooks,
outbound_webhooks = registration.outbound_webhooks,
"Registered plugin hooks for activated WASM tool"
);
}

if registration.errors > 0 {
tracing::warn!(
extension = name,
errors = registration.errors,
"Some plugin hooks failed to register"
);
}
}

tracing::info!("Activated WASM tool '{}'", name);

Ok(ActivateResult {
Expand Down Expand Up @@ -1008,6 +1055,21 @@ impl ExtensionManager {
let mut pending = self.pending_auth.write().await;
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
}

async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
let Some(ref hooks) = self.hooks else {
return 0;
};

let names = hooks.list().await;
let mut removed = 0;
for hook_name in names {
if hook_name.starts_with(prefix) && hooks.unregister(&hook_name).await {
removed += 1;
}
}
removed
}
Comment on lines +1059 to +1072

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of unregister_hook_prefix iterates over all hook names and calls hooks.unregister for each match. Since unregister acquires a write lock on the hooks list, this results in repeated locking and unlocking within the loop, which is inefficient, especially for plugins with many hooks.

To improve performance, consider enhancing HookRegistry with a method that can unregister multiple hooks by prefix in a single operation, for example unregister_by_prefix. This would allow acquiring the write lock only once to remove all matching hooks. ExtensionManager could then call this more efficient method.

}

/// Infer the extension kind from a URL.
Expand Down
Loading
Loading