Эх сурвалжийг харах

Merge remote-tracking branch 'origin/rcc/plugins' into integration/dori-cleanroom

# Conflicts:
#	rust/Cargo.lock
#	rust/README.md
#	rust/crates/api/src/client.rs
#	rust/crates/api/src/lib.rs
#	rust/crates/api/src/sse.rs
#	rust/crates/api/src/types.rs
#	rust/crates/api/tests/client_integration.rs
#	rust/crates/commands/Cargo.toml
#	rust/crates/commands/src/lib.rs
#	rust/crates/compat-harness/src/lib.rs
#	rust/crates/runtime/Cargo.toml
#	rust/crates/runtime/src/bootstrap.rs
#	rust/crates/runtime/src/compact.rs
#	rust/crates/runtime/src/config.rs
#	rust/crates/runtime/src/conversation.rs
#	rust/crates/runtime/src/hooks.rs
#	rust/crates/runtime/src/lib.rs
#	rust/crates/runtime/src/mcp.rs
#	rust/crates/runtime/src/mcp_client.rs
#	rust/crates/runtime/src/oauth.rs
#	rust/crates/runtime/src/permissions.rs
#	rust/crates/runtime/src/prompt.rs
#	rust/crates/claw-cli/Cargo.toml
#	rust/crates/claw-cli/src/args.rs
#	rust/crates/claw-cli/src/init.rs
#	rust/crates/claw-cli/src/main.rs
#	rust/crates/claw-cli/src/render.rs
#	rust/crates/tools/Cargo.toml
#	rust/crates/tools/src/lib.rs
YeonGyu-Kim 2 сар өмнө
parent
commit
6e8bd15154
38 өөрчлөгдсөн 6997 нэмэгдсэн , 1710 устгасан
  1. 12 0
      rust/Cargo.lock
  2. 1 30
      rust/README.md
  3. 16 16
      rust/crates/api/src/client.rs
  4. 1 1
      rust/crates/api/src/lib.rs
  5. 60 0
      rust/crates/api/src/sse.rs
  6. 11 0
      rust/crates/api/src/types.rs
  7. 127 6
      rust/crates/api/tests/client_integration.rs
  8. 1 0
      rust/crates/commands/Cargo.toml
  9. 637 5
      rust/crates/commands/src/lib.rs
  10. 2 6
      rust/crates/compat-harness/src/lib.rs
  11. 13 0
      rust/crates/plugins/Cargo.toml
  12. 10 0
      rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json
  13. 2 0
      rust/crates/plugins/bundled/example-bundled/hooks/post.sh
  14. 2 0
      rust/crates/plugins/bundled/example-bundled/hooks/pre.sh
  15. 10 0
      rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json
  16. 2 0
      rust/crates/plugins/bundled/sample-hooks/hooks/post.sh
  17. 2 0
      rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh
  18. 395 0
      rust/crates/plugins/src/hooks.rs
  19. 2929 0
      rust/crates/plugins/src/lib.rs
  20. 1 0
      rust/crates/runtime/Cargo.toml
  21. 1 1
      rust/crates/runtime/src/bootstrap.rs
  22. 227 10
      rust/crates/runtime/src/compact.rs
  23. 262 112
      rust/crates/runtime/src/config.rs
  24. 621 169
      rust/crates/runtime/src/conversation.rs
  25. 58 556
      rust/crates/runtime/src/hooks.rs
  26. 8 10
      rust/crates/runtime/src/lib.rs
  27. 2 2
      rust/crates/runtime/src/mcp.rs
  28. 4 4
      rust/crates/runtime/src/mcp_client.rs
  29. 4 4
      rust/crates/runtime/src/oauth.rs
  30. 24 467
      rust/crates/runtime/src/permissions.rs
  31. 38 41
      rust/crates/runtime/src/prompt.rs
  32. 2 1
      rust/crates/rusty-claude-cli/Cargo.toml
  33. 5 5
      rust/crates/rusty-claude-cli/src/args.rs
  34. 42 45
      rust/crates/rusty-claude-cli/src/init.rs
  35. 901 159
      rust/crates/rusty-claude-cli/src/main.rs
  36. 3 2
      rust/crates/rusty-claude-cli/src/render.rs
  37. 1 0
      rust/crates/tools/Cargo.toml
  38. 560 58
      rust/crates/tools/src/lib.rs

+ 12 - 0
rust/Cargo.lock

@@ -111,6 +111,7 @@ dependencies = [
 name = "commands"
 version = "0.1.0"
 dependencies = [
+ "plugins",
  "runtime",
 ]
 
@@ -825,6 +826,14 @@ dependencies = [
  "time",
 ]
 
+[[package]]
+name = "plugins"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "potential_utf"
 version = "0.1.4"
@@ -1092,6 +1101,7 @@ name = "runtime"
 version = "0.1.0"
 dependencies = [
  "glob",
+ "plugins",
  "regex",
  "serde",
  "serde_json",
@@ -1181,6 +1191,7 @@ dependencies = [
  "commands",
  "compat-harness",
  "crossterm",
+ "plugins",
  "pulldown-cmark",
  "runtime",
  "rustyline",
@@ -1546,6 +1557,7 @@ name = "tools"
 version = "0.1.0"
 dependencies = [
  "api",
+ "plugins",
  "reqwest",
  "runtime",
  "serde",

+ 1 - 30
rust/README.md

@@ -1,30 +1,6 @@
 # 🦞 Claw Code — Rust Implementation
 
-<p align="center">
-  <a href="https://github.com/instructkr/claw-code"><img src="https://img.shields.io/github/stars/instructkr/claw-code?style=for-the-badge" alt="GitHub stars" /></a>
-  <a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
-</p>
-
-<p align="center">
-  <strong>Claw Code</strong> — a clean-room Rust rewrite of the original agent harness.
-</p>
-
-<p align="center">
-  Built for 90K+ stars. 43 tools. JSONL sessions. Prompt caching. Wire-protocol telemetry matching.<br>
-  Blazing fast. Memory-safe. Zero-compromise architecture.
-</p>
-
-## Star History
-
-<p align="center">
-  <a href="https://star-history.com/#instructkr/claw-code&Date">
-    <picture>
-      <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date&theme=dark" />
-      <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" />
-      <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" width="600" />
-    </picture>
-  </a>
-</p>
+A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
 
 ## Quick Start
 
@@ -63,11 +39,6 @@ claw login
 
 | Feature | Status |
 |---------|--------|
-| 43 tools with full parity | ✅ |
-| JSONL session persistence | ✅ |
-| Prompt cache tracking | ✅ |
-| Wire-protocol telemetry matching | ✅ |
-| 11MB release binary | ✅ |
 | Anthropic API + streaming | ✅ |
 | OAuth login/logout | ✅ |
 | Interactive REPL (rustyline) | ✅ |

+ 16 - 16
rust/crates/api/src/client.rs

@@ -101,7 +101,7 @@ impl From<OAuthTokenSet> for AuthSource {
 }
 
 #[derive(Debug, Clone)]
-pub struct ApiClient {
+pub struct AnthropicClient {
     http: reqwest::Client,
     auth: AuthSource,
     base_url: String,
@@ -110,7 +110,7 @@ pub struct ApiClient {
     max_backoff: Duration,
 }
 
-impl ApiClient {
+impl AnthropicClient {
     #[must_use]
     pub fn new(api_key: impl Into<String>) -> Self {
         Self {
@@ -429,7 +429,7 @@ fn resolve_saved_oauth_token_set(
     let Some(refresh_token) = token_set.refresh_token.clone() else {
         return Err(ApiError::ExpiredOAuthToken);
     };
-    let client = ApiClient::from_auth(AuthSource::None).with_base_url(read_base_url());
+    let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(read_base_url());
     let refreshed = client_runtime_block_on(async {
         client
             .refresh_oauth_token(
@@ -614,7 +614,7 @@ mod tests {
 
     use crate::client::{
         now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
-        resolve_startup_auth_source, ApiClient, AuthSource, OAuthTokenSet,
+        resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
     };
     use crate::types::{ContentBlockDelta, MessageRequest};
 
@@ -671,7 +671,7 @@ mod tests {
         let _guard = env_lock();
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         let error = super::read_api_key().expect_err("missing key should error");
         assert!(matches!(error, crate::error::ApiError::MissingApiKey));
     }
@@ -735,7 +735,7 @@ mod tests {
     fn auth_source_from_saved_oauth_when_env_absent() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -750,7 +750,7 @@ mod tests {
         assert_eq!(auth.bearer_token(), Some("saved-access-token"));
 
         clear_oauth_credentials().expect("clear credentials");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 
@@ -774,7 +774,7 @@ mod tests {
     fn resolve_saved_oauth_token_refreshes_expired_credentials() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -798,7 +798,7 @@ mod tests {
         assert_eq!(stored.access_token, "refreshed-token");
 
         clear_oauth_credentials().expect("clear credentials");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 
@@ -806,7 +806,7 @@ mod tests {
     fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -822,7 +822,7 @@ mod tests {
         assert_eq!(auth.bearer_token(), Some("saved-access-token"));
 
         clear_oauth_credentials().expect("clear credentials");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 
@@ -830,7 +830,7 @@ mod tests {
     fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -854,7 +854,7 @@ mod tests {
         assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
 
         clear_oauth_credentials().expect("clear credentials");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 
@@ -862,7 +862,7 @@ mod tests {
     fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -887,7 +887,7 @@ mod tests {
         assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
 
         clear_oauth_credentials().expect("clear credentials");
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 
@@ -908,7 +908,7 @@ mod tests {
 
     #[test]
     fn backoff_doubles_until_maximum() {
-        let client = ApiClient::new("test-key").with_retry_policy(
+        let client = AnthropicClient::new("test-key").with_retry_policy(
             3,
             Duration::from_millis(10),
             Duration::from_millis(25),

+ 1 - 1
rust/crates/api/src/lib.rs

@@ -5,7 +5,7 @@ mod types;
 
 pub use client::{
     oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
-    ApiClient, AuthSource, MessageStream, OAuthTokenSet,
+    AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
 };
 pub use error::ApiError;
 pub use sse::{parse_frame, SseParser};

+ 60 - 0
rust/crates/api/src/sse.rs

@@ -216,4 +216,64 @@ mod tests {
             ))
         );
     }
+
+    #[test]
+    fn parses_thinking_content_block_start() {
+        let frame = concat!(
+            "event: content_block_start\n",
+            "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":null}}\n\n"
+        );
+
+        let event = parse_frame(frame).expect("frame should parse");
+        assert_eq!(
+            event,
+            Some(StreamEvent::ContentBlockStart(
+                crate::types::ContentBlockStartEvent {
+                    index: 0,
+                    content_block: OutputContentBlock::Thinking {
+                        thinking: String::new(),
+                        signature: None,
+                    },
+                },
+            ))
+        );
+    }
+
+    #[test]
+    fn parses_thinking_related_deltas() {
+        let thinking = concat!(
+            "event: content_block_delta\n",
+            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n"
+        );
+        let signature = concat!(
+            "event: content_block_delta\n",
+            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n"
+        );
+
+        let thinking_event = parse_frame(thinking).expect("thinking delta should parse");
+        let signature_event = parse_frame(signature).expect("signature delta should parse");
+
+        assert_eq!(
+            thinking_event,
+            Some(StreamEvent::ContentBlockDelta(
+                crate::types::ContentBlockDeltaEvent {
+                    index: 0,
+                    delta: ContentBlockDelta::ThinkingDelta {
+                        thinking: "step 1".to_string(),
+                    },
+                }
+            ))
+        );
+        assert_eq!(
+            signature_event,
+            Some(StreamEvent::ContentBlockDelta(
+                crate::types::ContentBlockDeltaEvent {
+                    index: 0,
+                    delta: ContentBlockDelta::SignatureDelta {
+                        signature: "sig_123".to_string(),
+                    },
+                }
+            ))
+        );
+    }
 }

+ 11 - 0
rust/crates/api/src/types.rs

@@ -135,6 +135,15 @@ pub enum OutputContentBlock {
         name: String,
         input: Value,
     },
+    Thinking {
+        #[serde(default)]
+        thinking: String,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        signature: Option<String>,
+    },
+    RedactedThinking {
+        data: Value,
+    },
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -190,6 +199,8 @@ pub struct ContentBlockDeltaEvent {
 pub enum ContentBlockDelta {
     TextDelta { text: String },
     InputJsonDelta { partial_json: String },
+    ThinkingDelta { thinking: String },
+    SignatureDelta { signature: String },
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

+ 127 - 6
rust/crates/api/tests/client_integration.rs

@@ -3,7 +3,7 @@ use std::sync::Arc;
 use std::time::Duration;
 
 use api::{
-    ApiClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
+    AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
     InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
     StreamEvent, ToolChoice, ToolDefinition,
 };
@@ -34,7 +34,7 @@ async fn send_message_posts_json_and_parses_response() {
     )
     .await;
 
-    let client = ApiClient::new("test-key")
+    let client = AnthropicClient::new("test-key")
         .with_auth_token(Some("proxy-token".to_string()))
         .with_base_url(server.base_url());
     let response = client
@@ -75,6 +75,48 @@ async fn send_message_posts_json_and_parses_response() {
     assert_eq!(body["tool_choice"]["type"], json!("auto"));
 }
 
+#[tokio::test]
+async fn send_message_parses_response_with_thinking_blocks() {
+    let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
+    let body = concat!(
+        "{",
+        "\"id\":\"msg_thinking\",",
+        "\"type\":\"message\",",
+        "\"role\":\"assistant\",",
+        "\"content\":[",
+        "{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},",
+        "{\"type\":\"text\",\"text\":\"Final answer\"}",
+        "],",
+        "\"model\":\"claude-3-7-sonnet-latest\",",
+        "\"stop_reason\":\"end_turn\",",
+        "\"stop_sequence\":null,",
+        "\"usage\":{\"input_tokens\":12,\"output_tokens\":4}",
+        "}"
+    );
+    let server = spawn_server(
+        state,
+        vec![http_response("200 OK", "application/json", body)],
+    )
+    .await;
+
+    let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
+    let response = client
+        .send_message(&sample_request(false))
+        .await
+        .expect("request should succeed");
+
+    assert_eq!(response.content.len(), 2);
+    assert!(matches!(
+        &response.content[0],
+        OutputContentBlock::Thinking { thinking, signature }
+            if thinking == "step 1" && signature.as_deref() == Some("sig_123")
+    ));
+    assert!(matches!(
+        &response.content[1],
+        OutputContentBlock::Text { text } if text == "Final answer"
+    ));
+}
+
 #[tokio::test]
 async fn stream_message_parses_sse_events_with_tool_use() {
     let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -104,7 +146,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
     )
     .await;
 
-    let client = ApiClient::new("test-key")
+    let client = AnthropicClient::new("test-key")
         .with_auth_token(Some("proxy-token".to_string()))
         .with_base_url(server.base_url());
     let mut stream = client
@@ -162,6 +204,85 @@ async fn stream_message_parses_sse_events_with_tool_use() {
     assert!(request.body.contains("\"stream\":true"));
 }
 
+#[tokio::test]
+async fn stream_message_parses_sse_events_with_thinking_blocks() {
+    let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
+    let sse = concat!(
+        "event: message_start\n",
+        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
+        "event: content_block_start\n",
+        "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
+        "event: content_block_delta\n",
+        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n",
+        "event: content_block_delta\n",
+        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n",
+        "event: content_block_stop\n",
+        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
+        "event: content_block_start\n",
+        "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n",
+        "event: content_block_stop\n",
+        "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
+        "event: message_delta\n",
+        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
+        "event: message_stop\n",
+        "data: {\"type\":\"message_stop\"}\n\n",
+        "data: [DONE]\n\n"
+    );
+    let server = spawn_server(
+        state,
+        vec![http_response("200 OK", "text/event-stream", sse)],
+    )
+    .await;
+
+    let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
+    let mut stream = client
+        .stream_message(&sample_request(false))
+        .await
+        .expect("stream should start");
+
+    let mut events = Vec::new();
+    while let Some(event) = stream
+        .next_event()
+        .await
+        .expect("stream event should parse")
+    {
+        events.push(event);
+    }
+
+    assert_eq!(events.len(), 9);
+    assert!(matches!(
+        &events[1],
+        StreamEvent::ContentBlockStart(ContentBlockStartEvent {
+            content_block: OutputContentBlock::Thinking { thinking, signature },
+            ..
+        }) if thinking.is_empty() && signature.is_none()
+    ));
+    assert!(matches!(
+        &events[2],
+        StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
+            delta: ContentBlockDelta::ThinkingDelta { thinking },
+            ..
+        }) if thinking == "step 1"
+    ));
+    assert!(matches!(
+        &events[3],
+        StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
+            delta: ContentBlockDelta::SignatureDelta { signature },
+            ..
+        }) if signature == "sig_123"
+    ));
+    assert!(matches!(
+        &events[5],
+        StreamEvent::ContentBlockStart(ContentBlockStartEvent {
+            content_block: OutputContentBlock::Text { text },
+            ..
+        }) if text == "Final answer"
+    ));
+    assert!(matches!(events[6], StreamEvent::ContentBlockStop(_)));
+    assert!(matches!(events[7], StreamEvent::MessageDelta(_)));
+    assert!(matches!(events[8], StreamEvent::MessageStop(_)));
+}
+
 #[tokio::test]
 async fn retries_retryable_failures_before_succeeding() {
     let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -182,7 +303,7 @@ async fn retries_retryable_failures_before_succeeding() {
     )
     .await;
 
-    let client = ApiClient::new("test-key")
+    let client = AnthropicClient::new("test-key")
         .with_base_url(server.base_url())
         .with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
 
@@ -215,7 +336,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
     )
     .await;
 
-    let client = ApiClient::new("test-key")
+    let client = AnthropicClient::new("test-key")
         .with_base_url(server.base_url())
         .with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
 
@@ -246,7 +367,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
 #[tokio::test]
 #[ignore = "requires ANTHROPIC_API_KEY and network access"]
 async fn live_stream_smoke_test() {
-    let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
+    let client = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
     let mut stream = client
         .stream_message(&MessageRequest {
             model: std::env::var("ANTHROPIC_MODEL")

+ 1 - 0
rust/crates/commands/Cargo.toml

@@ -9,4 +9,5 @@ publish.workspace = true
 workspace = true
 
 [dependencies]
+plugins = { path = "../plugins" }
 runtime = { path = "../runtime" }

+ 637 - 5
rust/crates/commands/src/lib.rs

@@ -1,3 +1,9 @@
+use std::collections::BTreeMap;
+use std::env;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use plugins::{PluginError, PluginManager, PluginSummary};
 use runtime::{compact_session, CompactionConfig, Session};
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -33,6 +39,7 @@ impl CommandRegistry {
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct SlashCommandSpec {
     pub name: &'static str,
+    pub aliases: &'static [&'static str],
     pub summary: &'static str,
     pub argument_hint: Option<&'static str>,
     pub resume_supported: bool,
@@ -90,7 +97,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
     SlashCommandSpec {
         name: "config",
         summary: "Inspect Claude config files or merged sections",
-        argument_hint: Some("[env|hooks|model]"),
+        argument_hint: Some("[env|hooks|model|plugins]"),
         resume_supported: true,
     },
     SlashCommandSpec {
@@ -117,6 +124,48 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: None,
         resume_supported: true,
     },
+    SlashCommandSpec {
+        name: "bughunter",
+        summary: "Inspect the codebase for likely bugs",
+        argument_hint: Some("[scope]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "commit",
+        summary: "Generate a commit message and create a git commit",
+        argument_hint: None,
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "pr",
+        summary: "Draft or create a pull request from the conversation",
+        argument_hint: Some("[context]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "issue",
+        summary: "Draft or create a GitHub issue from the conversation",
+        argument_hint: Some("[context]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "ultraplan",
+        summary: "Run a deep planning prompt with multi-step reasoning",
+        argument_hint: Some("[task]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "teleport",
+        summary: "Jump to a file or symbol by searching the workspace",
+        argument_hint: Some("<symbol-or-path>"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "debug-tool-call",
+        summary: "Replay the last tool call with debug details",
+        argument_hint: None,
+        resume_supported: false,
+    },
     SlashCommandSpec {
         name: "export",
         summary: "Export the current conversation to a file",
@@ -129,6 +178,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: Some("[list|switch <session-id>]"),
         resume_supported: false,
     },
+    SlashCommandSpec {
+        name: "plugins",
+        summary: "List or manage plugins",
+        argument_hint: Some(
+            "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
+        ),
+        resume_supported: false,
+    },
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -136,6 +193,23 @@ pub enum SlashCommand {
     Help,
     Status,
     Compact,
+    Bughunter {
+        scope: Option<String>,
+    },
+    Commit,
+    Pr {
+        context: Option<String>,
+    },
+    Issue {
+        context: Option<String>,
+    },
+    Ultraplan {
+        task: Option<String>,
+    },
+    Teleport {
+        target: Option<String>,
+    },
+    DebugToolCall,
     Model {
         model: Option<String>,
     },
@@ -163,6 +237,10 @@ pub enum SlashCommand {
         action: Option<String>,
         target: Option<String>,
     },
+    Plugins {
+        action: Option<String>,
+        target: Option<String>,
+    },
     Unknown(String),
 }
 
@@ -180,6 +258,23 @@ impl SlashCommand {
             "help" => Self::Help,
             "status" => Self::Status,
             "compact" => Self::Compact,
+            "bughunter" => Self::Bughunter {
+                scope: remainder_after_command(trimmed, command),
+            },
+            "commit" => Self::Commit,
+            "pr" => Self::Pr {
+                context: remainder_after_command(trimmed, command),
+            },
+            "issue" => Self::Issue {
+                context: remainder_after_command(trimmed, command),
+            },
+            "ultraplan" => Self::Ultraplan {
+                task: remainder_after_command(trimmed, command),
+            },
+            "teleport" => Self::Teleport {
+                target: remainder_after_command(trimmed, command),
+            },
+            "debug-tool-call" => Self::DebugToolCall,
             "model" => Self::Model {
                 model: parts.next().map(ToOwned::to_owned),
             },
@@ -207,11 +302,27 @@ impl SlashCommand {
                 action: parts.next().map(ToOwned::to_owned),
                 target: parts.next().map(ToOwned::to_owned),
             },
+            "plugins" => Self::Plugins {
+                action: parts.next().map(ToOwned::to_owned),
+                target: {
+                    let remainder = parts.collect::<Vec<_>>().join(" ");
+                    (!remainder.is_empty()).then_some(remainder)
+                },
+            },
             other => Self::Unknown(other.to_string()),
         })
     }
 }
 
+fn remainder_after_command(input: &str, command: &str) -> Option<String> {
+    input
+        .trim()
+        .strip_prefix(&format!("/{command}"))
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(ToOwned::to_owned)
+}
+
 #[must_use]
 pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
     SLASH_COMMAND_SPECS
@@ -252,6 +363,176 @@ pub struct SlashCommandResult {
     pub session: Session,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginsCommandResult {
+    pub message: String,
+    pub reload_runtime: bool,
+}
+
+#[allow(clippy::too_many_lines)]
+pub fn handle_plugins_slash_command(
+    action: Option<&str>,
+    target: Option<&str>,
+    manager: &mut PluginManager,
+) -> Result<PluginsCommandResult, PluginError> {
+    match action {
+        None | Some("list") => Ok(PluginsCommandResult {
+            message: render_plugins_report(&manager.list_installed_plugins()?),
+            reload_runtime: false,
+        }),
+        Some("install") => {
+            let Some(target) = target else {
+                return Ok(PluginsCommandResult {
+                    message: "Usage: /plugins install <path>".to_string(),
+                    reload_runtime: false,
+                });
+            };
+            let install = manager.install(target)?;
+            let plugin = manager
+                .list_installed_plugins()?
+                .into_iter()
+                .find(|plugin| plugin.metadata.id == install.plugin_id);
+            Ok(PluginsCommandResult {
+                message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
+                reload_runtime: true,
+            })
+        }
+        Some("enable") => {
+            let Some(target) = target else {
+                return Ok(PluginsCommandResult {
+                    message: "Usage: /plugins enable <name>".to_string(),
+                    reload_runtime: false,
+                });
+            };
+            let plugin = resolve_plugin_target(manager, target)?;
+            manager.enable(&plugin.metadata.id)?;
+            Ok(PluginsCommandResult {
+                message: format!(
+                    "Plugins\n  Result           enabled {}\n  Name             {}\n  Version          {}\n  Status           enabled",
+                    plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
+                ),
+                reload_runtime: true,
+            })
+        }
+        Some("disable") => {
+            let Some(target) = target else {
+                return Ok(PluginsCommandResult {
+                    message: "Usage: /plugins disable <name>".to_string(),
+                    reload_runtime: false,
+                });
+            };
+            let plugin = resolve_plugin_target(manager, target)?;
+            manager.disable(&plugin.metadata.id)?;
+            Ok(PluginsCommandResult {
+                message: format!(
+                    "Plugins\n  Result           disabled {}\n  Name             {}\n  Version          {}\n  Status           disabled",
+                    plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
+                ),
+                reload_runtime: true,
+            })
+        }
+        Some("uninstall") => {
+            let Some(target) = target else {
+                return Ok(PluginsCommandResult {
+                    message: "Usage: /plugins uninstall <plugin-id>".to_string(),
+                    reload_runtime: false,
+                });
+            };
+            manager.uninstall(target)?;
+            Ok(PluginsCommandResult {
+                message: format!("Plugins\n  Result           uninstalled {target}"),
+                reload_runtime: true,
+            })
+        }
+        Some("update") => {
+            let Some(target) = target else {
+                return Ok(PluginsCommandResult {
+                    message: "Usage: /plugins update <plugin-id>".to_string(),
+                    reload_runtime: false,
+                });
+            };
+            let update = manager.update(target)?;
+            let plugin = manager
+                .list_installed_plugins()?
+                .into_iter()
+                .find(|plugin| plugin.metadata.id == update.plugin_id);
+            Ok(PluginsCommandResult {
+                message: format!(
+                    "Plugins\n  Result           updated {}\n  Name             {}\n  Old version      {}\n  New version      {}\n  Status           {}",
+                    update.plugin_id,
+                    plugin
+                        .as_ref()
+                        .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
+                    update.old_version,
+                    update.new_version,
+                    plugin
+                        .as_ref()
+                        .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
+                ),
+                reload_runtime: true,
+            })
+        }
+        Some(other) => Ok(PluginsCommandResult {
+            message: format!(
+                "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
+            ),
+            reload_runtime: false,
+        }),
+    }
+}
+
+#[must_use]
+pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
+    let mut lines = vec!["Plugins".to_string()];
+    if plugins.is_empty() {
+        lines.push("  No plugins installed.".to_string());
+        return lines.join("\n");
+    }
+    for plugin in plugins {
+        let enabled = if plugin.enabled {
+            "enabled"
+        } else {
+            "disabled"
+        };
+        lines.push(format!(
+            "  {name:<20} v{version:<10} {enabled}",
+            name = plugin.metadata.name,
+            version = plugin.metadata.version,
+        ));
+    }
+    lines.join("\n")
+}
+
+fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
+    let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
+    let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
+    let enabled = plugin.is_some_and(|plugin| plugin.enabled);
+    format!(
+        "Plugins\n  Result           installed {plugin_id}\n  Name             {name}\n  Version          {version}\n  Status           {}",
+        if enabled { "enabled" } else { "disabled" }
+    )
+}
+
+fn resolve_plugin_target(
+    manager: &PluginManager,
+    target: &str,
+) -> Result<PluginSummary, PluginError> {
+    let mut matches = manager
+        .list_installed_plugins()?
+        .into_iter()
+        .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
+        .collect::<Vec<_>>();
+    match matches.len() {
+        1 => Ok(matches.remove(0)),
+        0 => Err(PluginError::NotFound(format!(
+            "plugin `{target}` is not installed or discoverable"
+        ))),
+        _ => Err(PluginError::InvalidManifest(format!(
+            "plugin name `{target}` is ambiguous; use the full plugin id"
+        ))),
+    }
+}
+
 #[must_use]
 pub fn handle_slash_command(
     input: &str,
@@ -279,6 +560,13 @@ pub fn handle_slash_command(
             session: session.clone(),
         }),
         SlashCommand::Status
+        | SlashCommand::Bughunter { .. }
+        | SlashCommand::Commit
+        | SlashCommand::Pr { .. }
+        | SlashCommand::Issue { .. }
+        | SlashCommand::Ultraplan { .. }
+        | SlashCommand::Teleport { .. }
+        | SlashCommand::DebugToolCall
         | SlashCommand::Model { .. }
         | SlashCommand::Permissions { .. }
         | SlashCommand::Clear { .. }
@@ -291,6 +579,7 @@ pub fn handle_slash_command(
         | SlashCommand::Version
         | SlashCommand::Export { .. }
         | SlashCommand::Session { .. }
+        | SlashCommand::Plugins { .. }
         | SlashCommand::Unknown(_) => None,
     }
 }
@@ -298,15 +587,88 @@ pub fn handle_slash_command(
 #[cfg(test)]
 mod tests {
     use super::{
-        handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
-        slash_command_specs, SlashCommand,
+        handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
+        load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
+        render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
+        DefinitionSource, SlashCommand,
     };
+    use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
     use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
+    use std::fs;
+    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!("commands-plugin-{label}-{nanos}"))
+    }
+
+    fn write_external_plugin(root: &Path, name: &str, version: &str) {
+        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
+        fs::write(
+            root.join(".claude-plugin").join("plugin.json"),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"commands plugin\"\n}}"
+            ),
+        )
+        .expect("write manifest");
+    }
+
+    fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
+        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
+        fs::write(
+            root.join(".claude-plugin").join("plugin.json"),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"bundled commands plugin\",\n  \"defaultEnabled\": {}\n}}",
+                if default_enabled { "true" } else { "false" }
+            ),
+        )
+        .expect("write bundled manifest");
+    }
 
+    #[allow(clippy::too_many_lines)]
     #[test]
     fn parses_supported_slash_commands() {
         assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
         assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
+        assert_eq!(
+            SlashCommand::parse("/bughunter runtime"),
+            Some(SlashCommand::Bughunter {
+                scope: Some("runtime".to_string())
+            })
+        );
+        assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
+        assert_eq!(
+            SlashCommand::parse("/pr ready for review"),
+            Some(SlashCommand::Pr {
+                context: Some("ready for review".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/issue flaky test"),
+            Some(SlashCommand::Issue {
+                context: Some("flaky test".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/ultraplan ship both features"),
+            Some(SlashCommand::Ultraplan {
+                task: Some("ship both features".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/teleport conversation.rs"),
+            Some(SlashCommand::Teleport {
+                target: Some("conversation.rs".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/debug-tool-call"),
+            Some(SlashCommand::DebugToolCall)
+        );
         assert_eq!(
             SlashCommand::parse("/model claude-opus"),
             Some(SlashCommand::Model {
@@ -365,6 +727,34 @@ mod tests {
                 target: Some("abc123".to_string())
             })
         );
+        assert_eq!(
+            SlashCommand::parse("/plugins install demo"),
+            Some(SlashCommand::Plugins {
+                action: Some("install".to_string()),
+                target: Some("demo".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/plugins list"),
+            Some(SlashCommand::Plugins {
+                action: Some("list".to_string()),
+                target: None
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/plugins enable demo"),
+            Some(SlashCommand::Plugins {
+                action: Some("enable".to_string()),
+                target: Some("demo".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/plugins disable demo"),
+            Some(SlashCommand::Plugins {
+                action: Some("disable".to_string()),
+                target: Some("demo".to_string())
+            })
+        );
     }
 
     #[test]
@@ -374,19 +764,29 @@ mod tests {
         assert!(help.contains("/help"));
         assert!(help.contains("/status"));
         assert!(help.contains("/compact"));
+        assert!(help.contains("/bughunter [scope]"));
+        assert!(help.contains("/commit"));
+        assert!(help.contains("/pr [context]"));
+        assert!(help.contains("/issue [context]"));
+        assert!(help.contains("/ultraplan [task]"));
+        assert!(help.contains("/teleport <symbol-or-path>"));
+        assert!(help.contains("/debug-tool-call"));
         assert!(help.contains("/model [model]"));
         assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
         assert!(help.contains("/clear [--confirm]"));
         assert!(help.contains("/cost"));
         assert!(help.contains("/resume <session-path>"));
-        assert!(help.contains("/config [env|hooks|model]"));
+        assert!(help.contains("/config [env|hooks|model|plugins]"));
         assert!(help.contains("/memory"));
         assert!(help.contains("/init"));
         assert!(help.contains("/diff"));
         assert!(help.contains("/version"));
         assert!(help.contains("/export [file]"));
         assert!(help.contains("/session [list|switch <session-id>]"));
-        assert_eq!(slash_command_specs().len(), 15);
+        assert!(help.contains(
+            "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
+        ));
+        assert_eq!(slash_command_specs().len(), 23);
         assert_eq!(resume_supported_slash_commands().len(), 11);
     }
 
@@ -434,6 +834,22 @@ mod tests {
         let session = Session::new();
         assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
         assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(
+            handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(
+            handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
+                .is_none()
+        );
         assert!(
             handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
         );
@@ -468,5 +884,221 @@ mod tests {
         assert!(
             handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
         );
+        assert!(
+            handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
+        );
+    }
+
+    #[test]
+    fn renders_plugins_report_with_name_version_and_status() {
+        let rendered = render_plugins_report(&[
+            PluginSummary {
+                metadata: PluginMetadata {
+                    id: "demo@external".to_string(),
+                    name: "demo".to_string(),
+                    version: "1.2.3".to_string(),
+                    description: "demo plugin".to_string(),
+                    kind: PluginKind::External,
+                    source: "demo".to_string(),
+                    default_enabled: false,
+                    root: None,
+                },
+                enabled: true,
+            },
+            PluginSummary {
+                metadata: PluginMetadata {
+                    id: "sample@external".to_string(),
+                    name: "sample".to_string(),
+                    version: "0.9.0".to_string(),
+                    description: "sample plugin".to_string(),
+                    kind: PluginKind::External,
+                    source: "sample".to_string(),
+                    default_enabled: false,
+                    root: None,
+                },
+                enabled: false,
+            },
+        ]);
+
+        assert!(rendered.contains("demo"));
+        assert!(rendered.contains("v1.2.3"));
+        assert!(rendered.contains("enabled"));
+        assert!(rendered.contains("sample"));
+        assert!(rendered.contains("v0.9.0"));
+        assert!(rendered.contains("disabled"));
+    }
+
+    #[test]
+    fn lists_agents_from_project_and_user_roots() {
+        let workspace = temp_dir("agents-workspace");
+        let project_agents = workspace.join(".codex").join("agents");
+        let user_home = temp_dir("agents-home");
+        let user_agents = user_home.join(".codex").join("agents");
+
+        write_agent(
+            &project_agents,
+            "planner",
+            "Project planner",
+            "gpt-5.4",
+            "medium",
+        );
+        write_agent(
+            &user_agents,
+            "planner",
+            "User planner",
+            "gpt-5.4-mini",
+            "high",
+        );
+        write_agent(
+            &user_agents,
+            "verifier",
+            "Verification agent",
+            "gpt-5.4-mini",
+            "high",
+        );
+
+        let roots = vec![
+            (DefinitionSource::ProjectCodex, project_agents),
+            (DefinitionSource::UserCodex, user_agents),
+        ];
+        let report = render_agents_report(
+            &load_agents_from_roots(&roots).expect("agent roots should load"),
+        );
+
+        assert!(report.contains("Agents"));
+        assert!(report.contains("2 active agents"));
+        assert!(report.contains("Project (.codex):"));
+        assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
+        assert!(report.contains("User (~/.codex):"));
+        assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
+        assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
+
+        let _ = fs::remove_dir_all(workspace);
+        let _ = fs::remove_dir_all(user_home);
+    }
+
+    #[test]
+    fn lists_skills_from_project_and_user_roots() {
+        let workspace = temp_dir("skills-workspace");
+        let project_skills = workspace.join(".codex").join("skills");
+        let user_home = temp_dir("skills-home");
+        let user_skills = user_home.join(".codex").join("skills");
+
+        write_skill(&project_skills, "plan", "Project planning guidance");
+        write_skill(&user_skills, "plan", "User planning guidance");
+        write_skill(&user_skills, "help", "Help guidance");
+
+        let roots = vec![
+            (DefinitionSource::ProjectCodex, project_skills),
+            (DefinitionSource::UserCodex, user_skills),
+        ];
+        let report = render_skills_report(
+            &load_skills_from_roots(&roots).expect("skill roots should load"),
+        );
+
+        assert!(report.contains("Skills"));
+        assert!(report.contains("2 available skills"));
+        assert!(report.contains("Project (.codex):"));
+        assert!(report.contains("plan · Project planning guidance"));
+        assert!(report.contains("User (~/.codex):"));
+        assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
+        assert!(report.contains("help · Help guidance"));
+
+        let _ = fs::remove_dir_all(workspace);
+        let _ = fs::remove_dir_all(user_home);
+    }
+
+    #[test]
+    fn installs_plugin_from_path_and_lists_it() {
+        let config_home = temp_dir("home");
+        let source_root = temp_dir("source");
+        write_external_plugin(&source_root, "demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let install = handle_plugins_slash_command(
+            Some("install"),
+            Some(source_root.to_str().expect("utf8 path")),
+            &mut manager,
+        )
+        .expect("install command should succeed");
+        assert!(install.reload_runtime);
+        assert!(install.message.contains("installed demo@external"));
+        assert!(install.message.contains("Name             demo"));
+        assert!(install.message.contains("Version          1.0.0"));
+        assert!(install.message.contains("Status           enabled"));
+
+        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
+            .expect("list command should succeed");
+        assert!(!list.reload_runtime);
+        assert!(list.message.contains("demo"));
+        assert!(list.message.contains("v1.0.0"));
+        assert!(list.message.contains("enabled"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn enables_and_disables_plugin_by_name() {
+        let config_home = temp_dir("toggle-home");
+        let source_root = temp_dir("toggle-source");
+        write_external_plugin(&source_root, "demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        handle_plugins_slash_command(
+            Some("install"),
+            Some(source_root.to_str().expect("utf8 path")),
+            &mut manager,
+        )
+        .expect("install command should succeed");
+
+        let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
+            .expect("disable command should succeed");
+        assert!(disable.reload_runtime);
+        assert!(disable.message.contains("disabled demo@external"));
+        assert!(disable.message.contains("Name             demo"));
+        assert!(disable.message.contains("Status           disabled"));
+
+        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
+            .expect("list command should succeed");
+        assert!(list.message.contains("demo"));
+        assert!(list.message.contains("disabled"));
+
+        let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
+            .expect("enable command should succeed");
+        assert!(enable.reload_runtime);
+        assert!(enable.message.contains("enabled demo@external"));
+        assert!(enable.message.contains("Name             demo"));
+        assert!(enable.message.contains("Status           enabled"));
+
+        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
+            .expect("list command should succeed");
+        assert!(list.message.contains("demo"));
+        assert!(list.message.contains("enabled"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn lists_auto_installed_bundled_plugins_with_status() {
+        let config_home = temp_dir("bundled-home");
+        let bundled_root = temp_dir("bundled-root");
+        let bundled_plugin = bundled_root.join("starter");
+        write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        let mut manager = PluginManager::new(config);
+
+        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
+            .expect("list command should succeed");
+        assert!(!list.reload_runtime);
+        assert!(list.message.contains("starter"));
+        assert!(list.message.contains("v0.1.0"));
+        assert!(list.message.contains("disabled"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
     }
 }

+ 2 - 6
rust/crates/compat-harness/src/lib.rs

@@ -74,11 +74,7 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
         candidates.push(ancestor.join("clawd-code"));
     }
 
-    candidates.push(
-        primary_repo_root
-            .join("reference-source")
-            .join("claw-code"),
-    );
+    candidates.push(primary_repo_root.join("reference-source").join("claw-code"));
     candidates.push(primary_repo_root.join("vendor").join("claw-code"));
 
     let mut deduped = Vec::new();
@@ -196,7 +192,7 @@ pub fn extract_bootstrap_plan(source: &str) -> BootstrapPlan {
     if source.contains("--dump-system-prompt") {
         phases.push(BootstrapPhase::SystemPromptFastPath);
     }
-    if source.contains("--claw-in-chrome-mcp") {
+    if source.contains("--claude-in-chrome-mcp") {
         phases.push(BootstrapPhase::ChromeMcpFastPath);
     }
     if source.contains("--daemon-worker") {

+ 13 - 0
rust/crates/plugins/Cargo.toml

@@ -0,0 +1,13 @@
+[package]
+name = "plugins"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[lints]
+workspace = true

+ 10 - 0
rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json

@@ -0,0 +1,10 @@
+{
+  "name": "example-bundled",
+  "version": "0.1.0",
+  "description": "Example bundled plugin scaffold for the Rust plugin system",
+  "defaultEnabled": false,
+  "hooks": {
+    "PreToolUse": ["./hooks/pre.sh"],
+    "PostToolUse": ["./hooks/post.sh"]
+  }
+}

+ 2 - 0
rust/crates/plugins/bundled/example-bundled/hooks/post.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+printf '%s\n' 'example bundled post hook'

+ 2 - 0
rust/crates/plugins/bundled/example-bundled/hooks/pre.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+printf '%s\n' 'example bundled pre hook'

+ 10 - 0
rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json

@@ -0,0 +1,10 @@
+{
+  "name": "sample-hooks",
+  "version": "0.1.0",
+  "description": "Bundled sample plugin scaffold for hook integration tests.",
+  "defaultEnabled": false,
+  "hooks": {
+    "PreToolUse": ["./hooks/pre.sh"],
+    "PostToolUse": ["./hooks/post.sh"]
+  }
+}

+ 2 - 0
rust/crates/plugins/bundled/sample-hooks/hooks/post.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+printf 'sample bundled post hook'

+ 2 - 0
rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+printf 'sample bundled pre hook'

+ 395 - 0
rust/crates/plugins/src/hooks.rs

@@ -0,0 +1,395 @@
+use std::ffi::OsStr;
+use std::path::Path;
+use std::process::Command;
+
+use serde_json::json;
+
+use crate::{PluginError, PluginHooks, PluginRegistry};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HookEvent {
+    PreToolUse,
+    PostToolUse,
+}
+
+impl HookEvent {
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::PreToolUse => "PreToolUse",
+            Self::PostToolUse => "PostToolUse",
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookRunResult {
+    denied: bool,
+    messages: Vec<String>,
+}
+
+impl HookRunResult {
+    #[must_use]
+    pub fn allow(messages: Vec<String>) -> Self {
+        Self {
+            denied: false,
+            messages,
+        }
+    }
+
+    #[must_use]
+    pub fn is_denied(&self) -> bool {
+        self.denied
+    }
+
+    #[must_use]
+    pub fn messages(&self) -> &[String] {
+        &self.messages
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct HookRunner {
+    hooks: PluginHooks,
+}
+
+impl HookRunner {
+    #[must_use]
+    pub fn new(hooks: PluginHooks) -> Self {
+        Self { hooks }
+    }
+
+    pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
+        Ok(Self::new(plugin_registry.aggregated_hooks()?))
+    }
+
+    #[must_use]
+    pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
+        self.run_commands(
+            HookEvent::PreToolUse,
+            &self.hooks.pre_tool_use,
+            tool_name,
+            tool_input,
+            None,
+            false,
+        )
+    }
+
+    #[must_use]
+    pub fn run_post_tool_use(
+        &self,
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: &str,
+        is_error: bool,
+    ) -> HookRunResult {
+        self.run_commands(
+            HookEvent::PostToolUse,
+            &self.hooks.post_tool_use,
+            tool_name,
+            tool_input,
+            Some(tool_output),
+            is_error,
+        )
+    }
+
+    fn run_commands(
+        &self,
+        event: HookEvent,
+        commands: &[String],
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: Option<&str>,
+        is_error: bool,
+    ) -> HookRunResult {
+        if commands.is_empty() {
+            return HookRunResult::allow(Vec::new());
+        }
+
+        let payload = json!({
+            "hook_event_name": event.as_str(),
+            "tool_name": tool_name,
+            "tool_input": parse_tool_input(tool_input),
+            "tool_input_json": tool_input,
+            "tool_output": tool_output,
+            "tool_result_is_error": is_error,
+        })
+        .to_string();
+
+        let mut messages = Vec::new();
+
+        for command in commands {
+            match self.run_command(
+                command,
+                event,
+                tool_name,
+                tool_input,
+                tool_output,
+                is_error,
+                &payload,
+            ) {
+                HookCommandOutcome::Allow { message } => {
+                    if let Some(message) = message {
+                        messages.push(message);
+                    }
+                }
+                HookCommandOutcome::Deny { message } => {
+                    messages.push(message.unwrap_or_else(|| {
+                        format!("{} hook denied tool `{tool_name}`", event.as_str())
+                    }));
+                    return HookRunResult {
+                        denied: true,
+                        messages,
+                    };
+                }
+                HookCommandOutcome::Warn { message } => messages.push(message),
+            }
+        }
+
+        HookRunResult::allow(messages)
+    }
+
+    #[allow(clippy::too_many_arguments, clippy::unused_self)]
+    fn run_command(
+        &self,
+        command: &str,
+        event: HookEvent,
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: Option<&str>,
+        is_error: bool,
+        payload: &str,
+    ) -> HookCommandOutcome {
+        let mut child = shell_command(command);
+        child.stdin(std::process::Stdio::piped());
+        child.stdout(std::process::Stdio::piped());
+        child.stderr(std::process::Stdio::piped());
+        child.env("HOOK_EVENT", event.as_str());
+        child.env("HOOK_TOOL_NAME", tool_name);
+        child.env("HOOK_TOOL_INPUT", tool_input);
+        child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
+        if let Some(tool_output) = tool_output {
+            child.env("HOOK_TOOL_OUTPUT", tool_output);
+        }
+
+        match child.output_with_stdin(payload.as_bytes()) {
+            Ok(output) => {
+                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+                let message = (!stdout.is_empty()).then_some(stdout);
+                match output.status.code() {
+                    Some(0) => HookCommandOutcome::Allow { message },
+                    Some(2) => HookCommandOutcome::Deny { message },
+                    Some(code) => HookCommandOutcome::Warn {
+                        message: format_hook_warning(
+                            command,
+                            code,
+                            message.as_deref(),
+                            stderr.as_str(),
+                        ),
+                    },
+                    None => HookCommandOutcome::Warn {
+                        message: format!(
+                            "{} hook `{command}` terminated by signal while handling `{tool_name}`",
+                            event.as_str()
+                        ),
+                    },
+                }
+            }
+            Err(error) => HookCommandOutcome::Warn {
+                message: format!(
+                    "{} hook `{command}` failed to start for `{tool_name}`: {error}",
+                    event.as_str()
+                ),
+            },
+        }
+    }
+}
+
+enum HookCommandOutcome {
+    Allow { message: Option<String> },
+    Deny { message: Option<String> },
+    Warn { message: String },
+}
+
+fn parse_tool_input(tool_input: &str) -> serde_json::Value {
+    serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
+}
+
+fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
+    let mut message =
+        format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
+    if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
+        message.push_str(": ");
+        message.push_str(stdout);
+    } else if !stderr.is_empty() {
+        message.push_str(": ");
+        message.push_str(stderr);
+    }
+    message
+}
+
+fn shell_command(command: &str) -> CommandWithStdin {
+    #[cfg(windows)]
+    let command_builder = {
+        let mut command_builder = Command::new("cmd");
+        command_builder.arg("/C").arg(command);
+        CommandWithStdin::new(command_builder)
+    };
+
+    #[cfg(not(windows))]
+    let command_builder = if Path::new(command).exists() {
+        let mut command_builder = Command::new("sh");
+        command_builder.arg(command);
+        CommandWithStdin::new(command_builder)
+    } else {
+        let mut command_builder = Command::new("sh");
+        command_builder.arg("-lc").arg(command);
+        CommandWithStdin::new(command_builder)
+    };
+
+    command_builder
+}
+
+struct CommandWithStdin {
+    command: Command,
+}
+
+impl CommandWithStdin {
+    fn new(command: Command) -> Self {
+        Self { command }
+    }
+
+    fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stdin(cfg);
+        self
+    }
+
+    fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stdout(cfg);
+        self
+    }
+
+    fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stderr(cfg);
+        self
+    }
+
+    fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
+    where
+        K: AsRef<OsStr>,
+        V: AsRef<OsStr>,
+    {
+        self.command.env(key, value);
+        self
+    }
+
+    fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
+        let mut child = self.command.spawn()?;
+        if let Some(mut child_stdin) = child.stdin.take() {
+            use std::io::Write as _;
+            child_stdin.write_all(stdin)?;
+        }
+        child.wait_with_output()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{HookRunResult, HookRunner};
+    use crate::{PluginManager, PluginManagerConfig};
+    use std::fs;
+    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!("plugins-hook-runner-{label}-{nanos}"))
+    }
+
+    fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &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"),
+            format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
+        )
+        .expect("write pre hook");
+        fs::write(
+            root.join("hooks").join("post.sh"),
+            format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
+        )
+        .expect("write 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 collects_and_runs_hooks_from_enabled_plugins() {
+        let config_home = temp_dir("config");
+        let first_source_root = temp_dir("source-a");
+        let second_source_root = temp_dir("source-b");
+        write_hook_plugin(
+            &first_source_root,
+            "first",
+            "plugin pre one",
+            "plugin post one",
+        );
+        write_hook_plugin(
+            &second_source_root,
+            "second",
+            "plugin pre two",
+            "plugin post two",
+        );
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(first_source_root.to_str().expect("utf8 path"))
+            .expect("first plugin install should succeed");
+        manager
+            .install(second_source_root.to_str().expect("utf8 path"))
+            .expect("second plugin install should succeed");
+        let registry = manager.plugin_registry().expect("registry should build");
+
+        let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
+
+        assert_eq!(
+            runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
+            HookRunResult::allow(vec![
+                "plugin pre one".to_string(),
+                "plugin pre two".to_string(),
+            ])
+        );
+        assert_eq!(
+            runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
+            HookRunResult::allow(vec![
+                "plugin post one".to_string(),
+                "plugin post two".to_string(),
+            ])
+        );
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(first_source_root);
+        let _ = fs::remove_dir_all(second_source_root);
+    }
+
+    #[test]
+    fn pre_tool_use_denies_when_plugin_hook_exits_two() {
+        let runner = HookRunner::new(crate::PluginHooks {
+            pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
+            post_tool_use: Vec::new(),
+        });
+
+        let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
+
+        assert!(result.is_denied());
+        assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
+    }
+}

+ 2929 - 0
rust/crates/plugins/src/lib.rs

@@ -0,0 +1,2929 @@
+mod hooks;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::{Display, Formatter};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+pub use hooks::{HookEvent, HookRunResult, HookRunner};
+
+const EXTERNAL_MARKETPLACE: &str = "external";
+const BUILTIN_MARKETPLACE: &str = "builtin";
+const BUNDLED_MARKETPLACE: &str = "bundled";
+const SETTINGS_FILE_NAME: &str = "settings.json";
+const REGISTRY_FILE_NAME: &str = "installed.json";
+const MANIFEST_FILE_NAME: &str = "plugin.json";
+const MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PluginKind {
+    Builtin,
+    Bundled,
+    External,
+}
+
+impl Display for PluginKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Builtin => write!(f, "builtin"),
+            Self::Bundled => write!(f, "bundled"),
+            Self::External => write!(f, "external"),
+        }
+    }
+}
+
+impl PluginKind {
+    #[must_use]
+    fn marketplace(self) -> &'static str {
+        match self {
+            Self::Builtin => BUILTIN_MARKETPLACE,
+            Self::Bundled => BUNDLED_MARKETPLACE,
+            Self::External => EXTERNAL_MARKETPLACE,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginMetadata {
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    pub kind: PluginKind,
+    pub source: String,
+    pub default_enabled: bool,
+    pub root: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginHooks {
+    #[serde(rename = "PreToolUse", default)]
+    pub pre_tool_use: Vec<String>,
+    #[serde(rename = "PostToolUse", default)]
+    pub post_tool_use: Vec<String>,
+}
+
+impl PluginHooks {
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
+    }
+
+    #[must_use]
+    pub fn merged_with(&self, other: &Self) -> Self {
+        let mut merged = self.clone();
+        merged
+            .pre_tool_use
+            .extend(other.pre_tool_use.iter().cloned());
+        merged
+            .post_tool_use
+            .extend(other.post_tool_use.iter().cloned());
+        merged
+    }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginLifecycle {
+    #[serde(rename = "Init", default)]
+    pub init: Vec<String>,
+    #[serde(rename = "Shutdown", default)]
+    pub shutdown: Vec<String>,
+}
+
+impl PluginLifecycle {
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.init.is_empty() && self.shutdown.is_empty()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginManifest {
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    pub permissions: Vec<PluginPermission>,
+    #[serde(rename = "defaultEnabled", default)]
+    pub default_enabled: bool,
+    #[serde(default)]
+    pub hooks: PluginHooks,
+    #[serde(default)]
+    pub lifecycle: PluginLifecycle,
+    #[serde(default)]
+    pub tools: Vec<PluginToolManifest>,
+    #[serde(default)]
+    pub commands: Vec<PluginCommandManifest>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PluginPermission {
+    Read,
+    Write,
+    Execute,
+}
+
+impl PluginPermission {
+    #[must_use]
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Read => "read",
+            Self::Write => "write",
+            Self::Execute => "execute",
+        }
+    }
+
+    fn parse(value: &str) -> Option<Self> {
+        match value {
+            "read" => Some(Self::Read),
+            "write" => Some(Self::Write),
+            "execute" => Some(Self::Execute),
+            _ => None,
+        }
+    }
+}
+
+impl AsRef<str> for PluginPermission {
+    fn as_ref(&self) -> &str {
+        self.as_str()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginToolManifest {
+    pub name: String,
+    pub description: String,
+    #[serde(rename = "inputSchema")]
+    pub input_schema: Value,
+    pub command: String,
+    #[serde(default)]
+    pub args: Vec<String>,
+    pub required_permission: PluginToolPermission,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum PluginToolPermission {
+    ReadOnly,
+    WorkspaceWrite,
+    DangerFullAccess,
+}
+
+impl PluginToolPermission {
+    #[must_use]
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::ReadOnly => "read-only",
+            Self::WorkspaceWrite => "workspace-write",
+            Self::DangerFullAccess => "danger-full-access",
+        }
+    }
+
+    fn parse(value: &str) -> Option<Self> {
+        match value {
+            "read-only" => Some(Self::ReadOnly),
+            "workspace-write" => Some(Self::WorkspaceWrite),
+            "danger-full-access" => Some(Self::DangerFullAccess),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginToolDefinition {
+    pub name: String,
+    #[serde(default)]
+    pub description: Option<String>,
+    #[serde(rename = "inputSchema")]
+    pub input_schema: Value,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginCommandManifest {
+    pub name: String,
+    pub description: String,
+    pub command: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct RawPluginManifest {
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    #[serde(default)]
+    pub permissions: Vec<String>,
+    #[serde(rename = "defaultEnabled", default)]
+    pub default_enabled: bool,
+    #[serde(default)]
+    pub hooks: PluginHooks,
+    #[serde(default)]
+    pub lifecycle: PluginLifecycle,
+    #[serde(default)]
+    pub tools: Vec<RawPluginToolManifest>,
+    #[serde(default)]
+    pub commands: Vec<PluginCommandManifest>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct RawPluginToolManifest {
+    pub name: String,
+    pub description: String,
+    #[serde(rename = "inputSchema")]
+    pub input_schema: Value,
+    pub command: String,
+    #[serde(default)]
+    pub args: Vec<String>,
+    #[serde(
+        rename = "requiredPermission",
+        default = "default_tool_permission_label"
+    )]
+    pub required_permission: String,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct PluginTool {
+    plugin_id: String,
+    plugin_name: String,
+    definition: PluginToolDefinition,
+    command: String,
+    args: Vec<String>,
+    required_permission: PluginToolPermission,
+    root: Option<PathBuf>,
+}
+
+impl PluginTool {
+    #[must_use]
+    pub fn new(
+        plugin_id: impl Into<String>,
+        plugin_name: impl Into<String>,
+        definition: PluginToolDefinition,
+        command: impl Into<String>,
+        args: Vec<String>,
+        required_permission: PluginToolPermission,
+        root: Option<PathBuf>,
+    ) -> Self {
+        Self {
+            plugin_id: plugin_id.into(),
+            plugin_name: plugin_name.into(),
+            definition,
+            command: command.into(),
+            args,
+            required_permission,
+            root,
+        }
+    }
+
+    #[must_use]
+    pub fn plugin_id(&self) -> &str {
+        &self.plugin_id
+    }
+
+    #[must_use]
+    pub fn definition(&self) -> &PluginToolDefinition {
+        &self.definition
+    }
+
+    #[must_use]
+    pub fn required_permission(&self) -> &str {
+        self.required_permission.as_str()
+    }
+
+    pub fn execute(&self, input: &Value) -> Result<String, PluginError> {
+        let input_json = input.to_string();
+        let mut process = Command::new(&self.command);
+        process
+            .args(&self.args)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .env("CLAWD_PLUGIN_ID", &self.plugin_id)
+            .env("CLAWD_PLUGIN_NAME", &self.plugin_name)
+            .env("CLAWD_TOOL_NAME", &self.definition.name)
+            .env("CLAWD_TOOL_INPUT", &input_json);
+        if let Some(root) = &self.root {
+            process
+                .current_dir(root)
+                .env("CLAWD_PLUGIN_ROOT", root.display().to_string());
+        }
+
+        let mut child = process.spawn()?;
+        if let Some(stdin) = child.stdin.as_mut() {
+            use std::io::Write as _;
+            stdin.write_all(input_json.as_bytes())?;
+        }
+
+        let output = child.wait_with_output()?;
+        if output.status.success() {
+            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+        } else {
+            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+            Err(PluginError::CommandFailed(format!(
+                "plugin tool `{}` from `{}` failed for `{}`: {}",
+                self.definition.name,
+                self.plugin_id,
+                self.command,
+                if stderr.is_empty() {
+                    format!("exit status {}", output.status)
+                } else {
+                    stderr
+                }
+            )))
+        }
+    }
+}
+
+fn default_tool_permission_label() -> String {
+    "danger-full-access".to_string()
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum PluginInstallSource {
+    LocalPath { path: PathBuf },
+    GitUrl { url: String },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPluginRecord {
+    #[serde(default = "default_plugin_kind")]
+    pub kind: PluginKind,
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    pub install_path: PathBuf,
+    pub source: PluginInstallSource,
+    pub installed_at_unix_ms: u128,
+    pub updated_at_unix_ms: u128,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPluginRegistry {
+    #[serde(default)]
+    pub plugins: BTreeMap<String, InstalledPluginRecord>,
+}
+
+fn default_plugin_kind() -> PluginKind {
+    PluginKind::External
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct BuiltinPlugin {
+    metadata: PluginMetadata,
+    hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
+    tools: Vec<PluginTool>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct BundledPlugin {
+    metadata: PluginMetadata,
+    hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
+    tools: Vec<PluginTool>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ExternalPlugin {
+    metadata: PluginMetadata,
+    hooks: PluginHooks,
+    lifecycle: PluginLifecycle,
+    tools: Vec<PluginTool>,
+}
+
+pub trait Plugin {
+    fn metadata(&self) -> &PluginMetadata;
+    fn hooks(&self) -> &PluginHooks;
+    fn lifecycle(&self) -> &PluginLifecycle;
+    fn tools(&self) -> &[PluginTool];
+    fn validate(&self) -> Result<(), PluginError>;
+    fn initialize(&self) -> Result<(), PluginError>;
+    fn shutdown(&self) -> Result<(), PluginError>;
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum PluginDefinition {
+    Builtin(BuiltinPlugin),
+    Bundled(BundledPlugin),
+    External(ExternalPlugin),
+}
+
+impl Plugin for BuiltinPlugin {
+    fn metadata(&self) -> &PluginMetadata {
+        &self.metadata
+    }
+
+    fn hooks(&self) -> &PluginHooks {
+        &self.hooks
+    }
+
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
+    fn tools(&self) -> &[PluginTool] {
+        &self.tools
+    }
+
+    fn validate(&self) -> Result<(), PluginError> {
+        Ok(())
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        Ok(())
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        Ok(())
+    }
+}
+
+impl Plugin for BundledPlugin {
+    fn metadata(&self) -> &PluginMetadata {
+        &self.metadata
+    }
+
+    fn hooks(&self) -> &PluginHooks {
+        &self.hooks
+    }
+
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
+    fn tools(&self) -> &[PluginTool] {
+        &self.tools
+    }
+
+    fn validate(&self) -> Result<(), PluginError> {
+        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+        validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
+        validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "init",
+            &self.lifecycle.init,
+        )
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "shutdown",
+            &self.lifecycle.shutdown,
+        )
+    }
+}
+
+impl Plugin for ExternalPlugin {
+    fn metadata(&self) -> &PluginMetadata {
+        &self.metadata
+    }
+
+    fn hooks(&self) -> &PluginHooks {
+        &self.hooks
+    }
+
+    fn lifecycle(&self) -> &PluginLifecycle {
+        &self.lifecycle
+    }
+
+    fn tools(&self) -> &[PluginTool] {
+        &self.tools
+    }
+
+    fn validate(&self) -> Result<(), PluginError> {
+        validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+        validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
+        validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "init",
+            &self.lifecycle.init,
+        )
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        run_lifecycle_commands(
+            self.metadata(),
+            self.lifecycle(),
+            "shutdown",
+            &self.lifecycle.shutdown,
+        )
+    }
+}
+
+impl Plugin for PluginDefinition {
+    fn metadata(&self) -> &PluginMetadata {
+        match self {
+            Self::Builtin(plugin) => plugin.metadata(),
+            Self::Bundled(plugin) => plugin.metadata(),
+            Self::External(plugin) => plugin.metadata(),
+        }
+    }
+
+    fn hooks(&self) -> &PluginHooks {
+        match self {
+            Self::Builtin(plugin) => plugin.hooks(),
+            Self::Bundled(plugin) => plugin.hooks(),
+            Self::External(plugin) => plugin.hooks(),
+        }
+    }
+
+    fn lifecycle(&self) -> &PluginLifecycle {
+        match self {
+            Self::Builtin(plugin) => plugin.lifecycle(),
+            Self::Bundled(plugin) => plugin.lifecycle(),
+            Self::External(plugin) => plugin.lifecycle(),
+        }
+    }
+
+    fn tools(&self) -> &[PluginTool] {
+        match self {
+            Self::Builtin(plugin) => plugin.tools(),
+            Self::Bundled(plugin) => plugin.tools(),
+            Self::External(plugin) => plugin.tools(),
+        }
+    }
+
+    fn validate(&self) -> Result<(), PluginError> {
+        match self {
+            Self::Builtin(plugin) => plugin.validate(),
+            Self::Bundled(plugin) => plugin.validate(),
+            Self::External(plugin) => plugin.validate(),
+        }
+    }
+
+    fn initialize(&self) -> Result<(), PluginError> {
+        match self {
+            Self::Builtin(plugin) => plugin.initialize(),
+            Self::Bundled(plugin) => plugin.initialize(),
+            Self::External(plugin) => plugin.initialize(),
+        }
+    }
+
+    fn shutdown(&self) -> Result<(), PluginError> {
+        match self {
+            Self::Builtin(plugin) => plugin.shutdown(),
+            Self::Bundled(plugin) => plugin.shutdown(),
+            Self::External(plugin) => plugin.shutdown(),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct RegisteredPlugin {
+    definition: PluginDefinition,
+    enabled: bool,
+}
+
+impl RegisteredPlugin {
+    #[must_use]
+    pub fn new(definition: PluginDefinition, enabled: bool) -> Self {
+        Self {
+            definition,
+            enabled,
+        }
+    }
+
+    #[must_use]
+    pub fn metadata(&self) -> &PluginMetadata {
+        self.definition.metadata()
+    }
+
+    #[must_use]
+    pub fn hooks(&self) -> &PluginHooks {
+        self.definition.hooks()
+    }
+
+    #[must_use]
+    pub fn tools(&self) -> &[PluginTool] {
+        self.definition.tools()
+    }
+
+    #[must_use]
+    pub fn is_enabled(&self) -> bool {
+        self.enabled
+    }
+
+    pub fn validate(&self) -> Result<(), PluginError> {
+        self.definition.validate()
+    }
+
+    pub fn initialize(&self) -> Result<(), PluginError> {
+        self.definition.initialize()
+    }
+
+    pub fn shutdown(&self) -> Result<(), PluginError> {
+        self.definition.shutdown()
+    }
+
+    #[must_use]
+    pub fn summary(&self) -> PluginSummary {
+        PluginSummary {
+            metadata: self.metadata().clone(),
+            enabled: self.enabled,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginSummary {
+    pub metadata: PluginMetadata,
+    pub enabled: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct PluginRegistry {
+    plugins: Vec<RegisteredPlugin>,
+}
+
+impl PluginRegistry {
+    #[must_use]
+    pub fn new(mut plugins: Vec<RegisteredPlugin>) -> Self {
+        plugins.sort_by(|left, right| left.metadata().id.cmp(&right.metadata().id));
+        Self { plugins }
+    }
+
+    #[must_use]
+    pub fn plugins(&self) -> &[RegisteredPlugin] {
+        &self.plugins
+    }
+
+    #[must_use]
+    pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
+        self.plugins
+            .iter()
+            .find(|plugin| plugin.metadata().id == plugin_id)
+    }
+
+    #[must_use]
+    pub fn contains(&self, plugin_id: &str) -> bool {
+        self.get(plugin_id).is_some()
+    }
+
+    #[must_use]
+    pub fn summaries(&self) -> Vec<PluginSummary> {
+        self.plugins.iter().map(RegisteredPlugin::summary).collect()
+    }
+
+    pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
+        self.plugins
+            .iter()
+            .filter(|plugin| plugin.is_enabled())
+            .try_fold(PluginHooks::default(), |acc, plugin| {
+                plugin.validate()?;
+                Ok(acc.merged_with(plugin.hooks()))
+            })
+    }
+
+    pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
+        let mut tools = Vec::new();
+        let mut seen_names = BTreeMap::new();
+        for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
+            plugin.validate()?;
+            for tool in plugin.tools() {
+                if let Some(existing_plugin) =
+                    seen_names.insert(tool.definition().name.clone(), tool.plugin_id().to_string())
+                {
+                    return Err(PluginError::InvalidManifest(format!(
+                        "plugin tool `{}` is defined by both `{existing_plugin}` and `{}`",
+                        tool.definition().name,
+                        tool.plugin_id()
+                    )));
+                }
+                tools.push(tool.clone());
+            }
+        }
+        Ok(tools)
+    }
+
+    pub fn initialize(&self) -> Result<(), PluginError> {
+        for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
+            plugin.validate()?;
+            plugin.initialize()?;
+        }
+        Ok(())
+    }
+
+    pub fn shutdown(&self) -> Result<(), PluginError> {
+        for plugin in self
+            .plugins
+            .iter()
+            .rev()
+            .filter(|plugin| plugin.is_enabled())
+        {
+            plugin.shutdown()?;
+        }
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginManagerConfig {
+    pub config_home: PathBuf,
+    pub enabled_plugins: BTreeMap<String, bool>,
+    pub external_dirs: Vec<PathBuf>,
+    pub install_root: Option<PathBuf>,
+    pub registry_path: Option<PathBuf>,
+    pub bundled_root: Option<PathBuf>,
+}
+
+impl PluginManagerConfig {
+    #[must_use]
+    pub fn new(config_home: impl Into<PathBuf>) -> Self {
+        Self {
+            config_home: config_home.into(),
+            enabled_plugins: BTreeMap::new(),
+            external_dirs: Vec::new(),
+            install_root: None,
+            registry_path: None,
+            bundled_root: None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginManager {
+    config: PluginManagerConfig,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InstallOutcome {
+    pub plugin_id: String,
+    pub version: String,
+    pub install_path: PathBuf,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateOutcome {
+    pub plugin_id: String,
+    pub old_version: String,
+    pub new_version: String,
+    pub install_path: PathBuf,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PluginManifestValidationError {
+    EmptyField {
+        field: &'static str,
+    },
+    EmptyEntryField {
+        kind: &'static str,
+        field: &'static str,
+        name: Option<String>,
+    },
+    InvalidPermission {
+        permission: String,
+    },
+    DuplicatePermission {
+        permission: String,
+    },
+    DuplicateEntry {
+        kind: &'static str,
+        name: String,
+    },
+    MissingPath {
+        kind: &'static str,
+        path: PathBuf,
+    },
+    InvalidToolInputSchema {
+        tool_name: String,
+    },
+    InvalidToolRequiredPermission {
+        tool_name: String,
+        permission: String,
+    },
+}
+
+impl Display for PluginManifestValidationError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::EmptyField { field } => {
+                write!(f, "plugin manifest {field} cannot be empty")
+            }
+            Self::EmptyEntryField { kind, field, name } => match name {
+                Some(name) if !name.is_empty() => {
+                    write!(f, "plugin {kind} `{name}` {field} cannot be empty")
+                }
+                _ => write!(f, "plugin {kind} {field} cannot be empty"),
+            },
+            Self::InvalidPermission { permission } => {
+                write!(
+                    f,
+                    "plugin manifest permission `{permission}` must be one of read, write, or execute"
+                )
+            }
+            Self::DuplicatePermission { permission } => {
+                write!(f, "plugin manifest permission `{permission}` is duplicated")
+            }
+            Self::DuplicateEntry { kind, name } => {
+                write!(f, "plugin {kind} `{name}` is duplicated")
+            }
+            Self::MissingPath { kind, path } => {
+                write!(f, "{kind} path `{}` does not exist", path.display())
+            }
+            Self::InvalidToolInputSchema { tool_name } => {
+                write!(
+                    f,
+                    "plugin tool `{tool_name}` inputSchema must be a JSON object"
+                )
+            }
+            Self::InvalidToolRequiredPermission {
+                tool_name,
+                permission,
+            } => write!(
+                f,
+                "plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
+            ),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum PluginError {
+    Io(std::io::Error),
+    Json(serde_json::Error),
+    ManifestValidation(Vec<PluginManifestValidationError>),
+    InvalidManifest(String),
+    NotFound(String),
+    CommandFailed(String),
+}
+
+impl Display for PluginError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Io(error) => write!(f, "{error}"),
+            Self::Json(error) => write!(f, "{error}"),
+            Self::ManifestValidation(errors) => {
+                for (index, error) in errors.iter().enumerate() {
+                    if index > 0 {
+                        write!(f, "; ")?;
+                    }
+                    write!(f, "{error}")?;
+                }
+                Ok(())
+            }
+            Self::InvalidManifest(message)
+            | Self::NotFound(message)
+            | Self::CommandFailed(message) => write!(f, "{message}"),
+        }
+    }
+}
+
+impl std::error::Error for PluginError {}
+
+impl From<std::io::Error> for PluginError {
+    fn from(value: std::io::Error) -> Self {
+        Self::Io(value)
+    }
+}
+
+impl From<serde_json::Error> for PluginError {
+    fn from(value: serde_json::Error) -> Self {
+        Self::Json(value)
+    }
+}
+
+impl PluginManager {
+    #[must_use]
+    pub fn new(config: PluginManagerConfig) -> Self {
+        Self { config }
+    }
+
+    #[must_use]
+    pub fn bundled_root() -> PathBuf {
+        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled")
+    }
+
+    #[must_use]
+    pub fn install_root(&self) -> PathBuf {
+        self.config
+            .install_root
+            .clone()
+            .unwrap_or_else(|| self.config.config_home.join("plugins").join("installed"))
+    }
+
+    #[must_use]
+    pub fn registry_path(&self) -> PathBuf {
+        self.config.registry_path.clone().unwrap_or_else(|| {
+            self.config
+                .config_home
+                .join("plugins")
+                .join(REGISTRY_FILE_NAME)
+        })
+    }
+
+    #[must_use]
+    pub fn settings_path(&self) -> PathBuf {
+        self.config.config_home.join(SETTINGS_FILE_NAME)
+    }
+
+    pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
+        Ok(PluginRegistry::new(
+            self.discover_plugins()?
+                .into_iter()
+                .map(|plugin| {
+                    let enabled = self.is_enabled(plugin.metadata());
+                    RegisteredPlugin::new(plugin, enabled)
+                })
+                .collect(),
+        ))
+    }
+
+    pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
+        Ok(self.plugin_registry()?.summaries())
+    }
+
+    pub fn list_installed_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
+        Ok(self.installed_plugin_registry()?.summaries())
+    }
+
+    pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
+        self.sync_bundled_plugins()?;
+        let mut plugins = builtin_plugins();
+        plugins.extend(self.discover_installed_plugins()?);
+        plugins.extend(self.discover_external_directory_plugins(&plugins)?);
+        Ok(plugins)
+    }
+
+    pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
+        self.plugin_registry()?.aggregated_hooks()
+    }
+
+    pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
+        self.plugin_registry()?.aggregated_tools()
+    }
+
+    pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
+        let path = resolve_local_source(source)?;
+        load_plugin_from_directory(&path)
+    }
+
+    pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
+        let install_source = parse_install_source(source)?;
+        let temp_root = self.install_root().join(".tmp");
+        let staged_source = materialize_source(&install_source, &temp_root)?;
+        let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
+        let manifest = load_plugin_from_directory(&staged_source)?;
+
+        let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
+        let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
+        if install_path.exists() {
+            fs::remove_dir_all(&install_path)?;
+        }
+        copy_dir_all(&staged_source, &install_path)?;
+        if cleanup_source {
+            let _ = fs::remove_dir_all(&staged_source);
+        }
+
+        let now = unix_time_ms();
+        let record = InstalledPluginRecord {
+            kind: PluginKind::External,
+            id: plugin_id.clone(),
+            name: manifest.name,
+            version: manifest.version.clone(),
+            description: manifest.description,
+            install_path: install_path.clone(),
+            source: install_source,
+            installed_at_unix_ms: now,
+            updated_at_unix_ms: now,
+        };
+
+        let mut registry = self.load_registry()?;
+        registry.plugins.insert(plugin_id.clone(), record);
+        self.store_registry(&registry)?;
+        self.write_enabled_state(&plugin_id, Some(true))?;
+        self.config.enabled_plugins.insert(plugin_id.clone(), true);
+
+        Ok(InstallOutcome {
+            plugin_id,
+            version: manifest.version,
+            install_path,
+        })
+    }
+
+    pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+        self.ensure_known_plugin(plugin_id)?;
+        self.write_enabled_state(plugin_id, Some(true))?;
+        self.config
+            .enabled_plugins
+            .insert(plugin_id.to_string(), true);
+        Ok(())
+    }
+
+    pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+        self.ensure_known_plugin(plugin_id)?;
+        self.write_enabled_state(plugin_id, Some(false))?;
+        self.config
+            .enabled_plugins
+            .insert(plugin_id.to_string(), false);
+        Ok(())
+    }
+
+    pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+        let mut registry = self.load_registry()?;
+        let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
+            PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
+        })?;
+        if record.kind == PluginKind::Bundled {
+            registry.plugins.insert(plugin_id.to_string(), record);
+            return Err(PluginError::CommandFailed(format!(
+                "plugin `{plugin_id}` is bundled and managed automatically; disable it instead"
+            )));
+        }
+        if record.install_path.exists() {
+            fs::remove_dir_all(&record.install_path)?;
+        }
+        self.store_registry(&registry)?;
+        self.write_enabled_state(plugin_id, None)?;
+        self.config.enabled_plugins.remove(plugin_id);
+        Ok(())
+    }
+
+    pub fn update(&mut self, plugin_id: &str) -> Result<UpdateOutcome, PluginError> {
+        let mut registry = self.load_registry()?;
+        let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| {
+            PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
+        })?;
+
+        let temp_root = self.install_root().join(".tmp");
+        let staged_source = materialize_source(&record.source, &temp_root)?;
+        let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
+        let manifest = load_plugin_from_directory(&staged_source)?;
+
+        if record.install_path.exists() {
+            fs::remove_dir_all(&record.install_path)?;
+        }
+        copy_dir_all(&staged_source, &record.install_path)?;
+        if cleanup_source {
+            let _ = fs::remove_dir_all(&staged_source);
+        }
+
+        let updated_record = InstalledPluginRecord {
+            version: manifest.version.clone(),
+            description: manifest.description,
+            updated_at_unix_ms: unix_time_ms(),
+            ..record.clone()
+        };
+        registry
+            .plugins
+            .insert(plugin_id.to_string(), updated_record);
+        self.store_registry(&registry)?;
+
+        Ok(UpdateOutcome {
+            plugin_id: plugin_id.to_string(),
+            old_version: record.version,
+            new_version: manifest.version,
+            install_path: record.install_path,
+        })
+    }
+
+    fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
+        let mut registry = self.load_registry()?;
+        let mut plugins = Vec::new();
+        let mut seen_ids = BTreeSet::<String>::new();
+        let mut seen_paths = BTreeSet::<PathBuf>::new();
+        let mut stale_registry_ids = Vec::new();
+
+        for install_path in discover_plugin_dirs(&self.install_root())? {
+            let matched_record = registry
+                .plugins
+                .values()
+                .find(|record| record.install_path == install_path);
+            let kind = matched_record.map_or(PluginKind::External, |record| record.kind);
+            let source = matched_record.map_or_else(
+                || install_path.display().to_string(),
+                |record| describe_install_source(&record.source),
+            );
+            let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
+            if seen_ids.insert(plugin.metadata().id.clone()) {
+                seen_paths.insert(install_path);
+                plugins.push(plugin);
+            }
+        }
+
+        for record in registry.plugins.values() {
+            if seen_paths.contains(&record.install_path) {
+                continue;
+            }
+            if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err()
+            {
+                stale_registry_ids.push(record.id.clone());
+                continue;
+            }
+            let plugin = load_plugin_definition(
+                &record.install_path,
+                record.kind,
+                describe_install_source(&record.source),
+                record.kind.marketplace(),
+            )?;
+            if seen_ids.insert(plugin.metadata().id.clone()) {
+                seen_paths.insert(record.install_path.clone());
+                plugins.push(plugin);
+            }
+        }
+
+        if !stale_registry_ids.is_empty() {
+            for plugin_id in stale_registry_ids {
+                registry.plugins.remove(&plugin_id);
+            }
+            self.store_registry(&registry)?;
+        }
+
+        Ok(plugins)
+    }
+
+    fn discover_external_directory_plugins(
+        &self,
+        existing_plugins: &[PluginDefinition],
+    ) -> Result<Vec<PluginDefinition>, PluginError> {
+        let mut plugins = Vec::new();
+
+        for directory in &self.config.external_dirs {
+            for root in discover_plugin_dirs(directory)? {
+                let plugin = load_plugin_definition(
+                    &root,
+                    PluginKind::External,
+                    root.display().to_string(),
+                    EXTERNAL_MARKETPLACE,
+                )?;
+                if existing_plugins
+                    .iter()
+                    .chain(plugins.iter())
+                    .all(|existing| existing.metadata().id != plugin.metadata().id)
+                {
+                    plugins.push(plugin);
+                }
+            }
+        }
+
+        Ok(plugins)
+    }
+
+    fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
+        self.sync_bundled_plugins()?;
+        Ok(PluginRegistry::new(
+            self.discover_installed_plugins()?
+                .into_iter()
+                .map(|plugin| {
+                    let enabled = self.is_enabled(plugin.metadata());
+                    RegisteredPlugin::new(plugin, enabled)
+                })
+                .collect(),
+        ))
+    }
+
+    fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
+        let bundled_root = self
+            .config
+            .bundled_root
+            .clone()
+            .unwrap_or_else(Self::bundled_root);
+        let bundled_plugins = discover_plugin_dirs(&bundled_root)?;
+        let mut registry = self.load_registry()?;
+        let mut changed = false;
+        let install_root = self.install_root();
+        let mut active_bundled_ids = BTreeSet::new();
+
+        for source_root in bundled_plugins {
+            let manifest = load_plugin_from_directory(&source_root)?;
+            let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE);
+            active_bundled_ids.insert(plugin_id.clone());
+            let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
+            let now = unix_time_ms();
+            let existing_record = registry.plugins.get(&plugin_id);
+            let needs_sync = existing_record.is_none_or(|record| {
+                record.kind != PluginKind::Bundled
+                    || record.version != manifest.version
+                    || record.name != manifest.name
+                    || record.description != manifest.description
+                    || record.install_path != install_path
+                    || !record.install_path.exists()
+            });
+
+            if !needs_sync {
+                continue;
+            }
+
+            if install_path.exists() {
+                fs::remove_dir_all(&install_path)?;
+            }
+            copy_dir_all(&source_root, &install_path)?;
+
+            let installed_at_unix_ms =
+                existing_record.map_or(now, |record| record.installed_at_unix_ms);
+            registry.plugins.insert(
+                plugin_id.clone(),
+                InstalledPluginRecord {
+                    kind: PluginKind::Bundled,
+                    id: plugin_id,
+                    name: manifest.name,
+                    version: manifest.version,
+                    description: manifest.description,
+                    install_path,
+                    source: PluginInstallSource::LocalPath { path: source_root },
+                    installed_at_unix_ms,
+                    updated_at_unix_ms: now,
+                },
+            );
+            changed = true;
+        }
+
+        let stale_bundled_ids = registry
+            .plugins
+            .iter()
+            .filter_map(|(plugin_id, record)| {
+                (record.kind == PluginKind::Bundled && !active_bundled_ids.contains(plugin_id))
+                    .then_some(plugin_id.clone())
+            })
+            .collect::<Vec<_>>();
+
+        for plugin_id in stale_bundled_ids {
+            if let Some(record) = registry.plugins.remove(&plugin_id) {
+                if record.install_path.exists() {
+                    fs::remove_dir_all(&record.install_path)?;
+                }
+                changed = true;
+            }
+        }
+
+        if changed {
+            self.store_registry(&registry)?;
+        }
+
+        Ok(())
+    }
+
+    fn is_enabled(&self, metadata: &PluginMetadata) -> bool {
+        self.config
+            .enabled_plugins
+            .get(&metadata.id)
+            .copied()
+            .unwrap_or(match metadata.kind {
+                PluginKind::External => false,
+                PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
+            })
+    }
+
+    fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
+        if self.plugin_registry()?.contains(plugin_id) {
+            Ok(())
+        } else {
+            Err(PluginError::NotFound(format!(
+                "plugin `{plugin_id}` is not installed or discoverable"
+            )))
+        }
+    }
+
+    fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> {
+        let path = self.registry_path();
+        match fs::read_to_string(&path) {
+            Ok(contents) => Ok(serde_json::from_str(&contents)?),
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+                Ok(InstalledPluginRegistry::default())
+            }
+            Err(error) => Err(PluginError::Io(error)),
+        }
+    }
+
+    fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> {
+        let path = self.registry_path();
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent)?;
+        }
+        fs::write(path, serde_json::to_string_pretty(registry)?)?;
+        Ok(())
+    }
+
+    fn write_enabled_state(
+        &self,
+        plugin_id: &str,
+        enabled: Option<bool>,
+    ) -> Result<(), PluginError> {
+        update_settings_json(&self.settings_path(), |root| {
+            let enabled_plugins = ensure_object(root, "enabledPlugins");
+            match enabled {
+                Some(value) => {
+                    enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
+                }
+                None => {
+                    enabled_plugins.remove(plugin_id);
+                }
+            }
+        })
+    }
+}
+
+#[must_use]
+pub fn builtin_plugins() -> Vec<PluginDefinition> {
+    vec![PluginDefinition::Builtin(BuiltinPlugin {
+        metadata: PluginMetadata {
+            id: plugin_id("example-builtin", BUILTIN_MARKETPLACE),
+            name: "example-builtin".to_string(),
+            version: "0.1.0".to_string(),
+            description: "Example built-in plugin scaffold for the Rust plugin system".to_string(),
+            kind: PluginKind::Builtin,
+            source: BUILTIN_MARKETPLACE.to_string(),
+            default_enabled: false,
+            root: None,
+        },
+        hooks: PluginHooks::default(),
+        lifecycle: PluginLifecycle::default(),
+        tools: Vec::new(),
+    })]
+}
+
+fn load_plugin_definition(
+    root: &Path,
+    kind: PluginKind,
+    source: String,
+    marketplace: &str,
+) -> Result<PluginDefinition, PluginError> {
+    let manifest = load_plugin_from_directory(root)?;
+    let metadata = PluginMetadata {
+        id: plugin_id(&manifest.name, marketplace),
+        name: manifest.name,
+        version: manifest.version,
+        description: manifest.description,
+        kind,
+        source,
+        default_enabled: manifest.default_enabled,
+        root: Some(root.to_path_buf()),
+    };
+    let hooks = resolve_hooks(root, &manifest.hooks);
+    let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
+    let tools = resolve_tools(root, &metadata.id, &metadata.name, &manifest.tools);
+    Ok(match kind {
+        PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+            tools,
+        }),
+        PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+            tools,
+        }),
+        PluginKind::External => PluginDefinition::External(ExternalPlugin {
+            metadata,
+            hooks,
+            lifecycle,
+            tools,
+        }),
+    })
+}
+
+pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
+    load_manifest_from_directory(root)
+}
+
+fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
+    let manifest_path = plugin_manifest_path(root)?;
+    load_manifest_from_path(root, &manifest_path)
+}
+
+fn load_manifest_from_path(
+    root: &Path,
+    manifest_path: &Path,
+) -> Result<PluginManifest, PluginError> {
+    let contents = fs::read_to_string(manifest_path).map_err(|error| {
+        PluginError::NotFound(format!(
+            "plugin manifest not found at {}: {error}",
+            manifest_path.display()
+        ))
+    })?;
+    let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
+    build_plugin_manifest(root, raw_manifest)
+}
+
+fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
+    let direct_path = root.join(MANIFEST_FILE_NAME);
+    if direct_path.exists() {
+        return Ok(direct_path);
+    }
+
+    let packaged_path = root.join(MANIFEST_RELATIVE_PATH);
+    if packaged_path.exists() {
+        return Ok(packaged_path);
+    }
+
+    Err(PluginError::NotFound(format!(
+        "plugin manifest not found at {} or {}",
+        direct_path.display(),
+        packaged_path.display()
+    )))
+}
+
+fn build_plugin_manifest(
+    root: &Path,
+    raw: RawPluginManifest,
+) -> Result<PluginManifest, PluginError> {
+    let mut errors = Vec::new();
+
+    validate_required_manifest_field("name", &raw.name, &mut errors);
+    validate_required_manifest_field("version", &raw.version, &mut errors);
+    validate_required_manifest_field("description", &raw.description, &mut errors);
+
+    let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
+    validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
+    validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
+    validate_command_entries(
+        root,
+        raw.lifecycle.init.iter(),
+        "lifecycle command",
+        &mut errors,
+    );
+    validate_command_entries(
+        root,
+        raw.lifecycle.shutdown.iter(),
+        "lifecycle command",
+        &mut errors,
+    );
+    let tools = build_manifest_tools(root, raw.tools, &mut errors);
+    let commands = build_manifest_commands(root, raw.commands, &mut errors);
+
+    if !errors.is_empty() {
+        return Err(PluginError::ManifestValidation(errors));
+    }
+
+    Ok(PluginManifest {
+        name: raw.name,
+        version: raw.version,
+        description: raw.description,
+        permissions,
+        default_enabled: raw.default_enabled,
+        hooks: raw.hooks,
+        lifecycle: raw.lifecycle,
+        tools,
+        commands,
+    })
+}
+
+fn validate_required_manifest_field(
+    field: &'static str,
+    value: &str,
+    errors: &mut Vec<PluginManifestValidationError>,
+) {
+    if value.trim().is_empty() {
+        errors.push(PluginManifestValidationError::EmptyField { field });
+    }
+}
+
+fn build_manifest_permissions(
+    permissions: &[String],
+    errors: &mut Vec<PluginManifestValidationError>,
+) -> Vec<PluginPermission> {
+    let mut seen = BTreeSet::new();
+    let mut validated = Vec::new();
+
+    for permission in permissions {
+        let permission = permission.trim();
+        if permission.is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "permission",
+                field: "value",
+                name: None,
+            });
+            continue;
+        }
+        if !seen.insert(permission.to_string()) {
+            errors.push(PluginManifestValidationError::DuplicatePermission {
+                permission: permission.to_string(),
+            });
+            continue;
+        }
+        match PluginPermission::parse(permission) {
+            Some(permission) => validated.push(permission),
+            None => errors.push(PluginManifestValidationError::InvalidPermission {
+                permission: permission.to_string(),
+            }),
+        }
+    }
+
+    validated
+}
+
+fn build_manifest_tools(
+    root: &Path,
+    tools: Vec<RawPluginToolManifest>,
+    errors: &mut Vec<PluginManifestValidationError>,
+) -> Vec<PluginToolManifest> {
+    let mut seen = BTreeSet::new();
+    let mut validated = Vec::new();
+
+    for tool in tools {
+        let name = tool.name.trim().to_string();
+        if name.is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "tool",
+                field: "name",
+                name: None,
+            });
+            continue;
+        }
+        if !seen.insert(name.clone()) {
+            errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name });
+            continue;
+        }
+        if tool.description.trim().is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "tool",
+                field: "description",
+                name: Some(name.clone()),
+            });
+        }
+        if tool.command.trim().is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "tool",
+                field: "command",
+                name: Some(name.clone()),
+            });
+        } else {
+            validate_command_entry(root, &tool.command, "tool", errors);
+        }
+        if !tool.input_schema.is_object() {
+            errors.push(PluginManifestValidationError::InvalidToolInputSchema {
+                tool_name: name.clone(),
+            });
+        }
+        let Some(required_permission) =
+            PluginToolPermission::parse(tool.required_permission.trim())
+        else {
+            errors.push(
+                PluginManifestValidationError::InvalidToolRequiredPermission {
+                    tool_name: name.clone(),
+                    permission: tool.required_permission.trim().to_string(),
+                },
+            );
+            continue;
+        };
+
+        validated.push(PluginToolManifest {
+            name,
+            description: tool.description,
+            input_schema: tool.input_schema,
+            command: tool.command,
+            args: tool.args,
+            required_permission,
+        });
+    }
+
+    validated
+}
+
+fn build_manifest_commands(
+    root: &Path,
+    commands: Vec<PluginCommandManifest>,
+    errors: &mut Vec<PluginManifestValidationError>,
+) -> Vec<PluginCommandManifest> {
+    let mut seen = BTreeSet::new();
+    let mut validated = Vec::new();
+
+    for command in commands {
+        let name = command.name.trim().to_string();
+        if name.is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "command",
+                field: "name",
+                name: None,
+            });
+            continue;
+        }
+        if !seen.insert(name.clone()) {
+            errors.push(PluginManifestValidationError::DuplicateEntry {
+                kind: "command",
+                name,
+            });
+            continue;
+        }
+        if command.description.trim().is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "command",
+                field: "description",
+                name: Some(name.clone()),
+            });
+        }
+        if command.command.trim().is_empty() {
+            errors.push(PluginManifestValidationError::EmptyEntryField {
+                kind: "command",
+                field: "command",
+                name: Some(name.clone()),
+            });
+        } else {
+            validate_command_entry(root, &command.command, "command", errors);
+        }
+        validated.push(command);
+    }
+
+    validated
+}
+
+fn validate_command_entries<'a>(
+    root: &Path,
+    entries: impl Iterator<Item = &'a String>,
+    kind: &'static str,
+    errors: &mut Vec<PluginManifestValidationError>,
+) {
+    for entry in entries {
+        validate_command_entry(root, entry, kind, errors);
+    }
+}
+
+fn validate_command_entry(
+    root: &Path,
+    entry: &str,
+    kind: &'static str,
+    errors: &mut Vec<PluginManifestValidationError>,
+) {
+    if entry.trim().is_empty() {
+        errors.push(PluginManifestValidationError::EmptyEntryField {
+            kind,
+            field: "command",
+            name: None,
+        });
+        return;
+    }
+    if is_literal_command(entry) {
+        return;
+    }
+
+    let path = if Path::new(entry).is_absolute() {
+        PathBuf::from(entry)
+    } else {
+        root.join(entry)
+    };
+    if !path.exists() {
+        errors.push(PluginManifestValidationError::MissingPath { kind, path });
+    }
+}
+
+fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
+    PluginHooks {
+        pre_tool_use: hooks
+            .pre_tool_use
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+        post_tool_use: hooks
+            .post_tool_use
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+    }
+}
+
+fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
+    PluginLifecycle {
+        init: lifecycle
+            .init
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+        shutdown: lifecycle
+            .shutdown
+            .iter()
+            .map(|entry| resolve_hook_entry(root, entry))
+            .collect(),
+    }
+}
+
+fn resolve_tools(
+    root: &Path,
+    plugin_id: &str,
+    plugin_name: &str,
+    tools: &[PluginToolManifest],
+) -> Vec<PluginTool> {
+    tools
+        .iter()
+        .map(|tool| {
+            PluginTool::new(
+                plugin_id,
+                plugin_name,
+                PluginToolDefinition {
+                    name: tool.name.clone(),
+                    description: Some(tool.description.clone()),
+                    input_schema: tool.input_schema.clone(),
+                },
+                resolve_hook_entry(root, &tool.command),
+                tool.args.clone(),
+                tool.required_permission,
+                Some(root.to_path_buf()),
+            )
+        })
+        .collect()
+}
+
+fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
+    let Some(root) = root else {
+        return Ok(());
+    };
+    for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
+        validate_command_path(root, entry, "hook")?;
+    }
+    Ok(())
+}
+
+fn validate_lifecycle_paths(
+    root: Option<&Path>,
+    lifecycle: &PluginLifecycle,
+) -> Result<(), PluginError> {
+    let Some(root) = root else {
+        return Ok(());
+    };
+    for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
+        validate_command_path(root, entry, "lifecycle command")?;
+    }
+    Ok(())
+}
+
+fn validate_tool_paths(root: Option<&Path>, tools: &[PluginTool]) -> Result<(), PluginError> {
+    let Some(root) = root else {
+        return Ok(());
+    };
+    for tool in tools {
+        validate_command_path(root, &tool.command, "tool")?;
+    }
+    Ok(())
+}
+
+fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
+    if is_literal_command(entry) {
+        return Ok(());
+    }
+    let path = if Path::new(entry).is_absolute() {
+        PathBuf::from(entry)
+    } else {
+        root.join(entry)
+    };
+    if !path.exists() {
+        return Err(PluginError::InvalidManifest(format!(
+            "{kind} path `{}` does not exist",
+            path.display()
+        )));
+    }
+    Ok(())
+}
+
+fn resolve_hook_entry(root: &Path, entry: &str) -> String {
+    if is_literal_command(entry) {
+        entry.to_string()
+    } else {
+        root.join(entry).display().to_string()
+    }
+}
+
+fn is_literal_command(entry: &str) -> bool {
+    !entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute()
+}
+
+fn run_lifecycle_commands(
+    metadata: &PluginMetadata,
+    lifecycle: &PluginLifecycle,
+    phase: &str,
+    commands: &[String],
+) -> Result<(), PluginError> {
+    if lifecycle.is_empty() || commands.is_empty() {
+        return Ok(());
+    }
+
+    for command in commands {
+        let mut process = if Path::new(command).exists() {
+            if cfg!(windows) {
+                let mut process = Command::new("cmd");
+                process.arg("/C").arg(command);
+                process
+            } else {
+                let mut process = Command::new("sh");
+                process.arg(command);
+                process
+            }
+        } else if cfg!(windows) {
+            let mut process = Command::new("cmd");
+            process.arg("/C").arg(command);
+            process
+        } else {
+            let mut process = Command::new("sh");
+            process.arg("-lc").arg(command);
+            process
+        };
+        if let Some(root) = &metadata.root {
+            process.current_dir(root);
+        }
+        let output = process.output()?;
+
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+            return Err(PluginError::CommandFailed(format!(
+                "plugin `{}` {} failed for `{}`: {}",
+                metadata.id,
+                phase,
+                command,
+                if stderr.is_empty() {
+                    format!("exit status {}", output.status)
+                } else {
+                    stderr
+                }
+            )));
+        }
+    }
+
+    Ok(())
+}
+
+fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
+    let path = PathBuf::from(source);
+    if path.exists() {
+        Ok(path)
+    } else {
+        Err(PluginError::NotFound(format!(
+            "plugin source `{source}` was not found"
+        )))
+    }
+}
+
+fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError> {
+    if source.starts_with("http://")
+        || source.starts_with("https://")
+        || source.starts_with("git@")
+        || Path::new(source)
+            .extension()
+            .is_some_and(|extension| extension.eq_ignore_ascii_case("git"))
+    {
+        Ok(PluginInstallSource::GitUrl {
+            url: source.to_string(),
+        })
+    } else {
+        Ok(PluginInstallSource::LocalPath {
+            path: resolve_local_source(source)?,
+        })
+    }
+}
+
+fn materialize_source(
+    source: &PluginInstallSource,
+    temp_root: &Path,
+) -> Result<PathBuf, PluginError> {
+    fs::create_dir_all(temp_root)?;
+    match source {
+        PluginInstallSource::LocalPath { path } => Ok(path.clone()),
+        PluginInstallSource::GitUrl { url } => {
+            let destination = temp_root.join(format!("plugin-{}", unix_time_ms()));
+            let output = Command::new("git")
+                .arg("clone")
+                .arg("--depth")
+                .arg("1")
+                .arg(url)
+                .arg(&destination)
+                .output()?;
+            if !output.status.success() {
+                return Err(PluginError::CommandFailed(format!(
+                    "git clone failed for `{url}`: {}",
+                    String::from_utf8_lossy(&output.stderr).trim()
+                )));
+            }
+            Ok(destination)
+        }
+    }
+}
+
+fn discover_plugin_dirs(root: &Path) -> Result<Vec<PathBuf>, PluginError> {
+    match fs::read_dir(root) {
+        Ok(entries) => {
+            let mut paths = Vec::new();
+            for entry in entries {
+                let path = entry?.path();
+                if path.is_dir() && plugin_manifest_path(&path).is_ok() {
+                    paths.push(path);
+                }
+            }
+            paths.sort();
+            Ok(paths)
+        }
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
+        Err(error) => Err(PluginError::Io(error)),
+    }
+}
+
+fn plugin_id(name: &str, marketplace: &str) -> String {
+    format!("{name}@{marketplace}")
+}
+
+fn sanitize_plugin_id(plugin_id: &str) -> String {
+    plugin_id
+        .chars()
+        .map(|ch| match ch {
+            '/' | '\\' | '@' | ':' => '-',
+            other => other,
+        })
+        .collect()
+}
+
+fn describe_install_source(source: &PluginInstallSource) -> String {
+    match source {
+        PluginInstallSource::LocalPath { path } => path.display().to_string(),
+        PluginInstallSource::GitUrl { url } => url.clone(),
+    }
+}
+
+fn unix_time_ms() -> u128 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("time should be after epoch")
+        .as_millis()
+}
+
+fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), PluginError> {
+    fs::create_dir_all(destination)?;
+    for entry in fs::read_dir(source)? {
+        let entry = entry?;
+        let target = destination.join(entry.file_name());
+        if entry.file_type()?.is_dir() {
+            copy_dir_all(&entry.path(), &target)?;
+        } else {
+            fs::copy(entry.path(), target)?;
+        }
+    }
+    Ok(())
+}
+
+fn update_settings_json(
+    path: &Path,
+    mut update: impl FnMut(&mut Map<String, Value>),
+) -> Result<(), PluginError> {
+    if let Some(parent) = path.parent() {
+        fs::create_dir_all(parent)?;
+    }
+    let mut root = match fs::read_to_string(path) {
+        Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::<Value>(&contents)?,
+        Ok(_) => Value::Object(Map::new()),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
+        Err(error) => return Err(PluginError::Io(error)),
+    };
+
+    let object = root.as_object_mut().ok_or_else(|| {
+        PluginError::InvalidManifest(format!(
+            "settings file {} must contain a JSON object",
+            path.display()
+        ))
+    })?;
+    update(object);
+    fs::write(path, serde_json::to_string_pretty(&root)?)?;
+    Ok(())
+}
+
+fn ensure_object<'a>(root: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
+    if !root.get(key).is_some_and(Value::is_object) {
+        root.insert(key.to_string(), Value::Object(Map::new()));
+    }
+    root.get_mut(key)
+        .and_then(Value::as_object_mut)
+        .expect("object should exist")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn temp_dir(label: &str) -> PathBuf {
+        std::env::temp_dir().join(format!("plugins-{label}-{}", unix_time_ms()))
+    }
+
+    fn write_file(path: &Path, contents: &str) {
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent).expect("parent dir");
+        }
+        fs::write(path, contents).expect("write file");
+    }
+
+    fn write_loader_plugin(root: &Path) {
+        write_file(
+            root.join("hooks").join("pre.sh").as_path(),
+            "#!/bin/sh\nprintf 'pre'\n",
+        );
+        write_file(
+            root.join("tools").join("echo-tool.sh").as_path(),
+            "#!/bin/sh\ncat\n",
+        );
+        write_file(
+            root.join("commands").join("sync.sh").as_path(),
+            "#!/bin/sh\nprintf 'sync'\n",
+        );
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "loader-demo",
+  "version": "1.2.3",
+  "description": "Manifest loader test plugin",
+  "permissions": ["read", "write"],
+  "hooks": {
+    "PreToolUse": ["./hooks/pre.sh"]
+  },
+  "tools": [
+    {
+      "name": "echo_tool",
+      "description": "Echoes JSON input",
+      "inputSchema": {
+        "type": "object"
+      },
+      "command": "./tools/echo-tool.sh",
+      "requiredPermission": "workspace-write"
+    }
+  ],
+  "commands": [
+    {
+      "name": "sync",
+      "description": "Sync command",
+      "command": "./commands/sync.sh"
+    }
+  ]
+}"#,
+        );
+    }
+
+    fn write_external_plugin(root: &Path, name: &str, version: &str) {
+        write_file(
+            root.join("hooks").join("pre.sh").as_path(),
+            "#!/bin/sh\nprintf 'pre'\n",
+        );
+        write_file(
+            root.join("hooks").join("post.sh").as_path(),
+            "#!/bin/sh\nprintf 'post'\n",
+        );
+        write_file(
+            root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"test plugin\",\n  \"hooks\": {{\n    \"PreToolUse\": [\"./hooks/pre.sh\"],\n    \"PostToolUse\": [\"./hooks/post.sh\"]\n  }}\n}}"
+            )
+            .as_str(),
+        );
+    }
+
+    fn write_broken_plugin(root: &Path, name: &str) {
+        write_file(
+            root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"1.0.0\",\n  \"description\": \"broken plugin\",\n  \"hooks\": {{\n    \"PreToolUse\": [\"./hooks/missing.sh\"]\n  }}\n}}"
+            )
+            .as_str(),
+        );
+    }
+
+    fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
+        let log_path = root.join("lifecycle.log");
+        write_file(
+            root.join("lifecycle").join("init.sh").as_path(),
+            "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
+        );
+        write_file(
+            root.join("lifecycle").join("shutdown.sh").as_path(),
+            "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
+        );
+        write_file(
+            root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"lifecycle plugin\",\n  \"lifecycle\": {{\n    \"Init\": [\"./lifecycle/init.sh\"],\n    \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n  }}\n}}"
+            )
+            .as_str(),
+        );
+        log_path
+    }
+
+    fn write_tool_plugin(root: &Path, name: &str, version: &str) {
+        write_tool_plugin_with_name(root, name, version, "plugin_echo");
+    }
+
+    fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) {
+        let script_path = root.join("tools").join("echo-json.sh");
+        write_file(
+            &script_path,
+            "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
+        );
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+
+            let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
+            permissions.set_mode(0o755);
+            fs::set_permissions(&script_path, permissions).expect("chmod");
+        }
+        write_file(
+            root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"tool plugin\",\n  \"tools\": [\n    {{\n      \"name\": \"{tool_name}\",\n      \"description\": \"Echo JSON input\",\n      \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n      \"command\": \"./tools/echo-json.sh\",\n      \"requiredPermission\": \"workspace-write\"\n    }}\n  ]\n}}"
+            )
+            .as_str(),
+        );
+    }
+
+    fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
+        write_file(
+            root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"bundled plugin\",\n  \"defaultEnabled\": {}\n}}",
+                if default_enabled { "true" } else { "false" }
+            )
+            .as_str(),
+        );
+    }
+
+    fn load_enabled_plugins(path: &Path) -> BTreeMap<String, bool> {
+        let contents = fs::read_to_string(path).expect("settings should exist");
+        let root: Value = serde_json::from_str(&contents).expect("settings json");
+        root.get("enabledPlugins")
+            .and_then(Value::as_object)
+            .map(|enabled_plugins| {
+                enabled_plugins
+                    .iter()
+                    .map(|(plugin_id, value)| {
+                        (
+                            plugin_id.clone(),
+                            value.as_bool().expect("plugin state should be a bool"),
+                        )
+                    })
+                    .collect()
+            })
+            .unwrap_or_default()
+    }
+
+    #[test]
+    fn load_plugin_from_directory_validates_required_fields() {
+        let root = temp_dir("manifest-required");
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{"name":"","version":"1.0.0","description":"desc"}"#,
+        );
+
+        let error = load_plugin_from_directory(&root).expect_err("empty name should fail");
+        assert!(error.to_string().contains("name cannot be empty"));
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
+        let root = temp_dir("manifest-root");
+        write_loader_plugin(&root);
+
+        let manifest = load_plugin_from_directory(&root).expect("manifest should load");
+        assert_eq!(manifest.name, "loader-demo");
+        assert_eq!(manifest.version, "1.2.3");
+        assert_eq!(
+            manifest
+                .permissions
+                .iter()
+                .map(|permission| permission.as_str())
+                .collect::<Vec<_>>(),
+            vec!["read", "write"]
+        );
+        assert_eq!(manifest.hooks.pre_tool_use, vec!["./hooks/pre.sh"]);
+        assert_eq!(manifest.tools.len(), 1);
+        assert_eq!(manifest.tools[0].name, "echo_tool");
+        assert_eq!(
+            manifest.tools[0].required_permission,
+            PluginToolPermission::WorkspaceWrite
+        );
+        assert_eq!(manifest.commands.len(), 1);
+        assert_eq!(manifest.commands[0].name, "sync");
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_supports_packaged_manifest_path() {
+        let root = temp_dir("manifest-packaged");
+        write_external_plugin(&root, "packaged-demo", "1.0.0");
+
+        let manifest = load_plugin_from_directory(&root).expect("packaged manifest should load");
+        assert_eq!(manifest.name, "packaged-demo");
+        assert!(manifest.tools.is_empty());
+        assert!(manifest.commands.is_empty());
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_defaults_optional_fields() {
+        let root = temp_dir("manifest-defaults");
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "minimal",
+  "version": "0.1.0",
+  "description": "Minimal manifest"
+}"#,
+        );
+
+        let manifest = load_plugin_from_directory(&root).expect("minimal manifest should load");
+        assert!(manifest.permissions.is_empty());
+        assert!(manifest.hooks.is_empty());
+        assert!(manifest.tools.is_empty());
+        assert!(manifest.commands.is_empty());
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
+        let root = temp_dir("manifest-duplicates");
+        write_file(
+            root.join("commands").join("sync.sh").as_path(),
+            "#!/bin/sh\nprintf 'sync'\n",
+        );
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "duplicate-manifest",
+  "version": "1.0.0",
+  "description": "Duplicate validation",
+  "permissions": ["read", "read"],
+  "commands": [
+    {"name": "sync", "description": "Sync one", "command": "./commands/sync.sh"},
+    {"name": "sync", "description": "Sync two", "command": "./commands/sync.sh"}
+  ]
+}"#,
+        );
+
+        let error = load_plugin_from_directory(&root).expect_err("duplicates should fail");
+        match error {
+            PluginError::ManifestValidation(errors) => {
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::DuplicatePermission { permission }
+                    if permission == "read"
+                )));
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::DuplicateEntry { kind, name }
+                    if *kind == "command" && name == "sync"
+                )));
+            }
+            other => panic!("expected manifest validation errors, got {other}"),
+        }
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
+        let root = temp_dir("manifest-paths");
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "missing-paths",
+  "version": "1.0.0",
+  "description": "Missing path validation",
+  "tools": [
+    {
+      "name": "tool_one",
+      "description": "Missing tool script",
+      "inputSchema": {"type": "object"},
+      "command": "./tools/missing.sh"
+    }
+  ]
+}"#,
+        );
+
+        let error = load_plugin_from_directory(&root).expect_err("missing paths should fail");
+        assert!(error.to_string().contains("does not exist"));
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_rejects_invalid_permissions() {
+        let root = temp_dir("manifest-invalid-permissions");
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "invalid-permissions",
+  "version": "1.0.0",
+  "description": "Invalid permission validation",
+  "permissions": ["admin"]
+}"#,
+        );
+
+        let error = load_plugin_from_directory(&root).expect_err("invalid permissions should fail");
+        match error {
+            PluginError::ManifestValidation(errors) => {
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::InvalidPermission { permission }
+                    if permission == "admin"
+                )));
+            }
+            other => panic!("expected manifest validation errors, got {other}"),
+        }
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_rejects_invalid_tool_required_permission() {
+        let root = temp_dir("manifest-invalid-tool-permission");
+        write_file(
+            root.join("tools").join("echo.sh").as_path(),
+            "#!/bin/sh\ncat\n",
+        );
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "invalid-tool-permission",
+  "version": "1.0.0",
+  "description": "Invalid tool permission validation",
+  "tools": [
+    {
+      "name": "echo_tool",
+      "description": "Echo tool",
+      "inputSchema": {"type": "object"},
+      "command": "./tools/echo.sh",
+      "requiredPermission": "admin"
+    }
+  ]
+}"#,
+        );
+
+        let error =
+            load_plugin_from_directory(&root).expect_err("invalid tool permission should fail");
+        match error {
+            PluginError::ManifestValidation(errors) => {
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::InvalidToolRequiredPermission {
+                        tool_name,
+                        permission
+                    } if tool_name == "echo_tool" && permission == "admin"
+                )));
+            }
+            other => panic!("expected manifest validation errors, got {other}"),
+        }
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn load_plugin_from_directory_accumulates_multiple_validation_errors() {
+        let root = temp_dir("manifest-multi-error");
+        write_file(
+            root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "",
+  "version": "1.0.0",
+  "description": "",
+  "permissions": ["admin"],
+  "commands": [
+    {"name": "", "description": "", "command": "./commands/missing.sh"}
+  ]
+}"#,
+        );
+
+        let error =
+            load_plugin_from_directory(&root).expect_err("multiple manifest errors should fail");
+        match error {
+            PluginError::ManifestValidation(errors) => {
+                assert!(errors.len() >= 4);
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::EmptyField { field } if *field == "name"
+                )));
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::EmptyField { field }
+                    if *field == "description"
+                )));
+                assert!(errors.iter().any(|error| matches!(
+                    error,
+                    PluginManifestValidationError::InvalidPermission { permission }
+                    if permission == "admin"
+                )));
+            }
+            other => panic!("expected manifest validation errors, got {other}"),
+        }
+
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn discovers_builtin_and_bundled_plugins() {
+        let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
+        let plugins = manager.list_plugins().expect("plugins should list");
+        assert!(plugins
+            .iter()
+            .any(|plugin| plugin.metadata.kind == PluginKind::Builtin));
+        assert!(plugins
+            .iter()
+            .any(|plugin| plugin.metadata.kind == PluginKind::Bundled));
+    }
+
+    #[test]
+    fn installs_enables_updates_and_uninstalls_external_plugins() {
+        let config_home = temp_dir("home");
+        let source_root = temp_dir("source");
+        write_external_plugin(&source_root, "demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let install = manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+        assert_eq!(install.plugin_id, "demo@external");
+        assert!(manager
+            .list_plugins()
+            .expect("list plugins")
+            .iter()
+            .any(|plugin| plugin.metadata.id == "demo@external" && plugin.enabled));
+
+        let hooks = manager.aggregated_hooks().expect("hooks should aggregate");
+        assert_eq!(hooks.pre_tool_use.len(), 1);
+        assert!(hooks.pre_tool_use[0].contains("pre.sh"));
+
+        manager
+            .disable("demo@external")
+            .expect("disable should work");
+        assert!(manager
+            .aggregated_hooks()
+            .expect("hooks after disable")
+            .is_empty());
+        manager.enable("demo@external").expect("enable should work");
+
+        write_external_plugin(&source_root, "demo", "2.0.0");
+        let update = manager.update("demo@external").expect("update should work");
+        assert_eq!(update.old_version, "1.0.0");
+        assert_eq!(update.new_version, "2.0.0");
+
+        manager
+            .uninstall("demo@external")
+            .expect("uninstall should work");
+        assert!(!manager
+            .list_plugins()
+            .expect("list plugins")
+            .iter()
+            .any(|plugin| plugin.metadata.id == "demo@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn auto_installs_bundled_plugins_into_the_registry() {
+        let config_home = temp_dir("bundled-home");
+        let bundled_root = temp_dir("bundled-root");
+        write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        let manager = PluginManager::new(config);
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("bundled plugins should auto-install");
+        assert!(installed.iter().any(|plugin| {
+            plugin.metadata.id == "starter@bundled"
+                && plugin.metadata.kind == PluginKind::Bundled
+                && !plugin.enabled
+        }));
+
+        let registry = manager.load_registry().expect("registry should exist");
+        let record = registry
+            .plugins
+            .get("starter@bundled")
+            .expect("bundled plugin should be recorded");
+        assert_eq!(record.kind, PluginKind::Bundled);
+        assert!(record.install_path.exists());
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
+        let config_home = temp_dir("default-bundled-home");
+        let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("default bundled plugins should auto-install");
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
+
+        let _ = fs::remove_dir_all(config_home);
+    }
+
+    #[test]
+    fn bundled_sync_prunes_removed_bundled_registry_entries() {
+        let config_home = temp_dir("bundled-prune-home");
+        let bundled_root = temp_dir("bundled-prune-root");
+        let stale_install_path = config_home
+            .join("plugins")
+            .join("installed")
+            .join("stale-bundled-external");
+        write_bundled_plugin(&bundled_root.join("active"), "active", "0.1.0", false);
+        write_file(
+            stale_install_path.join(MANIFEST_RELATIVE_PATH).as_path(),
+            r#"{
+  "name": "stale",
+  "version": "0.1.0",
+  "description": "stale bundled plugin"
+}"#,
+        );
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        config.install_root = Some(config_home.join("plugins").join("installed"));
+        let manager = PluginManager::new(config);
+
+        let mut registry = InstalledPluginRegistry::default();
+        registry.plugins.insert(
+            "stale@bundled".to_string(),
+            InstalledPluginRecord {
+                kind: PluginKind::Bundled,
+                id: "stale@bundled".to_string(),
+                name: "stale".to_string(),
+                version: "0.1.0".to_string(),
+                description: "stale bundled plugin".to_string(),
+                install_path: stale_install_path.clone(),
+                source: PluginInstallSource::LocalPath {
+                    path: bundled_root.join("stale"),
+                },
+                installed_at_unix_ms: 1,
+                updated_at_unix_ms: 1,
+            },
+        );
+        manager.store_registry(&registry).expect("store registry");
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("bundled sync should succeed");
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "active@bundled"));
+        assert!(!installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "stale@bundled"));
+
+        let registry = manager.load_registry().expect("load registry");
+        assert!(!registry.plugins.contains_key("stale@bundled"));
+        assert!(!stale_install_path.exists());
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
+        let config_home = temp_dir("registry-fallback-home");
+        let bundled_root = temp_dir("registry-fallback-bundled");
+        let install_root = config_home.join("plugins").join("installed");
+        let external_install_path = temp_dir("registry-fallback-external");
+        write_file(
+            external_install_path.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "registry-fallback",
+  "version": "1.0.0",
+  "description": "Registry fallback plugin"
+}"#,
+        );
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        config.install_root = Some(install_root.clone());
+        let manager = PluginManager::new(config);
+
+        let mut registry = InstalledPluginRegistry::default();
+        registry.plugins.insert(
+            "registry-fallback@external".to_string(),
+            InstalledPluginRecord {
+                kind: PluginKind::External,
+                id: "registry-fallback@external".to_string(),
+                name: "registry-fallback".to_string(),
+                version: "1.0.0".to_string(),
+                description: "Registry fallback plugin".to_string(),
+                install_path: external_install_path.clone(),
+                source: PluginInstallSource::LocalPath {
+                    path: external_install_path.clone(),
+                },
+                installed_at_unix_ms: 1,
+                updated_at_unix_ms: 1,
+            },
+        );
+        manager.store_registry(&registry).expect("store registry");
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("registry fallback plugin should load");
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "registry-fallback@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+        let _ = fs::remove_dir_all(external_install_path);
+    }
+
+    #[test]
+    fn installed_plugin_discovery_prunes_stale_registry_entries() {
+        let config_home = temp_dir("registry-prune-home");
+        let bundled_root = temp_dir("registry-prune-bundled");
+        let install_root = config_home.join("plugins").join("installed");
+        let missing_install_path = temp_dir("registry-prune-missing");
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        config.install_root = Some(install_root);
+        let manager = PluginManager::new(config);
+
+        let mut registry = InstalledPluginRegistry::default();
+        registry.plugins.insert(
+            "stale-external@external".to_string(),
+            InstalledPluginRecord {
+                kind: PluginKind::External,
+                id: "stale-external@external".to_string(),
+                name: "stale-external".to_string(),
+                version: "1.0.0".to_string(),
+                description: "stale external plugin".to_string(),
+                install_path: missing_install_path.clone(),
+                source: PluginInstallSource::LocalPath {
+                    path: missing_install_path.clone(),
+                },
+                installed_at_unix_ms: 1,
+                updated_at_unix_ms: 1,
+            },
+        );
+        manager.store_registry(&registry).expect("store registry");
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("stale registry entries should be pruned");
+        assert!(!installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "stale-external@external"));
+
+        let registry = manager.load_registry().expect("load registry");
+        assert!(!registry.plugins.contains_key("stale-external@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn persists_bundled_plugin_enable_state_across_reloads() {
+        let config_home = temp_dir("bundled-state-home");
+        let bundled_root = temp_dir("bundled-state-root");
+        write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        let mut manager = PluginManager::new(config.clone());
+
+        manager
+            .enable("starter@bundled")
+            .expect("enable bundled plugin should succeed");
+        assert_eq!(
+            load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
+            Some(&true)
+        );
+
+        let mut reloaded_config = PluginManagerConfig::new(&config_home);
+        reloaded_config.bundled_root = Some(bundled_root.clone());
+        reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
+        let reloaded_manager = PluginManager::new(reloaded_config);
+        let reloaded = reloaded_manager
+            .list_installed_plugins()
+            .expect("bundled plugins should still be listed");
+        assert!(reloaded
+            .iter()
+            .any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.enabled }));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn persists_bundled_plugin_disable_state_across_reloads() {
+        let config_home = temp_dir("bundled-disabled-home");
+        let bundled_root = temp_dir("bundled-disabled-root");
+        write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        let mut manager = PluginManager::new(config);
+
+        manager
+            .disable("starter@bundled")
+            .expect("disable bundled plugin should succeed");
+        assert_eq!(
+            load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
+            Some(&false)
+        );
+
+        let mut reloaded_config = PluginManagerConfig::new(&config_home);
+        reloaded_config.bundled_root = Some(bundled_root.clone());
+        reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
+        let reloaded_manager = PluginManager::new(reloaded_config);
+        let reloaded = reloaded_manager
+            .list_installed_plugins()
+            .expect("bundled plugins should still be listed");
+        assert!(reloaded
+            .iter()
+            .any(|plugin| { plugin.metadata.id == "starter@bundled" && !plugin.enabled }));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn validates_plugin_source_before_install() {
+        let config_home = temp_dir("validate-home");
+        let source_root = temp_dir("validate-source");
+        write_external_plugin(&source_root, "validator", "1.0.0");
+        let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let manifest = manager
+            .validate_plugin_source(source_root.to_str().expect("utf8 path"))
+            .expect("manifest should validate");
+        assert_eq!(manifest.name, "validator");
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn plugin_registry_tracks_enabled_state_and_lookup() {
+        let config_home = temp_dir("registry-home");
+        let source_root = temp_dir("registry-source");
+        write_external_plugin(&source_root, "registry-demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+        manager
+            .disable("registry-demo@external")
+            .expect("disable should succeed");
+
+        let registry = manager.plugin_registry().expect("registry should build");
+        let plugin = registry
+            .get("registry-demo@external")
+            .expect("installed plugin should be discoverable");
+        assert_eq!(plugin.metadata().name, "registry-demo");
+        assert!(!plugin.is_enabled());
+        assert!(registry.contains("registry-demo@external"));
+        assert!(!registry.contains("missing@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn rejects_plugin_sources_with_missing_hook_paths() {
+        let config_home = temp_dir("broken-home");
+        let source_root = temp_dir("broken-source");
+        write_broken_plugin(&source_root, "broken");
+
+        let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let error = manager
+            .validate_plugin_source(source_root.to_str().expect("utf8 path"))
+            .expect_err("missing hook file should fail validation");
+        assert!(error.to_string().contains("does not exist"));
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let install_error = manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect_err("install should reject invalid hook paths");
+        assert!(install_error.to_string().contains("does not exist"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
+        let config_home = temp_dir("lifecycle-home");
+        let source_root = temp_dir("lifecycle-source");
+        let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let install = manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+        let log_path = install.install_path.join("lifecycle.log");
+
+        let registry = manager.plugin_registry().expect("registry should build");
+        registry.initialize().expect("init should succeed");
+        registry.shutdown().expect("shutdown should succeed");
+
+        let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
+        assert_eq!(log, "init\nshutdown\n");
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn aggregates_and_executes_plugin_tools() {
+        let config_home = temp_dir("tool-home");
+        let source_root = temp_dir("tool-source");
+        write_tool_plugin(&source_root, "tool-demo", "1.0.0");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+
+        let tools = manager.aggregated_tools().expect("tools should aggregate");
+        assert_eq!(tools.len(), 1);
+        assert_eq!(tools[0].definition().name, "plugin_echo");
+        assert_eq!(tools[0].required_permission(), "workspace-write");
+
+        let output = tools[0]
+            .execute(&serde_json::json!({ "message": "hello" }))
+            .expect("plugin tool should execute");
+        let payload: Value = serde_json::from_str(&output).expect("valid json");
+        assert_eq!(payload["plugin"], "tool-demo@external");
+        assert_eq!(payload["tool"], "plugin_echo");
+        assert_eq!(payload["input"]["message"], "hello");
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn list_installed_plugins_scans_install_root_without_registry_entries() {
+        let config_home = temp_dir("installed-scan-home");
+        let bundled_root = temp_dir("installed-scan-bundled");
+        let install_root = config_home.join("plugins").join("installed");
+        let installed_plugin_root = install_root.join("scan-demo");
+        write_file(
+            installed_plugin_root.join(MANIFEST_FILE_NAME).as_path(),
+            r#"{
+  "name": "scan-demo",
+  "version": "1.0.0",
+  "description": "Scanned from install root"
+}"#,
+        );
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        config.install_root = Some(install_root);
+        let manager = PluginManager::new(config);
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("installed plugins should scan directories");
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "scan-demo@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+
+    #[test]
+    fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
+        let config_home = temp_dir("installed-packaged-scan-home");
+        let bundled_root = temp_dir("installed-packaged-scan-bundled");
+        let install_root = config_home.join("plugins").join("installed");
+        let installed_plugin_root = install_root.join("scan-packaged");
+        write_file(
+            installed_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
+            r#"{
+  "name": "scan-packaged",
+  "version": "1.0.0",
+  "description": "Packaged manifest in install root"
+}"#,
+        );
+
+        let mut config = PluginManagerConfig::new(&config_home);
+        config.bundled_root = Some(bundled_root.clone());
+        config.install_root = Some(install_root);
+        let manager = PluginManager::new(config);
+
+        let installed = manager
+            .list_installed_plugins()
+            .expect("installed plugins should scan packaged manifests");
+        assert!(installed
+            .iter()
+            .any(|plugin| plugin.metadata.id == "scan-packaged@external"));
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(bundled_root);
+    }
+}

+ 1 - 0
rust/crates/runtime/Cargo.toml

@@ -8,6 +8,7 @@ publish.workspace = true
 [dependencies]
 sha2 = "0.10"
 glob = "0.3"
+plugins = { path = "../plugins" }
 regex = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"

+ 1 - 1
rust/crates/runtime/src/bootstrap.rs

@@ -21,7 +21,7 @@ pub struct BootstrapPlan {
 
 impl BootstrapPlan {
     #[must_use]
-    pub fn default_bootstrap() -> Self {
+    pub fn claude_code_default() -> Self {
         Self::from_phases(vec![
             BootstrapPhase::CliEntry,
             BootstrapPhase::FastPathVersion,

+ 227 - 10
rust/crates/runtime/src/compact.rs

@@ -1,5 +1,10 @@
 use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
 
+const COMPACT_CONTINUATION_PREAMBLE: &str =
+    "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
+const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
+const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct CompactionConfig {
     pub preserve_recent_messages: usize,
@@ -30,8 +35,15 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
 
 #[must_use]
 pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
-    session.messages.len() > config.preserve_recent_messages
-        && estimate_session_tokens(session) >= config.max_estimated_tokens
+    let start = compacted_summary_prefix_len(session);
+    let compactable = &session.messages[start..];
+
+    compactable.len() > config.preserve_recent_messages
+        && compactable
+            .iter()
+            .map(estimate_message_tokens)
+            .sum::<usize>()
+            >= config.max_estimated_tokens
 }
 
 #[must_use]
@@ -56,16 +68,18 @@ pub fn get_compact_continuation_message(
     recent_messages_preserved: bool,
 ) -> String {
     let mut base = format!(
-        "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
+        "{COMPACT_CONTINUATION_PREAMBLE}{}",
         format_compact_summary(summary)
     );
 
     if recent_messages_preserved {
-        base.push_str("\n\nRecent messages are preserved verbatim.");
+        base.push_str("\n\n");
+        base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
     }
 
     if suppress_follow_up_questions {
-        base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
+        base.push('\n');
+        base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
     }
 
     base
@@ -82,13 +96,19 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
         };
     }
 
+    let existing_summary = session
+        .messages
+        .first()
+        .and_then(extract_existing_compacted_summary);
+    let compacted_prefix_len = usize::from(existing_summary.is_some());
     let keep_from = session
         .messages
         .len()
         .saturating_sub(config.preserve_recent_messages);
-    let removed = &session.messages[..keep_from];
+    let removed = &session.messages[compacted_prefix_len..keep_from];
     let preserved = session.messages[keep_from..].to_vec();
-    let summary = summarize_messages(removed);
+    let summary =
+        merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
     let formatted_summary = format_compact_summary(&summary);
     let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
 
@@ -110,6 +130,16 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
     }
 }
 
+fn compacted_summary_prefix_len(session: &Session) -> usize {
+    usize::from(
+        session
+            .messages
+            .first()
+            .and_then(extract_existing_compacted_summary)
+            .is_some(),
+    )
+}
+
 fn summarize_messages(messages: &[ConversationMessage]) -> String {
     let user_messages = messages
         .iter()
@@ -197,6 +227,41 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
     lines.join("\n")
 }
 
+fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
+    let Some(existing_summary) = existing_summary else {
+        return new_summary.to_string();
+    };
+
+    let previous_highlights = extract_summary_highlights(existing_summary);
+    let new_formatted_summary = format_compact_summary(new_summary);
+    let new_highlights = extract_summary_highlights(&new_formatted_summary);
+    let new_timeline = extract_summary_timeline(&new_formatted_summary);
+
+    let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
+
+    if !previous_highlights.is_empty() {
+        lines.push("- Previously compacted context:".to_string());
+        lines.extend(
+            previous_highlights
+                .into_iter()
+                .map(|line| format!("  {line}")),
+        );
+    }
+
+    if !new_highlights.is_empty() {
+        lines.push("- Newly compacted context:".to_string());
+        lines.extend(new_highlights.into_iter().map(|line| format!("  {line}")));
+    }
+
+    if !new_timeline.is_empty() {
+        lines.push("- Key timeline:".to_string());
+        lines.extend(new_timeline.into_iter().map(|line| format!("  {line}")));
+    }
+
+    lines.push("</summary>".to_string());
+    lines.join("\n")
+}
+
 fn summarize_block(block: &ContentBlock) -> String {
     let raw = match block {
         ContentBlock::Text { text } => text.clone(),
@@ -374,11 +439,71 @@ fn collapse_blank_lines(content: &str) -> String {
     result
 }
 
+fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
+    if message.role != MessageRole::System {
+        return None;
+    }
+
+    let text = first_text_block(message)?;
+    let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
+    let summary = summary
+        .split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
+        .map_or(summary, |(value, _)| value);
+    let summary = summary
+        .split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
+        .map_or(summary, |(value, _)| value);
+    Some(summary.trim().to_string())
+}
+
+fn extract_summary_highlights(summary: &str) -> Vec<String> {
+    let mut lines = Vec::new();
+    let mut in_timeline = false;
+
+    for line in format_compact_summary(summary).lines() {
+        let trimmed = line.trim_end();
+        if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
+            continue;
+        }
+        if trimmed == "- Key timeline:" {
+            in_timeline = true;
+            continue;
+        }
+        if in_timeline {
+            continue;
+        }
+        lines.push(trimmed.to_string());
+    }
+
+    lines
+}
+
+fn extract_summary_timeline(summary: &str) -> Vec<String> {
+    let mut lines = Vec::new();
+    let mut in_timeline = false;
+
+    for line in format_compact_summary(summary).lines() {
+        let trimmed = line.trim_end();
+        if trimmed == "- Key timeline:" {
+            in_timeline = true;
+            continue;
+        }
+        if !in_timeline {
+            continue;
+        }
+        if trimmed.is_empty() {
+            break;
+        }
+        lines.push(trimmed.to_string());
+    }
+
+    lines
+}
+
 #[cfg(test)]
 mod tests {
     use super::{
         collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
-        infer_pending_work, should_compact, CompactionConfig,
+        get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
     };
     use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
 
@@ -453,6 +578,98 @@ mod tests {
         );
     }
 
+    #[test]
+    fn keeps_previous_compacted_context_when_compacting_again() {
+        let initial_session = Session {
+            version: 1,
+            messages: vec![
+                ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
+                ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "I will inspect the compact flow.".to_string(),
+                }]),
+                ConversationMessage::user_text(
+                    "Also update rust/crates/runtime/src/conversation.rs",
+                ),
+                ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "Next: preserve prior summary context during auto compact.".to_string(),
+                }]),
+            ],
+        };
+        let config = CompactionConfig {
+            preserve_recent_messages: 2,
+            max_estimated_tokens: 1,
+        };
+
+        let first = compact_session(&initial_session, config);
+        let mut follow_up_messages = first.compacted_session.messages.clone();
+        follow_up_messages.extend([
+            ConversationMessage::user_text("Please add regression tests for compaction."),
+            ConversationMessage::assistant(vec![ContentBlock::Text {
+                text: "Working on regression coverage now.".to_string(),
+            }]),
+        ]);
+
+        let second = compact_session(
+            &Session {
+                version: 1,
+                messages: follow_up_messages,
+            },
+            config,
+        );
+
+        assert!(second
+            .formatted_summary
+            .contains("Previously compacted context:"));
+        assert!(second
+            .formatted_summary
+            .contains("Scope: 2 earlier messages compacted"));
+        assert!(second
+            .formatted_summary
+            .contains("Newly compacted context:"));
+        assert!(second
+            .formatted_summary
+            .contains("Also update rust/crates/runtime/src/conversation.rs"));
+        assert!(matches!(
+            &second.compacted_session.messages[0].blocks[0],
+            ContentBlock::Text { text }
+                if text.contains("Previously compacted context:")
+                    && text.contains("Newly compacted context:")
+        ));
+        assert!(matches!(
+            &second.compacted_session.messages[1].blocks[0],
+            ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
+        ));
+    }
+
+    #[test]
+    fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
+        let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n  - user: large preserved context\n</summary>";
+        let session = Session {
+            version: 1,
+            messages: vec![
+                ConversationMessage {
+                    role: MessageRole::System,
+                    blocks: vec![ContentBlock::Text {
+                        text: get_compact_continuation_message(summary, true, true),
+                    }],
+                    usage: None,
+                },
+                ConversationMessage::user_text("tiny"),
+                ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "recent".to_string(),
+                }]),
+            ],
+        };
+
+        assert!(!should_compact(
+            &session,
+            CompactionConfig {
+                preserve_recent_messages: 2,
+                max_estimated_tokens: 1,
+            }
+        ));
+    }
+
     #[test]
     fn truncates_long_blocks_in_summary() {
         let summary = super::summarize_block(&ContentBlock::Text {
@@ -465,10 +682,10 @@ mod tests {
     #[test]
     fn extracts_key_files_from_message_content() {
         let files = collect_key_files(&[ConversationMessage::user_text(
-            "Update rust/crates/runtime/src/compact.rs and rust/crates/claw-cli/src/main.rs next.",
+            "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
         )]);
         assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
-        assert!(files.contains(&"rust/crates/claw-cli/src/main.rs".to_string()));
+        assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
     }
 
     #[test]

+ 262 - 112
rust/crates/runtime/src/config.rs

@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
 use crate::json::JsonValue;
 use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
 
-pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
+pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 pub enum ConfigSource {
@@ -35,14 +35,23 @@ pub struct RuntimeConfig {
     feature_config: RuntimeFeatureConfig,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct RuntimePluginConfig {
+    enabled_plugins: BTreeMap<String, bool>,
+    external_directories: Vec<String>,
+    install_root: Option<String>,
+    registry_path: Option<String>,
+    bundled_root: Option<String>,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct RuntimeFeatureConfig {
     hooks: RuntimeHookConfig,
+    plugins: RuntimePluginConfig,
     mcp: McpConfigCollection,
     oauth: Option<OAuthConfig>,
     model: Option<String>,
     permission_mode: Option<ResolvedPermissionMode>,
-    permission_rules: RuntimePermissionRuleConfig,
     sandbox: SandboxConfig,
 }
 
@@ -50,14 +59,6 @@ pub struct RuntimeFeatureConfig {
 pub struct RuntimeHookConfig {
     pre_tool_use: Vec<String>,
     post_tool_use: Vec<String>,
-    post_tool_use_failure: Vec<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Default)]
-pub struct RuntimePermissionRuleConfig {
-    allow: Vec<String>,
-    deny: Vec<String>,
-    ask: Vec<String>,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -78,7 +79,7 @@ pub enum McpTransport {
     Http,
     Ws,
     Sdk,
-    ManagedProxy,
+    ClaudeAiProxy,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -88,7 +89,7 @@ pub enum McpServerConfig {
     Http(McpRemoteServerConfig),
     Ws(McpWebSocketServerConfig),
     Sdk(McpSdkServerConfig),
-    ManagedProxy(McpManagedProxyServerConfig),
+    ClaudeAiProxy(McpClaudeAiProxyServerConfig),
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -119,7 +120,7 @@ pub struct McpSdkServerConfig {
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct McpManagedProxyServerConfig {
+pub struct McpClaudeAiProxyServerConfig {
     pub url: String,
     pub id: String,
 }
@@ -183,18 +184,20 @@ impl ConfigLoader {
     #[must_use]
     pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
         let cwd = cwd.into();
-        let config_home = std::env::var_os("CLAW_CONFIG_HOME")
-            .map(PathBuf::from)
-            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
-            .unwrap_or_else(|| PathBuf::from(".claw"));
+        let config_home = default_config_home();
         Self { cwd, config_home }
     }
 
+    #[must_use]
+    pub fn config_home(&self) -> &Path {
+        &self.config_home
+    }
+
     #[must_use]
     pub fn discover(&self) -> Vec<ConfigEntry> {
         let user_legacy_path = self.config_home.parent().map_or_else(
-            || PathBuf::from(".claw.json"),
-            |parent| parent.join(".claw.json"),
+            || PathBuf::from(".claude.json"),
+            |parent| parent.join(".claude.json"),
         );
         vec![
             ConfigEntry {
@@ -207,15 +210,15 @@ impl ConfigLoader {
             },
             ConfigEntry {
                 source: ConfigSource::Project,
-                path: self.cwd.join(".claw.json"),
+                path: self.cwd.join(".claude.json"),
             },
             ConfigEntry {
                 source: ConfigSource::Project,
-                path: self.cwd.join(".claw").join("settings.json"),
+                path: self.cwd.join(".claude").join("settings.json"),
             },
             ConfigEntry {
                 source: ConfigSource::Local,
-                path: self.cwd.join(".claw").join("settings.local.json"),
+                path: self.cwd.join(".claude").join("settings.local.json"),
             },
         ]
     }
@@ -238,13 +241,13 @@ impl ConfigLoader {
 
         let feature_config = RuntimeFeatureConfig {
             hooks: parse_optional_hooks_config(&merged_value)?,
+            plugins: parse_optional_plugin_config(&merged_value)?,
             mcp: McpConfigCollection {
                 servers: mcp_servers,
             },
             oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
             model: parse_optional_model(&merged_value),
             permission_mode: parse_optional_permission_mode(&merged_value)?,
-            permission_rules: parse_optional_permission_rules(&merged_value)?,
             sandbox: parse_optional_sandbox_config(&merged_value)?,
         };
 
@@ -301,6 +304,11 @@ impl RuntimeConfig {
         &self.feature_config.hooks
     }
 
+    #[must_use]
+    pub fn plugins(&self) -> &RuntimePluginConfig {
+        &self.feature_config.plugins
+    }
+
     #[must_use]
     pub fn oauth(&self) -> Option<&OAuthConfig> {
         self.feature_config.oauth.as_ref()
@@ -316,11 +324,6 @@ impl RuntimeConfig {
         self.feature_config.permission_mode
     }
 
-    #[must_use]
-    pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
-        &self.feature_config.permission_rules
-    }
-
     #[must_use]
     pub fn sandbox(&self) -> &SandboxConfig {
         &self.feature_config.sandbox
@@ -334,11 +337,22 @@ impl RuntimeFeatureConfig {
         self
     }
 
+    #[must_use]
+    pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
+        self.plugins = plugins;
+        self
+    }
+
     #[must_use]
     pub fn hooks(&self) -> &RuntimeHookConfig {
         &self.hooks
     }
 
+    #[must_use]
+    pub fn plugins(&self) -> &RuntimePluginConfig {
+        &self.plugins
+    }
+
     #[must_use]
     pub fn mcp(&self) -> &McpConfigCollection {
         &self.mcp
@@ -359,66 +373,88 @@ impl RuntimeFeatureConfig {
         self.permission_mode
     }
 
-    #[must_use]
-    pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
-        &self.permission_rules
-    }
-
     #[must_use]
     pub fn sandbox(&self) -> &SandboxConfig {
         &self.sandbox
     }
 }
 
-impl RuntimeHookConfig {
+impl RuntimePluginConfig {
     #[must_use]
-    pub fn new(
-        pre_tool_use: Vec<String>,
-        post_tool_use: Vec<String>,
-        post_tool_use_failure: Vec<String>,
-    ) -> Self {
-        Self {
-            pre_tool_use,
-            post_tool_use,
-            post_tool_use_failure,
-        }
+    pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
+        &self.enabled_plugins
     }
 
     #[must_use]
-    pub fn pre_tool_use(&self) -> &[String] {
-        &self.pre_tool_use
+    pub fn external_directories(&self) -> &[String] {
+        &self.external_directories
     }
 
     #[must_use]
-    pub fn post_tool_use(&self) -> &[String] {
-        &self.post_tool_use
+    pub fn install_root(&self) -> Option<&str> {
+        self.install_root.as_deref()
     }
 
     #[must_use]
-    pub fn post_tool_use_failure(&self) -> &[String] {
-        &self.post_tool_use_failure
+    pub fn registry_path(&self) -> Option<&str> {
+        self.registry_path.as_deref()
     }
+
+    #[must_use]
+    pub fn bundled_root(&self) -> Option<&str> {
+        self.bundled_root.as_deref()
+    }
+
+    pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
+        self.enabled_plugins.insert(plugin_id, enabled);
+    }
+
+    #[must_use]
+    pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
+        self.enabled_plugins
+            .get(plugin_id)
+            .copied()
+            .unwrap_or(default_enabled)
+    }
+}
+
+#[must_use]
+pub fn default_config_home() -> PathBuf {
+    std::env::var_os("CLAUDE_CONFIG_HOME")
+        .map(PathBuf::from)
+        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
+        .unwrap_or_else(|| PathBuf::from(".claude"))
 }
 
-impl RuntimePermissionRuleConfig {
+impl RuntimeHookConfig {
     #[must_use]
-    pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
-        Self { allow, deny, ask }
+    pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
+        Self {
+            pre_tool_use,
+            post_tool_use,
+        }
     }
 
     #[must_use]
-    pub fn allow(&self) -> &[String] {
-        &self.allow
+    pub fn pre_tool_use(&self) -> &[String] {
+        &self.pre_tool_use
     }
 
     #[must_use]
-    pub fn deny(&self) -> &[String] {
-        &self.deny
+    pub fn post_tool_use(&self) -> &[String] {
+        &self.post_tool_use
     }
 
     #[must_use]
-    pub fn ask(&self) -> &[String] {
-        &self.ask
+    pub fn merged(&self, other: &Self) -> Self {
+        let mut merged = self.clone();
+        merged.extend(other);
+        merged
+    }
+
+    pub fn extend(&mut self, other: &Self) {
+        extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
+        extend_unique(&mut self.post_tool_use, other.post_tool_use());
     }
 }
 
@@ -450,7 +486,7 @@ impl McpServerConfig {
             Self::Http(_) => McpTransport::Http,
             Self::Ws(_) => McpTransport::Ws,
             Self::Sdk(_) => McpTransport::Sdk,
-            Self::ManagedProxy(_) => McpTransport::ManagedProxy,
+            Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
         }
     }
 }
@@ -458,7 +494,7 @@ impl McpServerConfig {
 fn read_optional_json_object(
     path: &Path,
 ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
-    let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
+    let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
     let contents = match fs::read_to_string(path) {
         Ok(contents) => contents,
         Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -533,33 +569,37 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
             .unwrap_or_default(),
         post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
             .unwrap_or_default(),
-        post_tool_use_failure: optional_string_array(
-            hooks,
-            "PostToolUseFailure",
-            "merged settings.hooks",
-        )?
-        .unwrap_or_default(),
     })
 }
 
-fn parse_optional_permission_rules(
-    root: &JsonValue,
-) -> Result<RuntimePermissionRuleConfig, ConfigError> {
+fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
     let Some(object) = root.as_object() else {
-        return Ok(RuntimePermissionRuleConfig::default());
+        return Ok(RuntimePluginConfig::default());
     };
-    let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
-        return Ok(RuntimePermissionRuleConfig::default());
+
+    let mut config = RuntimePluginConfig::default();
+    if let Some(enabled_plugins) = object.get("enabledPlugins") {
+        config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
+    }
+
+    let Some(plugins_value) = object.get("plugins") else {
+        return Ok(config);
     };
+    let plugins = expect_object(plugins_value, "merged settings.plugins")?;
 
-    Ok(RuntimePermissionRuleConfig {
-        allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
-            .unwrap_or_default(),
-        deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
-            .unwrap_or_default(),
-        ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
-            .unwrap_or_default(),
-    })
+    if let Some(enabled_value) = plugins.get("enabled") {
+        config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
+    }
+    config.external_directories =
+        optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
+            .unwrap_or_default();
+    config.install_root =
+        optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
+    config.registry_path =
+        optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
+    config.bundled_root =
+        optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
+    Ok(config)
 }
 
 fn parse_optional_permission_mode(
@@ -684,8 +724,8 @@ fn parse_mcp_server_config(
         "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
             name: expect_string(object, "name", context)?.to_string(),
         })),
-        "managed-proxy" => Ok(McpServerConfig::ManagedProxy(
-            McpManagedProxyServerConfig {
+        "claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
+            McpClaudeAiProxyServerConfig {
                 url: expect_string(object, "url", context)?.to_string(),
                 id: expect_string(object, "id", context)?.to_string(),
             },
@@ -794,6 +834,24 @@ fn optional_u16(
     }
 }
 
+fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
+    let Some(map) = value.as_object() else {
+        return Err(ConfigError::Parse(format!(
+            "{context}: expected JSON object"
+        )));
+    };
+    map.iter()
+        .map(|(key, value)| {
+            value
+                .as_bool()
+                .map(|enabled| (key.clone(), enabled))
+                .ok_or_else(|| {
+                    ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
+                })
+        })
+        .collect()
+}
+
 fn optional_string_array(
     object: &BTreeMap<String, JsonValue>,
     key: &str,
@@ -868,11 +926,23 @@ fn deep_merge_objects(
     }
 }
 
+fn extend_unique(target: &mut Vec<String>, values: &[String]) {
+    for value in values {
+        push_unique(target, value.clone());
+    }
+}
+
+fn push_unique(target: &mut Vec<String>, value: String) {
+    if !target.iter().any(|existing| existing == &value) {
+        target.push(value);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::{
         ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
-        CLAW_SETTINGS_SCHEMA_NAME,
+        CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
     };
     use crate::json::JsonValue;
     use crate::sandbox::FilesystemIsolationMode;
@@ -891,7 +961,7 @@ mod tests {
     fn rejects_non_object_settings_files() {
         let root = temp_dir();
         let cwd = root.join("project");
-        let home = root.join("home").join(".claw");
+        let home = root.join("home").join(".claude");
         fs::create_dir_all(&home).expect("home config dir");
         fs::create_dir_all(&cwd).expect("project dir");
         fs::write(home.join("settings.json"), "[]").expect("write bad settings");
@@ -907,35 +977,35 @@ mod tests {
     }
 
     #[test]
-    fn loads_and_merges_claw_config_files_by_precedence() {
+    fn loads_and_merges_claude_code_config_files_by_precedence() {
         let root = temp_dir();
         let cwd = root.join("project");
-        let home = root.join("home").join(".claw");
-        fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
         fs::create_dir_all(&home).expect("home config dir");
 
         fs::write(
-            home.parent().expect("home parent").join(".claw.json"),
+            home.parent().expect("home parent").join(".claude.json"),
             r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
         )
         .expect("write user compat config");
         fs::write(
             home.join("settings.json"),
-            r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
+            r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
         )
         .expect("write user settings");
         fs::write(
-            cwd.join(".claw.json"),
+            cwd.join(".claude.json"),
             r#"{"model":"project-compat","env":{"B":"2"}}"#,
         )
         .expect("write project compat config");
         fs::write(
-            cwd.join(".claw").join("settings.json"),
-            r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
+            cwd.join(".claude").join("settings.json"),
+            r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
         )
         .expect("write project settings");
         fs::write(
-            cwd.join(".claw").join("settings.local.json"),
+            cwd.join(".claude").join("settings.local.json"),
             r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
         )
         .expect("write local settings");
@@ -944,7 +1014,7 @@ mod tests {
             .load()
             .expect("config should load");
 
-        assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
+        assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
         assert_eq!(loaded.loaded_entries().len(), 5);
         assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
         assert_eq!(
@@ -976,16 +1046,6 @@ mod tests {
             .contains_key("PostToolUse"));
         assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
         assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
-        assert_eq!(
-            loaded.hooks().post_tool_use_failure(),
-            &["project-failure".to_string()]
-        );
-        assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
-        assert_eq!(
-            loaded.permission_rules().deny(),
-            &["Bash(rm -rf)".to_string()]
-        );
-        assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
         assert!(loaded.mcp().get("home").is_some());
         assert!(loaded.mcp().get("project").is_some());
 
@@ -996,12 +1056,12 @@ mod tests {
     fn parses_sandbox_config() {
         let root = temp_dir();
         let cwd = root.join("project");
-        let home = root.join("home").join(".claw");
-        fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
         fs::create_dir_all(&home).expect("home config dir");
 
         fs::write(
-            cwd.join(".claw").join("settings.local.json"),
+            cwd.join(".claude").join("settings.local.json"),
             r#"{
               "sandbox": {
                 "enabled": true,
@@ -1034,8 +1094,8 @@ mod tests {
     fn parses_typed_mcp_and_oauth_config() {
         let root = temp_dir();
         let cwd = root.join("project");
-        let home = root.join("home").join(".claw");
-        fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
         fs::create_dir_all(&home).expect("home config dir");
 
         fs::write(
@@ -1072,7 +1132,7 @@ mod tests {
         )
         .expect("write user settings");
         fs::write(
-            cwd.join(".claw").join("settings.local.json"),
+            cwd.join(".claude").join("settings.local.json"),
             r#"{
               "mcpServers": {
                 "remote-server": {
@@ -1121,11 +1181,101 @@ mod tests {
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }
 
+    #[test]
+    fn parses_plugin_config_from_enabled_plugins() {
+        let root = temp_dir();
+        let cwd = root.join("project");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
+        fs::create_dir_all(&home).expect("home config dir");
+
+        fs::write(
+            home.join("settings.json"),
+            r#"{
+              "enabledPlugins": {
+                "tool-guard@builtin": true,
+                "sample-plugin@external": false
+              }
+            }"#,
+        )
+        .expect("write user settings");
+
+        let loaded = ConfigLoader::new(&cwd, &home)
+            .load()
+            .expect("config should load");
+
+        assert_eq!(
+            loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
+            Some(&true)
+        );
+        assert_eq!(
+            loaded
+                .plugins()
+                .enabled_plugins()
+                .get("sample-plugin@external"),
+            Some(&false)
+        );
+
+        fs::remove_dir_all(root).expect("cleanup temp dir");
+    }
+
+    #[test]
+    fn parses_plugin_config() {
+        let root = temp_dir();
+        let cwd = root.join("project");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
+        fs::create_dir_all(&home).expect("home config dir");
+
+        fs::write(
+            home.join("settings.json"),
+            r#"{
+              "enabledPlugins": {
+                "core-helpers@builtin": true
+              },
+              "plugins": {
+                "externalDirectories": ["./external-plugins"],
+                "installRoot": "plugin-cache/installed",
+                "registryPath": "plugin-cache/installed.json",
+                "bundledRoot": "./bundled-plugins"
+              }
+            }"#,
+        )
+        .expect("write plugin settings");
+
+        let loaded = ConfigLoader::new(&cwd, &home)
+            .load()
+            .expect("config should load");
+
+        assert_eq!(
+            loaded
+                .plugins()
+                .enabled_plugins()
+                .get("core-helpers@builtin"),
+            Some(&true)
+        );
+        assert_eq!(
+            loaded.plugins().external_directories(),
+            &["./external-plugins".to_string()]
+        );
+        assert_eq!(
+            loaded.plugins().install_root(),
+            Some("plugin-cache/installed")
+        );
+        assert_eq!(
+            loaded.plugins().registry_path(),
+            Some("plugin-cache/installed.json")
+        );
+        assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
+
+        fs::remove_dir_all(root).expect("cleanup temp dir");
+    }
+
     #[test]
     fn rejects_invalid_mcp_server_shapes() {
         let root = temp_dir();
         let cwd = root.join("project");
-        let home = root.join("home").join(".claw");
+        let home = root.join("home").join(".claude");
         fs::create_dir_all(&home).expect("home config dir");
         fs::create_dir_all(&cwd).expect("project dir");
         fs::write(

+ 621 - 169
rust/crates/runtime/src/conversation.rs

@@ -1,17 +1,20 @@
 use std::collections::BTreeMap;
 use std::fmt::{Display, Formatter};
 
+use plugins::{HookRunner as PluginHookRunner, PluginRegistry};
+
 use crate::compact::{
     compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
 };
 use crate::config::RuntimeFeatureConfig;
-use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
-use crate::permissions::{
-    PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter,
-};
+use crate::hooks::HookRunner;
+use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
 use crate::session::{ContentBlock, ConversationMessage, Session};
 use crate::usage::{TokenUsage, UsageTracker};
 
+const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 200_000;
+const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ApiRequest {
     pub system_prompt: Vec<String>,
@@ -88,6 +91,12 @@ pub struct TurnSummary {
     pub tool_results: Vec<ConversationMessage>,
     pub iterations: usize,
     pub usage: TokenUsage,
+    pub auto_compaction: Option<AutoCompactionEvent>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AutoCompactionEvent {
+    pub removed_message_count: usize,
 }
 
 pub struct ConversationRuntime<C, T> {
@@ -99,8 +108,25 @@ pub struct ConversationRuntime<C, T> {
     max_iterations: usize,
     usage_tracker: UsageTracker,
     hook_runner: HookRunner,
-    hook_abort_signal: HookAbortSignal,
-    hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
+    auto_compaction_input_tokens_threshold: u32,
+    plugin_hook_runner: Option<PluginHookRunner>,
+    plugin_registry: Option<PluginRegistry>,
+    plugins_shutdown: bool,
+}
+
+impl<C, T> ConversationRuntime<C, T> {
+    fn shutdown_registered_plugins(&mut self) -> Result<(), RuntimeError> {
+        if self.plugins_shutdown {
+            return Ok(());
+        }
+        if let Some(registry) = &self.plugin_registry {
+            registry
+                .shutdown()
+                .map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
+        }
+        self.plugins_shutdown = true;
+        Ok(())
+    }
 }
 
 impl<C, T> ConversationRuntime<C, T>
@@ -146,103 +172,55 @@ where
             max_iterations: usize::MAX,
             usage_tracker,
             hook_runner: HookRunner::from_feature_config(&feature_config),
-            hook_abort_signal: HookAbortSignal::default(),
-            hook_progress_reporter: None,
+            auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
+            plugin_hook_runner: None,
+            plugin_registry: None,
+            plugins_shutdown: false,
         }
     }
 
-    #[must_use]
-    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
-        self.max_iterations = max_iterations;
-        self
+    #[allow(clippy::needless_pass_by_value)]
+    pub fn new_with_plugins(
+        session: Session,
+        api_client: C,
+        tool_executor: T,
+        permission_policy: PermissionPolicy,
+        system_prompt: Vec<String>,
+        feature_config: RuntimeFeatureConfig,
+        plugin_registry: PluginRegistry,
+    ) -> Result<Self, RuntimeError> {
+        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}")))?;
+        let mut runtime = Self::new_with_features(
+            session,
+            api_client,
+            tool_executor,
+            permission_policy,
+            system_prompt,
+            feature_config,
+        );
+        runtime.plugin_hook_runner = Some(plugin_hook_runner);
+        runtime.plugin_registry = Some(plugin_registry);
+        Ok(runtime)
     }
 
     #[must_use]
-    pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
-        self.hook_abort_signal = hook_abort_signal;
+    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
+        self.max_iterations = max_iterations;
         self
     }
 
     #[must_use]
-    pub fn with_hook_progress_reporter(
-        mut self,
-        hook_progress_reporter: Box<dyn HookProgressReporter>,
-    ) -> Self {
-        self.hook_progress_reporter = Some(hook_progress_reporter);
+    pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self {
+        self.auto_compaction_input_tokens_threshold = threshold;
         self
     }
 
-    fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
-        if let Some(reporter) = self.hook_progress_reporter.as_mut() {
-            self.hook_runner.run_pre_tool_use_with_context(
-                tool_name,
-                input,
-                Some(&self.hook_abort_signal),
-                Some(reporter.as_mut()),
-            )
-        } else {
-            self.hook_runner.run_pre_tool_use_with_context(
-                tool_name,
-                input,
-                Some(&self.hook_abort_signal),
-                None,
-            )
-        }
-    }
-
-    fn run_post_tool_use_hook(
-        &mut self,
-        tool_name: &str,
-        input: &str,
-        output: &str,
-        is_error: bool,
-    ) -> HookRunResult {
-        if let Some(reporter) = self.hook_progress_reporter.as_mut() {
-            self.hook_runner.run_post_tool_use_with_context(
-                tool_name,
-                input,
-                output,
-                is_error,
-                Some(&self.hook_abort_signal),
-                Some(reporter.as_mut()),
-            )
-        } else {
-            self.hook_runner.run_post_tool_use_with_context(
-                tool_name,
-                input,
-                output,
-                is_error,
-                Some(&self.hook_abort_signal),
-                None,
-            )
-        }
-    }
-
-    fn run_post_tool_use_failure_hook(
-        &mut self,
-        tool_name: &str,
-        input: &str,
-        output: &str,
-    ) -> HookRunResult {
-        if let Some(reporter) = self.hook_progress_reporter.as_mut() {
-            self.hook_runner.run_post_tool_use_failure_with_context(
-                tool_name,
-                input,
-                output,
-                Some(&self.hook_abort_signal),
-                Some(reporter.as_mut()),
-            )
-        } else {
-            self.hook_runner.run_post_tool_use_failure_with_context(
-                tool_name,
-                input,
-                output,
-                Some(&self.hook_abort_signal),
-                None,
-            )
-        }
-    }
-
     #[allow(clippy::too_many_lines)]
     pub fn run_turn(
         &mut self,
@@ -256,6 +234,7 @@ where
         let mut assistant_messages = Vec::new();
         let mut tool_results = Vec::new();
         let mut iterations = 0;
+        let mut max_turn_input_tokens = 0;
 
         loop {
             iterations += 1;
@@ -272,6 +251,7 @@ where
             let events = self.api_client.stream(request)?;
             let (assistant_message, usage) = build_assistant_message(events)?;
             if let Some(usage) = usage {
+                max_turn_input_tokens = max_turn_input_tokens.max(usage.input_tokens);
                 self.usage_tracker.record(usage);
             }
             let pending_tool_uses = assistant_message
@@ -293,96 +273,108 @@ where
             }
 
             for (tool_use_id, tool_name, input) in pending_tool_uses {
-                let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
-                let effective_input = pre_hook_result
-                    .updated_input()
-                    .map_or_else(|| input.clone(), ToOwned::to_owned);
-                let permission_context = PermissionContext::new(
-                    pre_hook_result.permission_override(),
-                    pre_hook_result.permission_reason().map(ToOwned::to_owned),
-                );
-
-                let permission_outcome = if pre_hook_result.is_cancelled() {
-                    PermissionOutcome::Deny {
-                        reason: format_hook_message(
-                            &pre_hook_result,
-                            &format!("PreToolUse hook cancelled tool `{tool_name}`"),
-                        ),
-                    }
-                } else if pre_hook_result.is_denied() {
-                    PermissionOutcome::Deny {
-                        reason: format_hook_message(
-                            &pre_hook_result,
-                            &format!("PreToolUse hook denied tool `{tool_name}`"),
-                        ),
-                    }
-                } else if let Some(prompt) = prompter.as_mut() {
-                    self.permission_policy.authorize_with_context(
-                        &tool_name,
-                        &effective_input,
-                        &permission_context,
-                        Some(*prompt),
-                    )
+                let permission_outcome = if let Some(prompt) = prompter.as_mut() {
+                    self.permission_policy
+                        .authorize(&tool_name, &input, Some(*prompt))
                 } else {
-                    self.permission_policy.authorize_with_context(
-                        &tool_name,
-                        &effective_input,
-                        &permission_context,
-                        None,
-                    )
+                    self.permission_policy.authorize(&tool_name, &input, None)
                 };
 
                 let result_message = match permission_outcome {
                     PermissionOutcome::Allow => {
-                        let (mut output, mut is_error) =
-                            match self.tool_executor.execute(&tool_name, &effective_input) {
-                                Ok(output) => (output, false),
-                                Err(error) => (error.to_string(), true),
-                            };
-                        output = merge_hook_feedback(pre_hook_result.messages(), output, false);
-
-                        let post_hook_result = if is_error {
-                            self.run_post_tool_use_failure_hook(
-                                &tool_name,
-                                &effective_input,
-                                &output,
+                        let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
+                        if 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(pre_hook_result.messages(), &deny_message),
+                                true,
                             )
                         } else {
-                            self.run_post_tool_use_hook(
-                                &tool_name,
-                                &effective_input,
-                                &output,
-                                false,
-                            )
-                        };
-                        if post_hook_result.is_denied() || post_hook_result.is_cancelled() {
-                            is_error = true;
+                            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}`");
+                                let mut messages = pre_hook_result.messages().to_vec();
+                                messages.extend(plugin_pre_hook_result.messages().iter().cloned());
+                                ConversationMessage::tool_result(
+                                    tool_use_id,
+                                    tool_name,
+                                    format_hook_message(&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 hook_output = output.clone();
+                                let post_hook_result = self.hook_runner.run_post_tool_use(
+                                    &tool_name,
+                                    &input,
+                                    &hook_output,
+                                    is_error,
+                                );
+                                let plugin_post_hook_result = self.run_plugin_post_tool_use(
+                                    &tool_name,
+                                    &input,
+                                    &hook_output,
+                                    is_error,
+                                );
+                                if post_hook_result.is_denied() {
+                                    is_error = true;
+                                }
+                                if plugin_post_hook_result.is_denied() {
+                                    is_error = true;
+                                }
+                                output = merge_hook_feedback(
+                                    post_hook_result.messages(),
+                                    output,
+                                    post_hook_result.is_denied(),
+                                );
+                                output = merge_hook_feedback(
+                                    plugin_post_hook_result.messages(),
+                                    output,
+                                    plugin_post_hook_result.is_denied(),
+                                );
+
+                                ConversationMessage::tool_result(
+                                    tool_use_id,
+                                    tool_name,
+                                    output,
+                                    is_error,
+                                )
+                            }
                         }
-                        output = merge_hook_feedback(
-                            post_hook_result.messages(),
-                            output,
-                            post_hook_result.is_denied() || post_hook_result.is_cancelled(),
-                        );
-
-                        ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
                     }
-                    PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
-                        tool_use_id,
-                        tool_name,
-                        merge_hook_feedback(pre_hook_result.messages(), reason, true),
-                        true,
-                    ),
+                    PermissionOutcome::Deny { reason } => {
+                        ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
+                    }
                 };
                 self.session.messages.push(result_message.clone());
                 tool_results.push(result_message);
             }
         }
 
+        let auto_compaction = self.maybe_auto_compact(max_turn_input_tokens);
+
         Ok(TurnSummary {
             assistant_messages,
             tool_results,
             iterations,
             usage: self.usage_tracker.cumulative_usage(),
+            auto_compaction,
         })
     }
 
@@ -407,11 +399,83 @@ where
     }
 
     #[must_use]
-    pub fn into_session(self) -> Session {
-        self.session
+    pub fn into_session(mut self) -> Session {
+        let _ = self.shutdown_registered_plugins();
+        std::mem::take(&mut self.session)
+    }
+
+    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),
+        )
+    }
+
+    fn maybe_auto_compact(&mut self, turn_input_tokens: u32) -> Option<AutoCompactionEvent> {
+        if turn_input_tokens < self.auto_compaction_input_tokens_threshold {
+            return None;
+        }
+
+        let result = compact_session(
+            &self.session,
+            CompactionConfig {
+                max_estimated_tokens: usize::try_from(self.auto_compaction_input_tokens_threshold)
+                    .unwrap_or(usize::MAX),
+                ..CompactionConfig::default()
+            },
+        );
+
+        if result.removed_message_count == 0 {
+            return None;
+        }
+
+        self.session = result.compacted_session;
+        Some(AutoCompactionEvent {
+            removed_message_count: result.removed_message_count,
+        })
     }
 }
 
+impl<C, T> Drop for ConversationRuntime<C, T> {
+    fn drop(&mut self) {
+        let _ = self.shutdown_registered_plugins();
+    }
+}
+
+#[must_use]
+pub fn auto_compaction_threshold_from_env() -> u32 {
+    parse_auto_compaction_threshold(
+        std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
+            .ok()
+            .as_deref(),
+    )
+}
+
+#[must_use]
+fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
+    value
+        .and_then(|raw| raw.trim().parse::<u32>().ok())
+        .filter(|threshold| *threshold > 0)
+        .unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
+}
+
 fn build_assistant_message(
     events: Vec<AssistantEvent>,
 ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
@@ -459,11 +523,11 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
     }
 }
 
-fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
-    if result.messages().is_empty() {
+fn format_hook_message(messages: &[String], fallback: &str) -> String {
+    if messages.is_empty() {
         fallback.to_string()
     } else {
-        result.messages().join("\n")
+        messages.join("\n")
     }
 }
 
@@ -520,8 +584,9 @@ impl ToolExecutor for StaticToolExecutor {
 #[cfg(test)]
 mod tests {
     use super::{
-        ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
-        StaticToolExecutor,
+        parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
+        AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
+        DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
     };
     use crate::compact::CompactionConfig;
     use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
@@ -532,7 +597,13 @@ mod tests {
     use crate::prompt::{ProjectContext, SystemPromptBuilder};
     use crate::session::{ContentBlock, MessageRole, Session};
     use crate::usage::TokenUsage;
+    use plugins::{PluginManager, PluginManagerConfig};
+    use std::fs;
+    #[cfg(unix)]
+    use std::os::unix::fs::PermissionsExt;
+    use std::path::Path;
     use std::path::PathBuf;
+    use std::time::{SystemTime, UNIX_EPOCH};
 
     struct ScriptedApiClient {
         call_count: usize,
@@ -594,6 +665,68 @@ mod tests {
         }
     }
 
+    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!("runtime-plugin-{label}-{nanos}"))
+    }
+
+    fn write_lifecycle_plugin(root: &Path, name: &str) -> PathBuf {
+        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
+        fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
+        let log_path = root.join("lifecycle.log");
+        fs::write(
+            root.join("lifecycle").join("init.sh"),
+            "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
+        )
+        .expect("write init script");
+        fs::write(
+            root.join("lifecycle").join("shutdown.sh"),
+            "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
+        )
+        .expect("write shutdown script");
+        fs::write(
+            root.join(".claude-plugin").join("plugin.json"),
+            format!(
+                "{{\n  \"name\": \"{name}\",\n  \"version\": \"1.0.0\",\n  \"description\": \"runtime lifecycle plugin\",\n  \"lifecycle\": {{\n    \"Init\": [\"./lifecycle/init.sh\"],\n    \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n  }}\n}}"
+            ),
+        )
+        .expect("write plugin manifest");
+        log_path
+    }
+
+    fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &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"),
+            format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
+        )
+        .expect("write pre hook");
+        fs::write(
+            root.join("hooks").join("post.sh"),
+            format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\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\": \"runtime hook plugin\",\n  \"hooks\": {{\n    \"PreToolUse\": [\"./hooks/pre.sh\"],\n    \"PostToolUse\": [\"./hooks/post.sh\"]\n  }}\n}}"
+            ),
+        )
+        .expect("write plugin manifest");
+    }
+
     #[test]
     fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
         let api_client = ScriptedApiClient { call_count: 0 };
@@ -632,6 +765,7 @@ mod tests {
         assert_eq!(summary.tool_results.len(), 1);
         assert_eq!(runtime.session().messages.len(), 4);
         assert_eq!(summary.usage.output_tokens, 10);
+        assert_eq!(summary.auto_compaction, None);
         assert!(matches!(
             runtime.session().messages[1].blocks[1],
             ContentBlock::ToolUse { .. }
@@ -736,7 +870,6 @@ mod tests {
             RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
                 vec![shell_snippet("printf 'blocked by hook'; exit 2")],
                 Vec::new(),
-                Vec::new(),
             )),
         );
 
@@ -803,7 +936,6 @@ mod tests {
             RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
                 vec![shell_snippet("printf 'pre hook ran'")],
                 vec![shell_snippet("printf 'post hook ran'")],
-                Vec::new(),
             )),
         );
 
@@ -836,6 +968,153 @@ mod tests {
         );
     }
 
+    #[test]
+    fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
+        let config_home = temp_dir("config");
+        let source_root = temp_dir("source");
+        let _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        let install = manager
+            .install(source_root.to_str().expect("utf8 path"))
+            .expect("install should succeed");
+        let log_path = install.install_path.join("lifecycle.log");
+        let registry = manager.plugin_registry().expect("registry should load");
+
+        {
+            let runtime = ConversationRuntime::new_with_plugins(
+                Session::new(),
+                ScriptedApiClient { call_count: 0 },
+                StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
+                PermissionPolicy::new(PermissionMode::WorkspaceWrite),
+                vec!["system".to_string()],
+                RuntimeFeatureConfig::default(),
+                registry,
+            )
+            .expect("runtime should initialize plugins");
+
+            let log = fs::read_to_string(&log_path).expect("init log should exist");
+            assert_eq!(log, "init\n");
+            drop(runtime);
+        }
+
+        let log = fs::read_to_string(&log_path).expect("shutdown log should exist");
+        assert_eq!(log, "init\nshutdown\n");
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
+    }
+
+    #[test]
+    fn executes_hooks_from_installed_plugins_during_tool_use() {
+        struct TwoCallApiClient {
+            calls: usize,
+        }
+
+        impl ApiClient for TwoCallApiClient {
+            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                self.calls += 1;
+                match self.calls {
+                    1 => Ok(vec![
+                        AssistantEvent::ToolUse {
+                            id: "tool-1".to_string(),
+                            name: "add".to_string(),
+                            input: r#"{"lhs":2,"rhs":2}"#.to_string(),
+                        },
+                        AssistantEvent::MessageStop,
+                    ]),
+                    2 => {
+                        assert!(request
+                            .messages
+                            .iter()
+                            .any(|message| message.role == MessageRole::Tool));
+                        Ok(vec![
+                            AssistantEvent::TextDelta("done".to_string()),
+                            AssistantEvent::MessageStop,
+                        ])
+                    }
+                    _ => Err(RuntimeError::new("unexpected extra API call")),
+                }
+            }
+        }
+
+        let config_home = temp_dir("hook-config");
+        let first_source_root = temp_dir("hook-source-a");
+        let second_source_root = temp_dir("hook-source-b");
+        write_hook_plugin(
+            &first_source_root,
+            "first",
+            "plugin pre one",
+            "plugin post one",
+        );
+        write_hook_plugin(
+            &second_source_root,
+            "second",
+            "plugin pre two",
+            "plugin post two",
+        );
+
+        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+        manager
+            .install(first_source_root.to_str().expect("utf8 path"))
+            .expect("first plugin install should succeed");
+        manager
+            .install(second_source_root.to_str().expect("utf8 path"))
+            .expect("second plugin install should succeed");
+        let registry = manager.plugin_registry().expect("registry should load");
+
+        let mut runtime = ConversationRuntime::new_with_plugins(
+            Session::new(),
+            TwoCallApiClient { calls: 0 },
+            StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+            RuntimeFeatureConfig::default(),
+            registry,
+        )
+        .expect("runtime should load plugin hooks");
+
+        let summary = runtime
+            .run_turn("use add", None)
+            .expect("tool loop succeeds");
+
+        assert_eq!(summary.tool_results.len(), 1);
+        let ContentBlock::ToolResult {
+            is_error, output, ..
+        } = &summary.tool_results[0].blocks[0]
+        else {
+            panic!("expected tool result block");
+        };
+        assert!(
+            !*is_error,
+            "plugin hooks should not force an error: {output:?}"
+        );
+        assert!(
+            output.contains('4'),
+            "tool output missing value: {output:?}"
+        );
+        assert!(
+            output.contains("plugin pre one"),
+            "tool output missing first pre hook feedback: {output:?}"
+        );
+        assert!(
+            output.contains("plugin pre two"),
+            "tool output missing second pre hook feedback: {output:?}"
+        );
+        assert!(
+            output.contains("plugin post one"),
+            "tool output missing first post hook feedback: {output:?}"
+        );
+        assert!(
+            output.contains("plugin post two"),
+            "tool output missing second post hook feedback: {output:?}"
+        );
+
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(first_source_root);
+        let _ = fs::remove_dir_all(second_source_root);
+    }
+
     #[test]
     fn reconstructs_usage_tracker_from_restored_session() {
         struct SimpleApi;
@@ -924,4 +1203,177 @@ mod tests {
     fn shell_snippet(script: &str) -> String {
         script.to_string()
     }
+
+    #[test]
+    fn auto_compacts_when_turn_input_threshold_is_crossed() {
+        struct SimpleApi;
+        impl ApiClient for SimpleApi {
+            fn stream(
+                &mut self,
+                _request: ApiRequest,
+            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                Ok(vec![
+                    AssistantEvent::TextDelta("done".to_string()),
+                    AssistantEvent::Usage(TokenUsage {
+                        input_tokens: 120_000,
+                        output_tokens: 4,
+                        cache_creation_input_tokens: 0,
+                        cache_read_input_tokens: 0,
+                    }),
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let session = Session {
+            version: 1,
+            messages: vec![
+                crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "two ".repeat(30_000),
+                }]),
+                crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "four ".repeat(30_000),
+                }]),
+            ],
+        };
+
+        let mut runtime = ConversationRuntime::new(
+            session,
+            SimpleApi,
+            StaticToolExecutor::new(),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+        )
+        .with_auto_compaction_input_tokens_threshold(100_000);
+
+        let summary = runtime
+            .run_turn("trigger", None)
+            .expect("turn should succeed");
+
+        assert_eq!(
+            summary.auto_compaction,
+            Some(AutoCompactionEvent {
+                removed_message_count: 2,
+            })
+        );
+        assert_eq!(runtime.session().messages[0].role, MessageRole::System);
+    }
+
+    #[test]
+    fn auto_compaction_does_not_repeat_after_context_is_already_compacted() {
+        struct SequentialUsageApi {
+            call_count: usize,
+        }
+
+        impl ApiClient for SequentialUsageApi {
+            fn stream(
+                &mut self,
+                _request: ApiRequest,
+            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                self.call_count += 1;
+                let input_tokens = if self.call_count == 1 { 120_000 } else { 64 };
+                Ok(vec![
+                    AssistantEvent::TextDelta("done".to_string()),
+                    AssistantEvent::Usage(TokenUsage {
+                        input_tokens,
+                        output_tokens: 4,
+                        cache_creation_input_tokens: 0,
+                        cache_read_input_tokens: 0,
+                    }),
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let session = Session {
+            version: 1,
+            messages: vec![
+                crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "two ".repeat(30_000),
+                }]),
+                crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "four ".repeat(30_000),
+                }]),
+            ],
+        };
+
+        let mut runtime = ConversationRuntime::new(
+            session,
+            SequentialUsageApi { call_count: 0 },
+            StaticToolExecutor::new(),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+        )
+        .with_auto_compaction_input_tokens_threshold(100_000);
+
+        let first = runtime
+            .run_turn("trigger", None)
+            .expect("first turn should succeed");
+        assert_eq!(
+            first.auto_compaction,
+            Some(AutoCompactionEvent {
+                removed_message_count: 2,
+            })
+        );
+
+        let second = runtime
+            .run_turn("continue", None)
+            .expect("second turn should succeed");
+        assert_eq!(second.auto_compaction, None);
+        assert_eq!(runtime.session().messages[0].role, MessageRole::System);
+    }
+
+    #[test]
+    fn skips_auto_compaction_below_threshold() {
+        struct SimpleApi;
+        impl ApiClient for SimpleApi {
+            fn stream(
+                &mut self,
+                _request: ApiRequest,
+            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                Ok(vec![
+                    AssistantEvent::TextDelta("done".to_string()),
+                    AssistantEvent::Usage(TokenUsage {
+                        input_tokens: 99_999,
+                        output_tokens: 4,
+                        cache_creation_input_tokens: 0,
+                        cache_read_input_tokens: 0,
+                    }),
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let mut runtime = ConversationRuntime::new(
+            Session::new(),
+            SimpleApi,
+            StaticToolExecutor::new(),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+        )
+        .with_auto_compaction_input_tokens_threshold(100_000);
+
+        let summary = runtime
+            .run_turn("trigger", None)
+            .expect("turn should succeed");
+        assert_eq!(summary.auto_compaction, None);
+        assert_eq!(runtime.session().messages.len(), 2);
+    }
+
+    #[test]
+    fn auto_compaction_threshold_defaults_and_parses_values() {
+        assert_eq!(
+            parse_auto_compaction_threshold(None),
+            DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
+        );
+        assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
+        assert_eq!(
+            parse_auto_compaction_threshold(Some("not-a-number")),
+            DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
+        );
+    }
 }

+ 58 - 556
rust/crates/runtime/src/hooks.rs

@@ -1,90 +1,30 @@
 use std::ffi::OsStr;
-use std::io::Write;
-use std::process::{Command, Stdio};
-use std::sync::{
-    atomic::{AtomicBool, Ordering},
-    Arc,
-};
-use std::thread;
-use std::time::Duration;
+use std::path::Path;
+use std::process::Command;
 
-use serde_json::{json, Value};
+use serde_json::json;
 
 use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
-use crate::permissions::PermissionOverride;
-
-pub type HookPermissionDecision = PermissionOverride;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum HookEvent {
     PreToolUse,
     PostToolUse,
-    PostToolUseFailure,
 }
 
 impl HookEvent {
-    #[must_use]
-    pub fn as_str(self) -> &'static str {
+    fn as_str(self) -> &'static str {
         match self {
             Self::PreToolUse => "PreToolUse",
             Self::PostToolUse => "PostToolUse",
-            Self::PostToolUseFailure => "PostToolUseFailure",
         }
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum HookProgressEvent {
-    Started {
-        event: HookEvent,
-        tool_name: String,
-        command: String,
-    },
-    Completed {
-        event: HookEvent,
-        tool_name: String,
-        command: String,
-    },
-    Cancelled {
-        event: HookEvent,
-        tool_name: String,
-        command: String,
-    },
-}
-
-pub trait HookProgressReporter {
-    fn on_event(&mut self, event: &HookProgressEvent);
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct HookAbortSignal {
-    aborted: Arc<AtomicBool>,
-}
-
-impl HookAbortSignal {
-    #[must_use]
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    pub fn abort(&self) {
-        self.aborted.store(true, Ordering::SeqCst);
-    }
-
-    #[must_use]
-    pub fn is_aborted(&self) -> bool {
-        self.aborted.load(Ordering::SeqCst)
-    }
-}
-
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct HookRunResult {
     denied: bool,
-    cancelled: bool,
     messages: Vec<String>,
-    permission_override: Option<PermissionOverride>,
-    permission_reason: Option<String>,
-    updated_input: Option<String>,
 }
 
 impl HookRunResult {
@@ -92,11 +32,7 @@ impl HookRunResult {
     pub fn allow(messages: Vec<String>) -> Self {
         Self {
             denied: false,
-            cancelled: false,
             messages,
-            permission_override: None,
-            permission_reason: None,
-            updated_input: None,
         }
     }
 
@@ -105,40 +41,10 @@ impl HookRunResult {
         self.denied
     }
 
-    #[must_use]
-    pub fn is_cancelled(&self) -> bool {
-        self.cancelled
-    }
-
     #[must_use]
     pub fn messages(&self) -> &[String] {
         &self.messages
     }
-
-    #[must_use]
-    pub fn permission_override(&self) -> Option<PermissionOverride> {
-        self.permission_override
-    }
-
-    #[must_use]
-    pub fn permission_decision(&self) -> Option<HookPermissionDecision> {
-        self.permission_override
-    }
-
-    #[must_use]
-    pub fn permission_reason(&self) -> Option<&str> {
-        self.permission_reason.as_deref()
-    }
-
-    #[must_use]
-    pub fn updated_input(&self) -> Option<&str> {
-        self.updated_input.as_deref()
-    }
-
-    #[must_use]
-    pub fn updated_input_json(&self) -> Option<&str> {
-        self.updated_input()
-    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -159,39 +65,16 @@ impl HookRunner {
 
     #[must_use]
     pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
-        self.run_pre_tool_use_with_context(tool_name, tool_input, None, None)
-    }
-
-    #[must_use]
-    pub fn run_pre_tool_use_with_context(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        abort_signal: Option<&HookAbortSignal>,
-        reporter: Option<&mut dyn HookProgressReporter>,
-    ) -> HookRunResult {
-        Self::run_commands(
+        self.run_commands(
             HookEvent::PreToolUse,
             self.config.pre_tool_use(),
             tool_name,
             tool_input,
             None,
             false,
-            abort_signal,
-            reporter,
         )
     }
 
-    #[must_use]
-    pub fn run_pre_tool_use_with_signal(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        abort_signal: Option<&HookAbortSignal>,
-    ) -> HookRunResult {
-        self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None)
-    }
-
     #[must_use]
     pub fn run_post_tool_use(
         &self,
@@ -200,147 +83,43 @@ impl HookRunner {
         tool_output: &str,
         is_error: bool,
     ) -> HookRunResult {
-        self.run_post_tool_use_with_context(
-            tool_name,
-            tool_input,
-            tool_output,
-            is_error,
-            None,
-            None,
-        )
-    }
-
-    #[must_use]
-    pub fn run_post_tool_use_with_context(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        tool_output: &str,
-        is_error: bool,
-        abort_signal: Option<&HookAbortSignal>,
-        reporter: Option<&mut dyn HookProgressReporter>,
-    ) -> HookRunResult {
-        Self::run_commands(
+        self.run_commands(
             HookEvent::PostToolUse,
             self.config.post_tool_use(),
             tool_name,
             tool_input,
             Some(tool_output),
             is_error,
-            abort_signal,
-            reporter,
         )
     }
 
-    #[must_use]
-    pub fn run_post_tool_use_with_signal(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        tool_output: &str,
-        is_error: bool,
-        abort_signal: Option<&HookAbortSignal>,
-    ) -> HookRunResult {
-        self.run_post_tool_use_with_context(
-            tool_name,
-            tool_input,
-            tool_output,
-            is_error,
-            abort_signal,
-            None,
-        )
-    }
-
-    #[must_use]
-    pub fn run_post_tool_use_failure(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        tool_error: &str,
-    ) -> HookRunResult {
-        self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None)
-    }
-
-    #[must_use]
-    pub fn run_post_tool_use_failure_with_context(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        tool_error: &str,
-        abort_signal: Option<&HookAbortSignal>,
-        reporter: Option<&mut dyn HookProgressReporter>,
-    ) -> HookRunResult {
-        Self::run_commands(
-            HookEvent::PostToolUseFailure,
-            self.config.post_tool_use_failure(),
-            tool_name,
-            tool_input,
-            Some(tool_error),
-            true,
-            abort_signal,
-            reporter,
-        )
-    }
-
-    #[must_use]
-    pub fn run_post_tool_use_failure_with_signal(
-        &self,
-        tool_name: &str,
-        tool_input: &str,
-        tool_error: &str,
-        abort_signal: Option<&HookAbortSignal>,
-    ) -> HookRunResult {
-        self.run_post_tool_use_failure_with_context(
-            tool_name,
-            tool_input,
-            tool_error,
-            abort_signal,
-            None,
-        )
-    }
-
-    #[allow(clippy::too_many_arguments)]
     fn run_commands(
+        &self,
         event: HookEvent,
         commands: &[String],
         tool_name: &str,
         tool_input: &str,
         tool_output: Option<&str>,
         is_error: bool,
-        abort_signal: Option<&HookAbortSignal>,
-        mut reporter: Option<&mut dyn HookProgressReporter>,
     ) -> HookRunResult {
         if commands.is_empty() {
             return HookRunResult::allow(Vec::new());
         }
 
-        if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
-            return HookRunResult {
-                denied: false,
-                cancelled: true,
-                messages: vec![format!(
-                    "{} hook cancelled before execution",
-                    event.as_str()
-                )],
-                permission_override: None,
-                permission_reason: None,
-                updated_input: None,
-            };
-        }
+        let payload = json!({
+            "hook_event_name": event.as_str(),
+            "tool_name": tool_name,
+            "tool_input": parse_tool_input(tool_input),
+            "tool_input_json": tool_input,
+            "tool_output": tool_output,
+            "tool_result_is_error": is_error,
+        })
+        .to_string();
 
-        let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
-        let mut result = HookRunResult::allow(Vec::new());
+        let mut messages = Vec::new();
 
         for command in commands {
-            if let Some(reporter) = reporter.as_deref_mut() {
-                reporter.on_event(&HookProgressEvent::Started {
-                    event,
-                    tool_name: tool_name.to_string(),
-                    command: command.clone(),
-                });
-            }
-
-            match Self::run_command(
+            match self.run_command(
                 command,
                 event,
                 tool_name,
@@ -348,60 +127,32 @@ impl HookRunner {
                 tool_output,
                 is_error,
                 &payload,
-                abort_signal,
             ) {
-                HookCommandOutcome::Allow { parsed } => {
-                    if let Some(reporter) = reporter.as_deref_mut() {
-                        reporter.on_event(&HookProgressEvent::Completed {
-                            event,
-                            tool_name: tool_name.to_string(),
-                            command: command.clone(),
-                        });
+                HookCommandOutcome::Allow { message } => {
+                    if let Some(message) = message {
+                        messages.push(message);
                     }
-                    merge_parsed_hook_output(&mut result, parsed);
                 }
-                HookCommandOutcome::Deny { parsed } => {
-                    if let Some(reporter) = reporter.as_deref_mut() {
-                        reporter.on_event(&HookProgressEvent::Completed {
-                            event,
-                            tool_name: tool_name.to_string(),
-                            command: command.clone(),
-                        });
-                    }
-                    merge_parsed_hook_output(&mut result, parsed);
-                    result.denied = true;
-                    return result;
-                }
-                HookCommandOutcome::Warn { message } => {
-                    if let Some(reporter) = reporter.as_deref_mut() {
-                        reporter.on_event(&HookProgressEvent::Completed {
-                            event,
-                            tool_name: tool_name.to_string(),
-                            command: command.clone(),
-                        });
-                    }
-                    result.messages.push(message);
-                }
-                HookCommandOutcome::Cancelled { message } => {
-                    if let Some(reporter) = reporter.as_deref_mut() {
-                        reporter.on_event(&HookProgressEvent::Cancelled {
-                            event,
-                            tool_name: tool_name.to_string(),
-                            command: command.clone(),
-                        });
-                    }
-                    result.cancelled = true;
-                    result.messages.push(message);
-                    return result;
+                HookCommandOutcome::Deny { message } => {
+                    let message = message.unwrap_or_else(|| {
+                        format!("{} hook denied tool `{tool_name}`", event.as_str())
+                    });
+                    messages.push(message);
+                    return HookRunResult {
+                        denied: true,
+                        messages,
+                    };
                 }
+                HookCommandOutcome::Warn { message } => messages.push(message),
             }
         }
 
-        result
+        HookRunResult::allow(messages)
     }
 
-    #[allow(clippy::too_many_arguments)]
+    #[allow(clippy::too_many_arguments, clippy::unused_self)]
     fn run_command(
+        &self,
         command: &str,
         event: HookEvent,
         tool_name: &str,
@@ -409,12 +160,11 @@ impl HookRunner {
         tool_output: Option<&str>,
         is_error: bool,
         payload: &str,
-        abort_signal: Option<&HookAbortSignal>,
     ) -> HookCommandOutcome {
         let mut child = shell_command(command);
-        child.stdin(Stdio::piped());
-        child.stdout(Stdio::piped());
-        child.stderr(Stdio::piped());
+        child.stdin(std::process::Stdio::piped());
+        child.stdout(std::process::Stdio::piped());
+        child.stderr(std::process::Stdio::piped());
         child.env("HOOK_EVENT", event.as_str());
         child.env("HOOK_TOOL_NAME", tool_name);
         child.env("HOOK_TOOL_INPUT", tool_input);
@@ -423,30 +173,19 @@ impl HookRunner {
             child.env("HOOK_TOOL_OUTPUT", tool_output);
         }
 
-        match child.output_with_stdin(payload.as_bytes(), abort_signal) {
-            Ok(CommandExecution::Finished(output)) => {
+        match child.output_with_stdin(payload.as_bytes()) {
+            Ok(output) => {
                 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
                 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
-                let parsed = parse_hook_output(&stdout);
+                let message = (!stdout.is_empty()).then_some(stdout);
                 match output.status.code() {
-                    Some(0) => {
-                        if parsed.deny {
-                            HookCommandOutcome::Deny { parsed }
-                        } else {
-                            HookCommandOutcome::Allow { parsed }
-                        }
-                    }
-                    Some(2) => HookCommandOutcome::Deny {
-                        parsed: parsed.with_fallback_message(format!(
-                            "{} hook denied tool `{tool_name}`",
-                            event.as_str()
-                        )),
-                    },
+                    Some(0) => HookCommandOutcome::Allow { message },
+                    Some(2) => HookCommandOutcome::Deny { message },
                     Some(code) => HookCommandOutcome::Warn {
                         message: format_hook_warning(
                             command,
                             code,
-                            parsed.primary_message(),
+                            message.as_deref(),
                             stderr.as_str(),
                         ),
                     },
@@ -458,12 +197,6 @@ impl HookRunner {
                     },
                 }
             }
-            Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled {
-                message: format!(
-                    "{} hook `{command}` cancelled while handling `{tool_name}`",
-                    event.as_str()
-                ),
-            },
             Err(error) => HookCommandOutcome::Warn {
                 message: format!(
                     "{} hook `{command}` failed to start for `{tool_name}`: {error}",
@@ -475,131 +208,12 @@ impl HookRunner {
 }
 
 enum HookCommandOutcome {
-    Allow { parsed: ParsedHookOutput },
-    Deny { parsed: ParsedHookOutput },
+    Allow { message: Option<String> },
+    Deny { message: Option<String> },
     Warn { message: String },
-    Cancelled { message: String },
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Default)]
-struct ParsedHookOutput {
-    messages: Vec<String>,
-    deny: bool,
-    permission_override: Option<PermissionOverride>,
-    permission_reason: Option<String>,
-    updated_input: Option<String>,
-}
-
-impl ParsedHookOutput {
-    fn with_fallback_message(mut self, fallback: String) -> Self {
-        if self.messages.is_empty() {
-            self.messages.push(fallback);
-        }
-        self
-    }
-
-    fn primary_message(&self) -> Option<&str> {
-        self.messages.first().map(String::as_str)
-    }
-}
-
-fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) {
-    target.messages.extend(parsed.messages);
-    if parsed.permission_override.is_some() {
-        target.permission_override = parsed.permission_override;
-    }
-    if parsed.permission_reason.is_some() {
-        target.permission_reason = parsed.permission_reason;
-    }
-    if parsed.updated_input.is_some() {
-        target.updated_input = parsed.updated_input;
-    }
 }
 
-fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
-    if stdout.is_empty() {
-        return ParsedHookOutput::default();
-    }
-
-    let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
-        return ParsedHookOutput {
-            messages: vec![stdout.to_string()],
-            ..ParsedHookOutput::default()
-        };
-    };
-
-    let mut parsed = ParsedHookOutput::default();
-
-    if let Some(message) = root.get("systemMessage").and_then(Value::as_str) {
-        parsed.messages.push(message.to_string());
-    }
-    if let Some(message) = root.get("reason").and_then(Value::as_str) {
-        parsed.messages.push(message.to_string());
-    }
-    if root.get("continue").and_then(Value::as_bool) == Some(false)
-        || root.get("decision").and_then(Value::as_str) == Some("block")
-    {
-        parsed.deny = true;
-    }
-
-    if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") {
-        if let Some(Value::String(additional_context)) = specific.get("additionalContext") {
-            parsed.messages.push(additional_context.clone());
-        }
-        if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) {
-            parsed.permission_override = match decision {
-                "allow" => Some(PermissionOverride::Allow),
-                "deny" => Some(PermissionOverride::Deny),
-                "ask" => Some(PermissionOverride::Ask),
-                _ => None,
-            };
-        }
-        if let Some(reason) = specific
-            .get("permissionDecisionReason")
-            .and_then(Value::as_str)
-        {
-            parsed.permission_reason = Some(reason.to_string());
-        }
-        if let Some(updated_input) = specific.get("updatedInput") {
-            parsed.updated_input = serde_json::to_string(updated_input).ok();
-        }
-    }
-
-    if parsed.messages.is_empty() {
-        parsed.messages.push(stdout.to_string());
-    }
-
-    parsed
-}
-
-fn hook_payload(
-    event: HookEvent,
-    tool_name: &str,
-    tool_input: &str,
-    tool_output: Option<&str>,
-    is_error: bool,
-) -> Value {
-    match event {
-        HookEvent::PostToolUseFailure => json!({
-            "hook_event_name": event.as_str(),
-            "tool_name": tool_name,
-            "tool_input": parse_tool_input(tool_input),
-            "tool_input_json": tool_input,
-            "tool_error": tool_output,
-            "tool_result_is_error": true,
-        }),
-        _ => json!({
-            "hook_event_name": event.as_str(),
-            "tool_name": tool_name,
-            "tool_input": parse_tool_input(tool_input),
-            "tool_input_json": tool_input,
-            "tool_output": tool_output,
-            "tool_result_is_error": is_error,
-        }),
-    }
-}
-
-fn parse_tool_input(tool_input: &str) -> Value {
+fn parse_tool_input(tool_input: &str) -> serde_json::Value {
     serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
 }
 
@@ -625,7 +239,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
     };
 
     #[cfg(not(windows))]
-    let command_builder = {
+    let command_builder = if Path::new(command).exists() {
+        let mut command_builder = Command::new("sh");
+        command_builder.arg(command);
+        CommandWithStdin::new(command_builder)
+    } else {
         let mut command_builder = Command::new("sh");
         command_builder.arg("-lc").arg(command);
         CommandWithStdin::new(command_builder)
@@ -643,17 +261,17 @@ impl CommandWithStdin {
         Self { command }
     }
 
-    fn stdin(&mut self, cfg: Stdio) -> &mut Self {
+    fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
         self.command.stdin(cfg);
         self
     }
 
-    fn stdout(&mut self, cfg: Stdio) -> &mut Self {
+    fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
         self.command.stdout(cfg);
         self
     }
 
-    fn stderr(&mut self, cfg: Stdio) -> &mut Self {
+    fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
         self.command.stderr(cfg);
         self
     }
@@ -667,64 +285,26 @@ impl CommandWithStdin {
         self
     }
 
-    fn output_with_stdin(
-        &mut self,
-        stdin: &[u8],
-        abort_signal: Option<&HookAbortSignal>,
-    ) -> std::io::Result<CommandExecution> {
+    fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
         let mut child = self.command.spawn()?;
         if let Some(mut child_stdin) = child.stdin.take() {
+            use std::io::Write;
             child_stdin.write_all(stdin)?;
         }
-
-        loop {
-            if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
-                let _ = child.kill();
-                let _ = child.wait_with_output();
-                return Ok(CommandExecution::Cancelled);
-            }
-
-            match child.try_wait()? {
-                Some(_) => return child.wait_with_output().map(CommandExecution::Finished),
-                None => thread::sleep(Duration::from_millis(20)),
-            }
-        }
+        child.wait_with_output()
     }
 }
 
-enum CommandExecution {
-    Finished(std::process::Output),
-    Cancelled,
-}
-
 #[cfg(test)]
 mod tests {
-    use std::thread;
-    use std::time::Duration;
-
-    use super::{
-        HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
-        HookRunner,
-    };
+    use super::{HookRunResult, HookRunner};
     use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
-    use crate::permissions::PermissionOverride;
-
-    struct RecordingReporter {
-        events: Vec<HookProgressEvent>,
-    }
-
-    impl HookProgressReporter for RecordingReporter {
-        fn on_event(&mut self, event: &HookProgressEvent) {
-            self.events.push(event.clone());
-        }
-    }
 
     #[test]
     fn allows_exit_code_zero_and_captures_stdout() {
         let runner = HookRunner::new(RuntimeHookConfig::new(
             vec![shell_snippet("printf 'pre ok'")],
             Vec::new(),
-            Vec::new(),
         ));
 
         let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
@@ -737,7 +317,6 @@ mod tests {
         let runner = HookRunner::new(RuntimeHookConfig::new(
             vec![shell_snippet("printf 'blocked by hook'; exit 2")],
             Vec::new(),
-            Vec::new(),
         ));
 
         let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
@@ -752,7 +331,6 @@ mod tests {
             RuntimeHookConfig::new(
                 vec![shell_snippet("printf 'warning hook'; exit 1")],
                 Vec::new(),
-                Vec::new(),
             ),
         ));
 
@@ -765,82 +343,6 @@ mod tests {
             .any(|message| message.contains("allowing tool execution to continue")));
     }
 
-    #[test]
-    fn parses_pre_hook_permission_override_and_updated_input() {
-        let runner = HookRunner::new(RuntimeHookConfig::new(
-            vec![shell_snippet(
-                r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
-            )],
-            Vec::new(),
-            Vec::new(),
-        ));
-
-        let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#);
-
-        assert_eq!(
-            result.permission_override(),
-            Some(PermissionOverride::Allow)
-        );
-        assert_eq!(result.permission_reason(), Some("hook ok"));
-        assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#));
-        assert!(result.messages().iter().any(|message| message == "updated"));
-    }
-
-    #[test]
-    fn runs_post_tool_use_failure_hooks() {
-        let runner = HookRunner::new(RuntimeHookConfig::new(
-            Vec::new(),
-            Vec::new(),
-            vec![shell_snippet("printf 'failure hook ran'")],
-        ));
-
-        let result =
-            runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
-
-        assert!(!result.is_denied());
-        assert_eq!(result.messages(), &["failure hook ran".to_string()]);
-    }
-
-    #[test]
-    fn abort_signal_cancels_long_running_hook_and_reports_progress() {
-        let runner = HookRunner::new(RuntimeHookConfig::new(
-            vec![shell_snippet("sleep 5")],
-            Vec::new(),
-            Vec::new(),
-        ));
-        let abort_signal = HookAbortSignal::new();
-        let abort_signal_for_thread = abort_signal.clone();
-        let mut reporter = RecordingReporter { events: Vec::new() };
-
-        thread::spawn(move || {
-            thread::sleep(Duration::from_millis(100));
-            abort_signal_for_thread.abort();
-        });
-
-        let result = runner.run_pre_tool_use_with_context(
-            "bash",
-            r#"{"command":"sleep 5"}"#,
-            Some(&abort_signal),
-            Some(&mut reporter),
-        );
-
-        assert!(result.is_cancelled());
-        assert!(reporter.events.iter().any(|event| matches!(
-            event,
-            HookProgressEvent::Started {
-                event: HookEvent::PreToolUse,
-                ..
-            }
-        )));
-        assert!(reporter.events.iter().any(|event| matches!(
-            event,
-            HookProgressEvent::Cancelled {
-                event: HookEvent::PreToolUse,
-                ..
-            }
-        )));
-    }
-
     #[cfg(windows)]
     fn shell_snippet(script: &str) -> String {
         script.replace('\'', "\"")

+ 8 - 10
rust/crates/runtime/src/lib.rs

@@ -24,30 +24,28 @@ pub use compact::{
     get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
 };
 pub use config::{
-    ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
+    ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
     McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
     McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
     ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
-    RuntimePermissionRuleConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
+    RuntimePluginConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
 };
 pub use conversation::{
-    ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
-    ToolError, ToolExecutor, TurnSummary,
+    auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
+    ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
 };
 pub use file_ops::{
     edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
     GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
     WriteFileOutput,
 };
-pub use hooks::{
-    HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
-};
+pub use hooks::{HookEvent, HookRunResult, HookRunner};
 pub use mcp::{
     mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
     scoped_mcp_config_hash, unwrap_ccr_proxy_url,
 };
 pub use mcp_client::{
-    McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
+    McpClaudeAiProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
     McpRemoteTransport, McpSdkTransport, McpStdioTransport,
 };
 pub use mcp_stdio::{
@@ -66,8 +64,8 @@ pub use oauth::{
     PkceChallengeMethod, PkceCodePair,
 };
 pub use permissions::{
-    PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
-    PermissionPromptDecision, PermissionPrompter, PermissionRequest,
+    PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
+    PermissionPrompter, PermissionRequest,
 };
 pub use prompt::{
     load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,

+ 2 - 2
rust/crates/runtime/src/mcp.rs

@@ -73,7 +73,7 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
             Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
         }
         McpServerConfig::Ws(config) => Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url))),
-        McpServerConfig::ManagedProxy(config) => {
+        McpServerConfig::ClaudeAiProxy(config) => {
             Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
         }
         McpServerConfig::Sdk(_) => None,
@@ -110,7 +110,7 @@ pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
             ws.headers_helper.as_deref().unwrap_or("")
         ),
         McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
-        McpServerConfig::ManagedProxy(proxy) => {
+        McpServerConfig::ClaudeAiProxy(proxy) => {
             format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
         }
     };

+ 4 - 4
rust/crates/runtime/src/mcp_client.rs

@@ -10,7 +10,7 @@ pub enum McpClientTransport {
     Http(McpRemoteTransport),
     WebSocket(McpRemoteTransport),
     Sdk(McpSdkTransport),
-    ManagedProxy(McpManagedProxyTransport),
+    ClaudeAiProxy(McpClaudeAiProxyTransport),
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -34,7 +34,7 @@ pub struct McpSdkTransport {
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct McpManagedProxyTransport {
+pub struct McpClaudeAiProxyTransport {
     pub url: String,
     pub id: String,
 }
@@ -97,8 +97,8 @@ impl McpClientTransport {
             McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
                 name: config.name.clone(),
             }),
-            McpServerConfig::ManagedProxy(config) => {
-                Self::ManagedProxy(McpManagedProxyTransport {
+            McpServerConfig::ClaudeAiProxy(config) => {
+                Self::ClaudeAiProxy(McpClaudeAiProxyTransport {
                     url: config.url.clone(),
                     id: config.id.clone(),
                 })

+ 4 - 4
rust/crates/runtime/src/oauth.rs

@@ -324,12 +324,12 @@ fn generate_random_token(bytes: usize) -> io::Result<String> {
 }
 
 fn credentials_home_dir() -> io::Result<PathBuf> {
-    if let Some(path) = std::env::var_os("CLAW_CONFIG_HOME") {
+    if let Some(path) = std::env::var_os("CLAUDE_CONFIG_HOME") {
         return Ok(PathBuf::from(path));
     }
     let home = std::env::var_os("HOME")
         .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
-    Ok(PathBuf::from(home).join(".claw"))
+    Ok(PathBuf::from(home).join(".claude"))
 }
 
 fn read_credentials_root(path: &PathBuf) -> io::Result<Map<String, Value>> {
@@ -541,7 +541,7 @@ mod tests {
     fn oauth_credentials_round_trip_and_clear_preserves_other_fields() {
         let _guard = env_lock();
         let config_home = temp_config_home();
-        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
         let path = credentials_path().expect("credentials path");
         std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
         std::fs::write(&path, "{\"other\":\"value\"}\n").expect("seed credentials");
@@ -567,7 +567,7 @@ mod tests {
         assert!(cleared.contains("\"other\": \"value\""));
         assert!(!cleared.contains("\"oauth\""));
 
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
     }
 

+ 24 - 467
rust/crates/runtime/src/permissions.rs

@@ -1,9 +1,5 @@
 use std::collections::BTreeMap;
 
-use serde_json::Value;
-
-use crate::config::RuntimePermissionRuleConfig;
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 pub enum PermissionMode {
     ReadOnly,
@@ -26,49 +22,12 @@ impl PermissionMode {
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum PermissionOverride {
-    Allow,
-    Deny,
-    Ask,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Default)]
-pub struct PermissionContext {
-    override_decision: Option<PermissionOverride>,
-    override_reason: Option<String>,
-}
-
-impl PermissionContext {
-    #[must_use]
-    pub fn new(
-        override_decision: Option<PermissionOverride>,
-        override_reason: Option<String>,
-    ) -> Self {
-        Self {
-            override_decision,
-            override_reason,
-        }
-    }
-
-    #[must_use]
-    pub fn override_decision(&self) -> Option<PermissionOverride> {
-        self.override_decision
-    }
-
-    #[must_use]
-    pub fn override_reason(&self) -> Option<&str> {
-        self.override_reason.as_deref()
-    }
-}
-
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct PermissionRequest {
     pub tool_name: String,
     pub input: String,
     pub current_mode: PermissionMode,
     pub required_mode: PermissionMode,
-    pub reason: Option<String>,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -91,9 +50,6 @@ pub enum PermissionOutcome {
 pub struct PermissionPolicy {
     active_mode: PermissionMode,
     tool_requirements: BTreeMap<String, PermissionMode>,
-    allow_rules: Vec<PermissionRule>,
-    deny_rules: Vec<PermissionRule>,
-    ask_rules: Vec<PermissionRule>,
 }
 
 impl PermissionPolicy {
@@ -102,9 +58,6 @@ impl PermissionPolicy {
         Self {
             active_mode,
             tool_requirements: BTreeMap::new(),
-            allow_rules: Vec::new(),
-            deny_rules: Vec::new(),
-            ask_rules: Vec::new(),
         }
     }
 
@@ -119,26 +72,6 @@ impl PermissionPolicy {
         self
     }
 
-    #[must_use]
-    pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
-        self.allow_rules = config
-            .allow()
-            .iter()
-            .map(|rule| PermissionRule::parse(rule))
-            .collect();
-        self.deny_rules = config
-            .deny()
-            .iter()
-            .map(|rule| PermissionRule::parse(rule))
-            .collect();
-        self.ask_rules = config
-            .ask()
-            .iter()
-            .map(|rule| PermissionRule::parse(rule))
-            .collect();
-        self
-    }
-
     #[must_use]
     pub fn active_mode(&self) -> PermissionMode {
         self.active_mode
@@ -157,121 +90,38 @@ impl PermissionPolicy {
         &self,
         tool_name: &str,
         input: &str,
-        prompter: Option<&mut dyn PermissionPrompter>,
-    ) -> PermissionOutcome {
-        self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
-    }
-
-    #[must_use]
-    #[allow(clippy::too_many_lines)]
-    pub fn authorize_with_context(
-        &self,
-        tool_name: &str,
-        input: &str,
-        context: &PermissionContext,
-        prompter: Option<&mut dyn PermissionPrompter>,
+        mut prompter: Option<&mut dyn PermissionPrompter>,
     ) -> PermissionOutcome {
-        if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
-            return PermissionOutcome::Deny {
-                reason: format!(
-                    "Permission to use {tool_name} has been denied by rule '{}'",
-                    rule.raw
-                ),
-            };
-        }
-
         let current_mode = self.active_mode();
         let required_mode = self.required_mode_for(tool_name);
-        let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
-        let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
-
-        match context.override_decision() {
-            Some(PermissionOverride::Deny) => {
-                return PermissionOutcome::Deny {
-                    reason: context.override_reason().map_or_else(
-                        || format!("tool '{tool_name}' denied by hook"),
-                        ToOwned::to_owned,
-                    ),
-                };
-            }
-            Some(PermissionOverride::Ask) => {
-                let reason = context.override_reason().map_or_else(
-                    || format!("tool '{tool_name}' requires approval due to hook guidance"),
-                    ToOwned::to_owned,
-                );
-                return Self::prompt_or_deny(
-                    tool_name,
-                    input,
-                    current_mode,
-                    required_mode,
-                    Some(reason),
-                    prompter,
-                );
-            }
-            Some(PermissionOverride::Allow) => {
-                if let Some(rule) = ask_rule {
-                    let reason = format!(
-                        "tool '{tool_name}' requires approval due to ask rule '{}'",
-                        rule.raw
-                    );
-                    return Self::prompt_or_deny(
-                        tool_name,
-                        input,
-                        current_mode,
-                        required_mode,
-                        Some(reason),
-                        prompter,
-                    );
-                }
-                if allow_rule.is_some()
-                    || current_mode == PermissionMode::Allow
-                    || current_mode >= required_mode
-                {
-                    return PermissionOutcome::Allow;
-                }
-            }
-            None => {}
-        }
-
-        if let Some(rule) = ask_rule {
-            let reason = format!(
-                "tool '{tool_name}' requires approval due to ask rule '{}'",
-                rule.raw
-            );
-            return Self::prompt_or_deny(
-                tool_name,
-                input,
-                current_mode,
-                required_mode,
-                Some(reason),
-                prompter,
-            );
-        }
-
-        if allow_rule.is_some()
-            || current_mode == PermissionMode::Allow
-            || current_mode >= required_mode
-        {
+        if current_mode == PermissionMode::Allow || current_mode >= required_mode {
             return PermissionOutcome::Allow;
         }
 
+        let request = PermissionRequest {
+            tool_name: tool_name.to_string(),
+            input: input.to_string(),
+            current_mode,
+            required_mode,
+        };
+
         if current_mode == PermissionMode::Prompt
             || (current_mode == PermissionMode::WorkspaceWrite
                 && required_mode == PermissionMode::DangerFullAccess)
         {
-            let reason = Some(format!(
-                "tool '{tool_name}' requires approval to escalate from {} to {}",
-                current_mode.as_str(),
-                required_mode.as_str()
-            ));
-            return Self::prompt_or_deny(
-                tool_name,
-                input,
-                current_mode,
-                required_mode,
-                reason,
-                prompter,
-            );
+            return match prompter.as_mut() {
+                Some(prompter) => match prompter.decide(&request) {
+                    PermissionPromptDecision::Allow => PermissionOutcome::Allow,
+                    PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
+                },
+                None => PermissionOutcome::Deny {
+                    reason: format!(
+                        "tool '{tool_name}' requires approval to escalate from {} to {}",
+                        current_mode.as_str(),
+                        required_mode.as_str()
+                    ),
+                },
+            };
         }
 
         PermissionOutcome::Deny {
@@ -282,191 +132,14 @@ impl PermissionPolicy {
             ),
         }
     }
-
-    fn prompt_or_deny(
-        tool_name: &str,
-        input: &str,
-        current_mode: PermissionMode,
-        required_mode: PermissionMode,
-        reason: Option<String>,
-        mut prompter: Option<&mut dyn PermissionPrompter>,
-    ) -> PermissionOutcome {
-        let request = PermissionRequest {
-            tool_name: tool_name.to_string(),
-            input: input.to_string(),
-            current_mode,
-            required_mode,
-            reason: reason.clone(),
-        };
-
-        match prompter.as_mut() {
-            Some(prompter) => match prompter.decide(&request) {
-                PermissionPromptDecision::Allow => PermissionOutcome::Allow,
-                PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
-            },
-            None => PermissionOutcome::Deny {
-                reason: reason.unwrap_or_else(|| {
-                    format!(
-                        "tool '{tool_name}' requires approval to run while mode is {}",
-                        current_mode.as_str()
-                    )
-                }),
-            },
-        }
-    }
-
-    fn find_matching_rule<'a>(
-        rules: &'a [PermissionRule],
-        tool_name: &str,
-        input: &str,
-    ) -> Option<&'a PermissionRule> {
-        rules.iter().find(|rule| rule.matches(tool_name, input))
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct PermissionRule {
-    raw: String,
-    tool_name: String,
-    matcher: PermissionRuleMatcher,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-enum PermissionRuleMatcher {
-    Any,
-    Exact(String),
-    Prefix(String),
-}
-
-impl PermissionRule {
-    fn parse(raw: &str) -> Self {
-        let trimmed = raw.trim();
-        let open = find_first_unescaped(trimmed, '(');
-        let close = find_last_unescaped(trimmed, ')');
-
-        if let (Some(open), Some(close)) = (open, close) {
-            if close == trimmed.len() - 1 && open < close {
-                let tool_name = trimmed[..open].trim();
-                let content = &trimmed[open + 1..close];
-                if !tool_name.is_empty() {
-                    let matcher = parse_rule_matcher(content);
-                    return Self {
-                        raw: trimmed.to_string(),
-                        tool_name: tool_name.to_string(),
-                        matcher,
-                    };
-                }
-            }
-        }
-
-        Self {
-            raw: trimmed.to_string(),
-            tool_name: trimmed.to_string(),
-            matcher: PermissionRuleMatcher::Any,
-        }
-    }
-
-    fn matches(&self, tool_name: &str, input: &str) -> bool {
-        if self.tool_name != tool_name {
-            return false;
-        }
-
-        match &self.matcher {
-            PermissionRuleMatcher::Any => true,
-            PermissionRuleMatcher::Exact(expected) => {
-                extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
-            }
-            PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
-                .is_some_and(|candidate| candidate.starts_with(prefix)),
-        }
-    }
-}
-
-fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
-    let unescaped = unescape_rule_content(content.trim());
-    if unescaped.is_empty() || unescaped == "*" {
-        PermissionRuleMatcher::Any
-    } else if let Some(prefix) = unescaped.strip_suffix(":*") {
-        PermissionRuleMatcher::Prefix(prefix.to_string())
-    } else {
-        PermissionRuleMatcher::Exact(unescaped)
-    }
-}
-
-fn unescape_rule_content(content: &str) -> String {
-    content
-        .replace(r"\(", "(")
-        .replace(r"\)", ")")
-        .replace(r"\\", r"\")
-}
-
-fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
-    let mut escaped = false;
-    for (idx, ch) in value.char_indices() {
-        if ch == '\\' {
-            escaped = !escaped;
-            continue;
-        }
-        if ch == needle && !escaped {
-            return Some(idx);
-        }
-        escaped = false;
-    }
-    None
-}
-
-fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
-    let chars = value.char_indices().collect::<Vec<_>>();
-    for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
-        if *ch != needle {
-            continue;
-        }
-        let mut backslashes = 0;
-        for (_, prev) in chars[..pos].iter().rev() {
-            if *prev == '\\' {
-                backslashes += 1;
-            } else {
-                break;
-            }
-        }
-        if backslashes % 2 == 0 {
-            return Some(*idx);
-        }
-    }
-    None
-}
-
-fn extract_permission_subject(input: &str) -> Option<String> {
-    let parsed = serde_json::from_str::<Value>(input).ok();
-    if let Some(Value::Object(object)) = parsed {
-        for key in [
-            "command",
-            "path",
-            "file_path",
-            "filePath",
-            "notebook_path",
-            "notebookPath",
-            "url",
-            "pattern",
-            "code",
-            "message",
-        ] {
-            if let Some(value) = object.get(key).and_then(Value::as_str) {
-                return Some(value.to_string());
-            }
-        }
-    }
-
-    (!input.trim().is_empty()).then(|| input.to_string())
 }
 
 #[cfg(test)]
 mod tests {
     use super::{
-        PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
-        PermissionPromptDecision, PermissionPrompter, PermissionRequest,
+        PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
+        PermissionPrompter, PermissionRequest,
     };
-    use crate::config::RuntimePermissionRuleConfig;
 
     struct RecordingPrompter {
         seen: Vec<PermissionRequest>,
@@ -556,120 +229,4 @@ mod tests {
             PermissionOutcome::Deny { reason } if reason == "not now"
         ));
     }
-
-    #[test]
-    fn applies_rule_based_denials_and_allows() {
-        let rules = RuntimePermissionRuleConfig::new(
-            vec!["bash(git:*)".to_string()],
-            vec!["bash(rm -rf:*)".to_string()],
-            Vec::new(),
-        );
-        let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
-            .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
-            .with_permission_rules(&rules);
-
-        assert_eq!(
-            policy.authorize("bash", r#"{"command":"git status"}"#, None),
-            PermissionOutcome::Allow
-        );
-        assert!(matches!(
-            policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
-            PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
-        ));
-    }
-
-    #[test]
-    fn ask_rules_force_prompt_even_when_mode_allows() {
-        let rules = RuntimePermissionRuleConfig::new(
-            Vec::new(),
-            Vec::new(),
-            vec!["bash(git:*)".to_string()],
-        );
-        let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
-            .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
-            .with_permission_rules(&rules);
-        let mut prompter = RecordingPrompter {
-            seen: Vec::new(),
-            allow: true,
-        };
-
-        let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
-
-        assert_eq!(outcome, PermissionOutcome::Allow);
-        assert_eq!(prompter.seen.len(), 1);
-        assert!(prompter.seen[0]
-            .reason
-            .as_deref()
-            .is_some_and(|reason| reason.contains("ask rule")));
-    }
-
-    #[test]
-    fn hook_allow_still_respects_ask_rules() {
-        let rules = RuntimePermissionRuleConfig::new(
-            Vec::new(),
-            Vec::new(),
-            vec!["bash(git:*)".to_string()],
-        );
-        let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
-            .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
-            .with_permission_rules(&rules);
-        let context = PermissionContext::new(
-            Some(PermissionOverride::Allow),
-            Some("hook approved".to_string()),
-        );
-        let mut prompter = RecordingPrompter {
-            seen: Vec::new(),
-            allow: true,
-        };
-
-        let outcome = policy.authorize_with_context(
-            "bash",
-            r#"{"command":"git status"}"#,
-            &context,
-            Some(&mut prompter),
-        );
-
-        assert_eq!(outcome, PermissionOutcome::Allow);
-        assert_eq!(prompter.seen.len(), 1);
-    }
-
-    #[test]
-    fn hook_deny_short_circuits_permission_flow() {
-        let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
-            .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
-        let context = PermissionContext::new(
-            Some(PermissionOverride::Deny),
-            Some("blocked by hook".to_string()),
-        );
-
-        assert_eq!(
-            policy.authorize_with_context("bash", "{}", &context, None),
-            PermissionOutcome::Deny {
-                reason: "blocked by hook".to_string(),
-            }
-        );
-    }
-
-    #[test]
-    fn hook_ask_forces_prompt() {
-        let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
-            .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
-        let context = PermissionContext::new(
-            Some(PermissionOverride::Ask),
-            Some("hook requested confirmation".to_string()),
-        );
-        let mut prompter = RecordingPrompter {
-            seen: Vec::new(),
-            allow: true,
-        };
-
-        let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
-
-        assert_eq!(outcome, PermissionOutcome::Allow);
-        assert_eq!(prompter.seen.len(), 1);
-        assert_eq!(
-            prompter.seen[0].reason.as_deref(),
-            Some("hook requested confirmation")
-        );
-    }
 }

+ 38 - 41
rust/crates/runtime/src/prompt.rs

@@ -201,10 +201,10 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
     let mut files = Vec::new();
     for dir in directories {
         for candidate in [
-            dir.join("INSTRUCTIONS.md"),
-            dir.join("INSTRUCTIONS.local.md"),
-            dir.join(".claw").join("INSTRUCTIONS.md"),
-            dir.join(".claw").join("instructions.md"),
+            dir.join("CLAUDE.md"),
+            dir.join("CLAUDE.local.md"),
+            dir.join(".claude").join("CLAUDE.md"),
+            dir.join(".claude").join("instructions.md"),
         ] {
             push_context_file(&mut files, candidate)?;
         }
@@ -301,7 +301,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
 }
 
 fn render_instruction_files(files: &[ContextFile]) -> String {
-    let mut sections = vec!["# Project instructions".to_string()];
+    let mut sections = vec!["# Claude instructions".to_string()];
     let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
     for file in files {
         if remaining_chars == 0 {
@@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
     let mut lines = vec!["# Runtime config".to_string()];
     if config.loaded_entries().is_empty() {
         lines.extend(prepend_bullets(vec![
-            "No Claude Code settings files loaded.".to_string(),
+            "No Claw Code settings files loaded.".to_string()
         ]));
         return lines.join("\n");
     }
@@ -517,23 +517,23 @@ mod tests {
     fn discovers_instruction_files_from_ancestor_chain() {
         let root = temp_dir();
         let nested = root.join("apps").join("api");
-        fs::create_dir_all(nested.join(".claw")).expect("nested claude dir");
-        fs::write(root.join("INSTRUCTIONS.md"), "root instructions").expect("write root instructions");
-        fs::write(root.join("INSTRUCTIONS.local.md"), "local instructions")
+        fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
+        fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
+        fs::write(root.join("CLAUDE.local.md"), "local instructions")
             .expect("write local instructions");
         fs::create_dir_all(root.join("apps")).expect("apps dir");
-        fs::create_dir_all(root.join("apps").join(".claw")).expect("apps claude dir");
-        fs::write(root.join("apps").join("INSTRUCTIONS.md"), "apps instructions")
+        fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
+        fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
             .expect("write apps instructions");
         fs::write(
-            root.join("apps").join(".claw").join("instructions.md"),
+            root.join("apps").join(".claude").join("instructions.md"),
             "apps dot claude instructions",
         )
         .expect("write apps dot claude instructions");
-        fs::write(nested.join(".claw").join("INSTRUCTIONS.md"), "nested rules")
+        fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
             .expect("write nested rules");
         fs::write(
-            nested.join(".claw").join("instructions.md"),
+            nested.join(".claude").join("instructions.md"),
             "nested instructions",
         )
         .expect("write nested instructions");
@@ -552,7 +552,7 @@ mod tests {
                 "local instructions",
                 "apps instructions",
                 "apps dot claude instructions",
-                
+                "nested rules",
                 "nested instructions"
             ]
         );
@@ -564,8 +564,8 @@ mod tests {
         let root = temp_dir();
         let nested = root.join("apps").join("api");
         fs::create_dir_all(&nested).expect("nested dir");
-        fs::write(root.join("INSTRUCTIONS.md"), "same rules\n\n").expect("write root");
-        fs::write(nested.join("INSTRUCTIONS.md"), "same rules\n").expect("write nested");
+        fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
+        fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
 
         let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
         assert_eq!(context.instruction_files.len(), 1);
@@ -593,8 +593,8 @@ mod tests {
     #[test]
     fn displays_context_paths_compactly() {
         assert_eq!(
-            display_context_path(Path::new("/tmp/project/.claw/INSTRUCTIONS.md")),
-            "INSTRUCTIONS.md"
+            display_context_path(Path::new("/tmp/project/.claude/CLAUDE.md")),
+            "CLAUDE.md"
         );
     }
 
@@ -607,7 +607,7 @@ mod tests {
             .current_dir(&root)
             .status()
             .expect("git init should run");
-        fs::write(root.join("INSTRUCTIONS.md"), "rules").expect("write instructions");
+        fs::write(root.join("CLAUDE.md"), "rules").expect("write instructions");
         fs::write(root.join("tracked.txt"), "hello").expect("write tracked file");
 
         let context =
@@ -615,7 +615,7 @@ mod tests {
 
         let status = context.git_status.expect("git status should be present");
         assert!(status.contains("## No commits yet on") || status.contains("## "));
-        assert!(status.contains("?? INSTRUCTIONS.md"));
+        assert!(status.contains("?? CLAUDE.md"));
         assert!(status.contains("?? tracked.txt"));
         assert!(context.git_diff.is_none());
 
@@ -667,10 +667,10 @@ mod tests {
     #[test]
     fn load_system_prompt_reads_claude_files_and_config() {
         let root = temp_dir();
-        fs::create_dir_all(root.join(".claw")).expect("claude dir");
-        fs::write(root.join("INSTRUCTIONS.md"), "Project rules").expect("write instructions");
+        fs::create_dir_all(root.join(".claude")).expect("claude dir");
+        fs::write(root.join("CLAUDE.md"), "Project rules").expect("write instructions");
         fs::write(
-            root.join(".claw").join("settings.json"),
+            root.join(".claude").join("settings.json"),
             r#"{"permissionMode":"acceptEdits"}"#,
         )
         .expect("write settings");
@@ -678,9 +678,9 @@ mod tests {
         let _guard = env_lock();
         let previous = std::env::current_dir().expect("cwd");
         let original_home = std::env::var("HOME").ok();
-        let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
+        let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
         std::env::set_var("HOME", &root);
-        std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
+        std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
         std::env::set_current_dir(&root).expect("change cwd");
         let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
             .expect("system prompt should load")
@@ -695,10 +695,10 @@ mod tests {
         } else {
             std::env::remove_var("HOME");
         }
-        if let Some(value) = original_config_home {
-            std::env::set_var("CLAW_CONFIG_HOME", value);
+        if let Some(value) = original_claude_home {
+            std::env::set_var("CLAUDE_CONFIG_HOME", value);
         } else {
-            std::env::remove_var("CLAW_CONFIG_HOME");
+            std::env::remove_var("CLAUDE_CONFIG_HOME");
         }
 
         assert!(prompt.contains("Project rules"));
@@ -709,10 +709,10 @@ mod tests {
     #[test]
     fn renders_claude_code_style_sections_with_project_context() {
         let root = temp_dir();
-        fs::create_dir_all(root.join(".claw")).expect("claude dir");
-        fs::write(root.join("INSTRUCTIONS.md"), "Project rules").expect("write INSTRUCTIONS.md");
+        fs::create_dir_all(root.join(".claude")).expect("claude dir");
+        fs::write(root.join("CLAUDE.md"), "Project rules").expect("write CLAUDE.md");
         fs::write(
-            root.join(".claw").join("settings.json"),
+            root.join(".claude").join("settings.json"),
             r#"{"permissionMode":"acceptEdits"}"#,
         )
         .expect("write settings");
@@ -731,7 +731,7 @@ mod tests {
 
         assert!(prompt.contains("# System"));
         assert!(prompt.contains("# Project context"));
-        assert!(prompt.contains("# Project instructions"));
+        assert!(prompt.contains("# Claude instructions"));
         assert!(prompt.contains("Project rules"));
         assert!(prompt.contains("permissionMode"));
         assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -751,9 +751,9 @@ mod tests {
     fn discovers_dot_claude_instructions_markdown() {
         let root = temp_dir();
         let nested = root.join("apps").join("api");
-        fs::create_dir_all(nested.join(".claw")).expect("nested claude dir");
+        fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
         fs::write(
-            nested.join(".claw").join("instructions.md"),
+            nested.join(".claude").join("instructions.md"),
             "instruction markdown",
         )
         .expect("write instructions.md");
@@ -762,10 +762,7 @@ mod tests {
         assert!(context
             .instruction_files
             .iter()
-            .any(|file| {
-                let p = file.path.to_string_lossy().to_lowercase();
-                p.ends_with(".claw/instructions.md")
-            }));
+            .any(|file| file.path.ends_with(".claude/instructions.md")));
         assert!(
             render_instruction_files(&context.instruction_files).contains("instruction markdown")
         );
@@ -776,10 +773,10 @@ mod tests {
     #[test]
     fn renders_instruction_file_metadata() {
         let rendered = render_instruction_files(&[ContextFile {
-            path: PathBuf::from("/tmp/project/INSTRUCTIONS.md"),
+            path: PathBuf::from("/tmp/project/CLAUDE.md"),
             content: "Project rules".to_string(),
         }]);
-        assert!(rendered.contains("# Project instructions"));
+        assert!(rendered.contains("# Claude instructions"));
         assert!(rendered.contains("scope: /tmp/project"));
         assert!(rendered.contains("Project rules"));
     }

+ 2 - 1
rust/crates/rusty-claude-cli/Cargo.toml

@@ -17,9 +17,10 @@ crossterm = "0.28"
 pulldown-cmark = "0.13"
 rustyline = "15"
 runtime = { path = "../runtime" }
+plugins = { path = "../plugins" }
 serde_json = "1"
 syntect = "5"
-tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
+tokio = { version = "1", features = ["rt-multi-thread", "time"] }
 tools = { path = "../tools" }
 
 [lints]

+ 5 - 5
rust/crates/rusty-claude-cli/src/args.rs

@@ -4,7 +4,7 @@ use clap::{Parser, Subcommand, ValueEnum};
 
 #[derive(Debug, Clone, Parser, PartialEq, Eq)]
 #[command(
-    name = "claw-cli",
+    name = "rusty-claude-cli",
     version,
     about = "Rust Claude CLI prototype"
 )]
@@ -62,7 +62,7 @@ mod tests {
     #[test]
     fn parses_requested_flags() {
         let cli = Cli::parse_from([
-            "claw-cli",
+            "rusty-claude-cli",
             "--model",
             "claude-3-5-haiku",
             "--permission-mode",
@@ -93,16 +93,16 @@ mod tests {
 
     #[test]
     fn parses_login_and_logout_commands() {
-        let login = Cli::parse_from(["claw-cli", "login"]);
+        let login = Cli::parse_from(["rusty-claude-cli", "login"]);
         assert_eq!(login.command, Some(Command::Login));
 
-        let logout = Cli::parse_from(["claw-cli", "logout"]);
+        let logout = Cli::parse_from(["rusty-claude-cli", "logout"]);
         assert_eq!(logout.command, Some(Command::Logout));
     }
 
     #[test]
     fn defaults_to_danger_full_access_permission_mode() {
-        let cli = Cli::parse_from(["claw-cli"]);
+        let cli = Cli::parse_from(["rusty-claude-cli"]);
         assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
     }
 }

+ 42 - 45
rust/crates/rusty-claude-cli/src/init.rs

@@ -1,7 +1,7 @@
 use std::fs;
 use std::path::{Path, PathBuf};
 
-const STARTER_CLAW_JSON: &str = concat!(
+const STARTER_CLAUDE_JSON: &str = concat!(
     "{\n",
     "  \"permissions\": {\n",
     "    \"defaultMode\": \"dontAsk\"\n",
@@ -9,7 +9,7 @@ const STARTER_CLAW_JSON: &str = concat!(
     "}\n",
 );
 const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
-const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
+const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub(crate) enum InitStatus {
@@ -80,16 +80,16 @@ struct RepoDetection {
 pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
     let mut artifacts = Vec::new();
 
-    let claw_dir = cwd.join(".claw");
+    let claude_dir = cwd.join(".claude");
     artifacts.push(InitArtifact {
-        name: ".claw/",
-        status: ensure_dir(&claw_dir)?,
+        name: ".claude/",
+        status: ensure_dir(&claude_dir)?,
     });
 
-    let claw_json = cwd.join(".claw.json");
+    let claude_json = cwd.join(".claude.json");
     artifacts.push(InitArtifact {
-        name: ".claw.json",
-        status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?,
+        name: ".claude.json",
+        status: write_file_if_missing(&claude_json, STARTER_CLAUDE_JSON)?,
     });
 
     let gitignore = cwd.join(".gitignore");
@@ -98,11 +98,11 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
         status: ensure_gitignore_entries(&gitignore)?,
     });
 
-    let instructions_md = cwd.join("INSTRUCTIONS.md");
-    let content = render_init_instructions_md(cwd);
+    let claude_md = cwd.join("CLAUDE.md");
+    let content = render_init_claude_md(cwd);
     artifacts.push(InitArtifact {
-        name: "INSTRUCTIONS.md",
-        status: write_file_if_missing(&instructions_md, &content)?,
+        name: "CLAUDE.md",
+        status: write_file_if_missing(&claude_md, &content)?,
     });
 
     Ok(InitReport {
@@ -159,13 +159,12 @@ fn ensure_gitignore_entries(path: &Path) -> Result<InitStatus, std::io::Error> {
     Ok(InitStatus::Updated)
 }
 
-pub(crate) fn render_init_instructions_md(cwd: &Path) -> String {
+pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
     let detection = detect_repo(cwd);
     let mut lines = vec![
-        "# INSTRUCTIONS.md".to_string(),
+        "# CLAUDE.md".to_string(),
         String::new(),
-        "This file provides guidance to Claw Code when working with code in this repository."
-            .to_string(),
+        "This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
         String::new(),
     ];
 
@@ -210,8 +209,8 @@ pub(crate) fn render_init_instructions_md(cwd: &Path) -> String {
 
     lines.push("## Working agreement".to_string());
     lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
-    lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string());
-    lines.push("- Do not overwrite existing `INSTRUCTIONS.md` content automatically; update it intentionally when repo workflows change.".to_string());
+    lines.push("- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.".to_string());
+    lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
     lines.push(String::new());
 
     lines.join("\n")
@@ -334,7 +333,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
 
 #[cfg(test)]
 mod tests {
-    use super::{initialize_repo, render_init_instructions_md};
+    use super::{initialize_repo, render_init_claude_md};
     use std::fs;
     use std::path::Path;
     use std::time::{SystemTime, UNIX_EPOCH};
@@ -344,7 +343,7 @@ mod tests {
             .duration_since(UNIX_EPOCH)
             .expect("time should be after epoch")
             .as_nanos();
-        std::env::temp_dir().join(format!("rusty-claw-init-{nanos}"))
+        std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
     }
 
     #[test]
@@ -355,15 +354,15 @@ mod tests {
 
         let report = initialize_repo(&root).expect("init should succeed");
         let rendered = report.render();
-        assert!(rendered.contains(".claw/           created"));
-        assert!(rendered.contains(".claw.json       created"));
+        assert!(rendered.contains(".claude/         created"));
+        assert!(rendered.contains(".claude.json     created"));
         assert!(rendered.contains(".gitignore       created"));
-        assert!(rendered.contains("INSTRUCTIONS.md  created"));
-        assert!(root.join(".claw").is_dir());
-        assert!(root.join(".claw.json").is_file());
-        assert!(root.join("INSTRUCTIONS.md").is_file());
+        assert!(rendered.contains("CLAUDE.md        created"));
+        assert!(root.join(".claude").is_dir());
+        assert!(root.join(".claude.json").is_file());
+        assert!(root.join("CLAUDE.md").is_file());
         assert_eq!(
-            fs::read_to_string(root.join(".claw.json")).expect("read claw json"),
+            fs::read_to_string(root.join(".claude.json")).expect("read claude json"),
             concat!(
                 "{\n",
                 "  \"permissions\": {\n",
@@ -373,12 +372,11 @@ mod tests {
             )
         );
         let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
-        assert!(gitignore.contains(".claw/settings.local.json"));
-        assert!(gitignore.contains(".claw/sessions/"));
-        let instructions_md =
-            fs::read_to_string(root.join("INSTRUCTIONS.md")).expect("read instructions md");
-        assert!(instructions_md.contains("Languages: Rust."));
-        assert!(instructions_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
+        assert!(gitignore.contains(".claude/settings.local.json"));
+        assert!(gitignore.contains(".claude/sessions/"));
+        let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
+        assert!(claude_md.contains("Languages: Rust."));
+        assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
 
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }
@@ -387,28 +385,27 @@ mod tests {
     fn initialize_repo_is_idempotent_and_preserves_existing_files() {
         let root = temp_dir();
         fs::create_dir_all(&root).expect("create root");
-        fs::write(root.join("INSTRUCTIONS.md"), "custom guidance\n")
-            .expect("write existing instructions md");
-        fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
+        fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
+        fs::write(root.join(".gitignore"), ".claude/settings.local.json\n")
+            .expect("write gitignore");
 
         let first = initialize_repo(&root).expect("first init should succeed");
         assert!(first
             .render()
-            .contains("INSTRUCTIONS.md  skipped (already exists)"));
+            .contains("CLAUDE.md        skipped (already exists)"));
         let second = initialize_repo(&root).expect("second init should succeed");
         let second_rendered = second.render();
-        assert!(second_rendered.contains(".claw/           skipped (already exists)"));
-        assert!(second_rendered.contains(".claw.json       skipped (already exists)"));
+        assert!(second_rendered.contains(".claude/         skipped (already exists)"));
+        assert!(second_rendered.contains(".claude.json     skipped (already exists)"));
         assert!(second_rendered.contains(".gitignore       skipped (already exists)"));
-        assert!(second_rendered.contains("INSTRUCTIONS.md  skipped (already exists)"));
+        assert!(second_rendered.contains("CLAUDE.md        skipped (already exists)"));
         assert_eq!(
-            fs::read_to_string(root.join("INSTRUCTIONS.md"))
-                .expect("read existing instructions md"),
+            fs::read_to_string(root.join("CLAUDE.md")).expect("read existing claude md"),
             "custom guidance\n"
         );
         let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
-        assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
-        assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
+        assert_eq!(gitignore.matches(".claude/settings.local.json").count(), 1);
+        assert_eq!(gitignore.matches(".claude/sessions/").count(), 1);
 
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }
@@ -425,7 +422,7 @@ mod tests {
         )
         .expect("write package json");
 
-        let rendered = render_init_instructions_md(Path::new(&root));
+        let rendered = render_init_claude_md(Path::new(&root));
         assert!(rendered.contains("Languages: Python, TypeScript."));
         assert!(rendered.contains("Frameworks/tooling markers: Next.js, React."));
         assert!(rendered.contains("pyproject.toml"));

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 901 - 159
rust/crates/rusty-claude-cli/src/main.rs


+ 3 - 2
rust/crates/rusty-claude-cli/src/render.rs

@@ -286,7 +286,7 @@ impl TerminalRenderer {
     ) {
         match event {
             Event::Start(Tag::Heading { level, .. }) => {
-                Self::start_heading(state, level as u8, output);
+                self.start_heading(state, level as u8, output);
             }
             Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
             Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
@@ -426,7 +426,8 @@ impl TerminalRenderer {
         }
     }
 
-    fn start_heading(state: &mut RenderState, level: u8, output: &mut String) {
+    #[allow(clippy::unused_self)]
+    fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
         state.heading_level = Some(level);
         if !output.is_empty() {
             output.push('\n');

+ 1 - 0
rust/crates/tools/Cargo.toml

@@ -7,6 +7,7 @@ publish.workspace = true
 
 [dependencies]
 api = { path = "../api" }
+plugins = { path = "../plugins" }
 runtime = { path = "../runtime" }
 reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
 serde = { version = "1", features = ["derive"] }

+ 560 - 58
rust/crates/tools/src/lib.rs

@@ -4,16 +4,17 @@ use std::process::Command;
 use std::time::{Duration, Instant};
 
 use api::{
-    read_base_url, ApiClient as ApiHttpClient, ContentBlockDelta, InputContentBlock, InputMessage,
+    read_base_url, AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage,
     MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
     ToolDefinition, ToolResultContentBlock,
 };
+use plugins::{PluginManager, PluginManagerConfig, PluginTool};
 use reqwest::blocking::Client;
 use runtime::{
     edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
-    ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
-    ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
-    RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
+    ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
+    ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
+    PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
 };
 use serde::{Deserialize, Serialize};
 use serde_json::{json, Value};
@@ -55,6 +56,239 @@ pub struct ToolSpec {
     pub required_permission: PermissionMode,
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub struct RegisteredTool {
+    pub definition: ToolDefinition,
+    pub required_permission: PermissionMode,
+    handler: RegisteredToolHandler,
+}
+
+#[allow(clippy::large_enum_variant)]
+#[derive(Debug, Clone, PartialEq)]
+enum RegisteredToolHandler {
+    Builtin,
+    Plugin(PluginTool),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct GlobalToolRegistry {
+    entries: Vec<RegisteredTool>,
+}
+
+impl GlobalToolRegistry {
+    #[must_use]
+    pub fn builtin() -> Self {
+        Self {
+            entries: mvp_tool_specs()
+                .into_iter()
+                .map(|spec| RegisteredTool {
+                    definition: ToolDefinition {
+                        name: spec.name.to_string(),
+                        description: Some(spec.description.to_string()),
+                        input_schema: spec.input_schema,
+                    },
+                    required_permission: spec.required_permission,
+                    handler: RegisteredToolHandler::Builtin,
+                })
+                .collect(),
+        }
+    }
+
+    pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
+        let mut registry = Self::builtin();
+        let mut seen = registry
+            .entries
+            .iter()
+            .map(|entry| {
+                (
+                    normalize_registry_tool_name(&entry.definition.name),
+                    entry.definition.name.clone(),
+                )
+            })
+            .collect::<BTreeMap<_, _>>();
+
+        for tool in plugin_tools {
+            let normalized = normalize_registry_tool_name(&tool.definition().name);
+            if let Some(existing) = seen.get(&normalized) {
+                return Err(format!(
+                    "plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`",
+                    tool.definition().name,
+                    tool.plugin_id()
+                ));
+            }
+            seen.insert(normalized, tool.definition().name.clone());
+            registry.entries.push(RegisteredTool {
+                definition: ToolDefinition {
+                    name: tool.definition().name.clone(),
+                    description: tool.definition().description.clone(),
+                    input_schema: tool.definition().input_schema.clone(),
+                },
+                required_permission: permission_mode_from_plugin_tool(tool.required_permission())?,
+                handler: RegisteredToolHandler::Plugin(tool),
+            });
+        }
+
+        Ok(registry)
+    }
+
+    #[must_use]
+    pub fn entries(&self) -> &[RegisteredTool] {
+        &self.entries
+    }
+
+    fn find_entry(&self, name: &str) -> Option<&RegisteredTool> {
+        let normalized = normalize_registry_tool_name(name);
+        self.entries.iter().find(|entry| {
+            normalize_registry_tool_name(entry.definition.name.as_str()) == normalized
+        })
+    }
+
+    #[must_use]
+    pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
+        self.entries
+            .iter()
+            .filter(|entry| {
+                allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
+            })
+            .map(|entry| entry.definition.clone())
+            .collect()
+    }
+
+    #[must_use]
+    pub fn permission_specs(
+        &self,
+        allowed_tools: Option<&BTreeSet<String>>,
+    ) -> Vec<(String, PermissionMode)> {
+        self.entries
+            .iter()
+            .filter(|entry| {
+                allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
+            })
+            .map(|entry| (entry.definition.name.clone(), entry.required_permission))
+            .collect()
+    }
+
+    pub fn normalize_allowed_tools(
+        &self,
+        values: &[String],
+    ) -> Result<Option<BTreeSet<String>>, String> {
+        if values.is_empty() {
+            return Ok(None);
+        }
+
+        let canonical_names = self
+            .entries
+            .iter()
+            .map(|entry| entry.definition.name.clone())
+            .collect::<Vec<_>>();
+        let mut name_map = canonical_names
+            .iter()
+            .map(|name| (normalize_registry_tool_name(name), name.clone()))
+            .collect::<BTreeMap<_, _>>();
+
+        for (alias, canonical) in [
+            ("read", "read_file"),
+            ("write", "write_file"),
+            ("edit", "edit_file"),
+            ("glob", "glob_search"),
+            ("grep", "grep_search"),
+        ] {
+            if canonical_names.iter().any(|name| name == canonical) {
+                name_map.insert(alias.to_string(), canonical.to_string());
+            }
+        }
+
+        let mut allowed = BTreeSet::new();
+        for value in values {
+            for token in value
+                .split(|ch: char| ch == ',' || ch.is_whitespace())
+                .filter(|token| !token.is_empty())
+            {
+                let normalized = normalize_registry_tool_name(token);
+                let canonical = name_map.get(&normalized).ok_or_else(|| {
+                    format!(
+                        "unsupported tool in --allowedTools: {token} (expected one of: {})",
+                        canonical_names.join(", ")
+                    )
+                })?;
+                allowed.insert(canonical.clone());
+            }
+        }
+
+        Ok(Some(allowed))
+    }
+
+    pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
+        let entry = self
+            .find_entry(name)
+            .ok_or_else(|| format!("unsupported tool: {name}"))?;
+        match &entry.handler {
+            RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input),
+            RegisteredToolHandler::Plugin(tool) => {
+                tool.execute(input).map_err(|error| error.to_string())
+            }
+        }
+    }
+}
+
+impl Default for GlobalToolRegistry {
+    fn default() -> Self {
+        Self::builtin()
+    }
+}
+
+fn normalize_registry_tool_name(value: &str) -> String {
+    let trimmed = value.trim();
+    let chars = trimmed.chars().collect::<Vec<_>>();
+    let mut normalized = String::new();
+
+    for (index, ch) in chars.iter().copied().enumerate() {
+        if matches!(ch, '-' | ' ' | '\t' | '\n') {
+            if !normalized.ends_with('_') {
+                normalized.push('_');
+            }
+            continue;
+        }
+
+        if ch == '_' {
+            if !normalized.ends_with('_') {
+                normalized.push('_');
+            }
+            continue;
+        }
+
+        if ch.is_uppercase() {
+            let prev = chars.get(index.wrapping_sub(1)).copied();
+            let next = chars.get(index + 1).copied();
+            let needs_separator = index > 0
+                && !normalized.ends_with('_')
+                && (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit())
+                    || (prev.is_some_and(char::is_uppercase)
+                        && next.is_some_and(char::is_lowercase)));
+            if needs_separator {
+                normalized.push('_');
+            }
+            normalized.extend(ch.to_lowercase());
+            continue;
+        }
+
+        normalized.push(ch.to_ascii_lowercase());
+    }
+
+    normalized.trim_matches('_').to_string()
+}
+
+fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> {
+    match value {
+        "read-only" => Ok(PermissionMode::ReadOnly),
+        "workspace-write" => Ok(PermissionMode::WorkspaceWrite),
+        "danger-full-access" => Ok(PermissionMode::DangerFullAccess),
+        other => Err(format!(
+            "unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)"
+        )),
+    }
+}
+
 #[must_use]
 #[allow(clippy::too_many_lines)]
 pub fn mvp_tool_specs() -> Vec<ToolSpec> {
@@ -323,7 +557,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
         },
         ToolSpec {
             name: "Config",
-            description: "Get or set Claude Code settings.",
+            description: "Get or set Claw Code settings.",
             input_schema: json!({
                 "type": "object",
                 "properties": {
@@ -1308,12 +1542,6 @@ fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
     if let Ok(codex_home) = std::env::var("CODEX_HOME") {
         candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
     }
-    if let Ok(home) = std::env::var("HOME") {
-        let home = std::path::PathBuf::from(home);
-        candidates.push(home.join(".agents").join("skills"));
-        candidates.push(home.join(".config").join("opencode").join("skills"));
-        candidates.push(home.join(".codex").join("skills"));
-    }
     candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
 
     for root in candidates {
@@ -1465,20 +1693,22 @@ fn run_agent_job(job: &AgentJob) -> Result<(), String> {
 
 fn build_agent_runtime(
     job: &AgentJob,
-) -> Result<ConversationRuntime<RuntimeApiClient, SubagentToolExecutor>, String> {
+) -> Result<ConversationRuntime<AnthropicRuntimeClient, SubagentToolExecutor>, String> {
     let model = job
         .manifest
         .model
         .clone()
         .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
     let allowed_tools = job.allowed_tools.clone();
-    let api_client = RuntimeApiClient::new(model, allowed_tools.clone())?;
-    let tool_executor = SubagentToolExecutor::new(allowed_tools);
+    let tool_registry = current_tool_registry()?;
+    let api_client =
+        AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?;
+    let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone());
     Ok(ConversationRuntime::new(
         Session::new(),
         api_client,
         tool_executor,
-        agent_permission_policy(),
+        agent_permission_policy(&tool_registry),
         job.system_prompt.clone(),
     ))
 }
@@ -1543,7 +1773,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
             "SendUserMessage",
             "PowerShell",
         ],
-        "claw-guide" => vec![
+        "claw-code-guide" => vec![
             "read_file",
             "glob_search",
             "grep_search",
@@ -1587,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
     tools.into_iter().map(str::to_string).collect()
 }
 
-fn agent_permission_policy() -> PermissionPolicy {
-    mvp_tool_specs().into_iter().fold(
+fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
+    tool_registry.permission_specs(None).into_iter().fold(
         PermissionPolicy::new(PermissionMode::DangerFullAccess),
-        |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
+        |policy, (name, required_permission)| {
+            policy.with_tool_requirement(name, required_permission)
+        },
     )
 }
 
@@ -1641,16 +1873,21 @@ fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Optio
     sections.join("")
 }
 
-struct RuntimeApiClient {
+struct AnthropicRuntimeClient {
     runtime: tokio::runtime::Runtime,
-    client: ApiHttpClient,
+    client: AnthropicClient,
     model: String,
     allowed_tools: BTreeSet<String>,
+    tool_registry: GlobalToolRegistry,
 }
 
-impl RuntimeApiClient {
-    fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
-        let client = ApiHttpClient::from_env()
+impl AnthropicRuntimeClient {
+    fn new(
+        model: String,
+        allowed_tools: BTreeSet<String>,
+        tool_registry: GlobalToolRegistry,
+    ) -> Result<Self, String> {
+        let client = AnthropicClient::from_env()
             .map_err(|error| error.to_string())?
             .with_base_url(read_base_url());
         Ok(Self {
@@ -1658,20 +1895,14 @@ impl RuntimeApiClient {
             client,
             model,
             allowed_tools,
+            tool_registry,
         })
     }
 }
 
-impl ApiClient for RuntimeApiClient {
+impl ApiClient for AnthropicRuntimeClient {
     fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
-        let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
-            .into_iter()
-            .map(|spec| ToolDefinition {
-                name: spec.name.to_string(),
-                description: Some(spec.description.to_string()),
-                input_schema: spec.input_schema,
-            })
-            .collect::<Vec<_>>();
+        let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
         let message_request = MessageRequest {
             model: self.model.clone(),
             max_tokens: 32_000,
@@ -1722,6 +1953,8 @@ impl ApiClient for RuntimeApiClient {
                                 input.push_str(&partial_json);
                             }
                         }
+                        ContentBlockDelta::ThinkingDelta { .. }
+                        | ContentBlockDelta::SignatureDelta { .. } => {}
                     },
                     ApiStreamEvent::ContentBlockStop(_) => {
                         if let Some((id, name, input)) = pending_tool.take() {
@@ -1774,32 +2007,82 @@ impl ApiClient for RuntimeApiClient {
 
 struct SubagentToolExecutor {
     allowed_tools: BTreeSet<String>,
+    tool_registry: GlobalToolRegistry,
 }
 
 impl SubagentToolExecutor {
-    fn new(allowed_tools: BTreeSet<String>) -> Self {
-        Self { allowed_tools }
+    fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
+        Self {
+            allowed_tools,
+            tool_registry,
+        }
     }
 }
 
 impl ToolExecutor for SubagentToolExecutor {
     fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
-        if !self.allowed_tools.contains(tool_name) {
+        let entry = self
+            .tool_registry
+            .find_entry(tool_name)
+            .ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?;
+        if !self.allowed_tools.contains(entry.definition.name.as_str()) {
             return Err(ToolError::new(format!(
                 "tool `{tool_name}` is not enabled for this sub-agent"
             )));
         }
         let value = serde_json::from_str(input)
             .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
-        execute_tool(tool_name, &value).map_err(ToolError::new)
+        self.tool_registry
+            .execute(tool_name, &value)
+            .map_err(ToolError::new)
     }
 }
 
-fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
-    mvp_tool_specs()
-        .into_iter()
-        .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
-        .collect()
+fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
+    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
+    let loader = ConfigLoader::default_for(&cwd);
+    let runtime_config = loader.load().map_err(|error| error.to_string())?;
+    let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
+    let plugin_tools = plugin_manager
+        .aggregated_tools()
+        .map_err(|error| error.to_string())?;
+    GlobalToolRegistry::with_plugin_tools(plugin_tools)
+}
+
+fn build_plugin_manager(
+    cwd: &Path,
+    loader: &ConfigLoader,
+    runtime_config: &RuntimeConfig,
+) -> PluginManager {
+    let plugin_settings = runtime_config.plugins();
+    let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
+    plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
+    plugin_config.external_dirs = plugin_settings
+        .external_directories()
+        .iter()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
+        .collect();
+    plugin_config.install_root = plugin_settings
+        .install_root()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    plugin_config.registry_path = plugin_settings
+        .registry_path()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    plugin_config.bundled_root = plugin_settings
+        .bundled_root()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    PluginManager::new(plugin_config)
+}
+
+fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
+    let path = PathBuf::from(value);
+    if path.is_absolute() {
+        path
+    } else if value.starts_with('.') {
+        cwd.join(path)
+    } else {
+        config_home.join(path)
+    }
 }
 
 fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -1866,6 +2149,7 @@ fn push_output_block(
             };
             *pending_tool = Some((id, name, initial_input));
         }
+        OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
     }
 }
 
@@ -2093,7 +2377,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
         "verification" | "verificationagent" | "verify" | "verifier" => {
             String::from("Verification")
         }
-        "clawguide" | "clawguideagent" | "guide" => String::from("claw-guide"),
+        "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"),
         "statusline" | "statuslinesetup" => String::from("statusline-setup"),
         _ => trimmed.to_string(),
     }
@@ -2593,16 +2877,16 @@ fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
     let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
     Ok(match scope {
         ConfigScope::Global => config_home_dir()?.join("settings.json"),
-        ConfigScope::Settings => cwd.join(".claw").join("settings.local.json"),
+        ConfigScope::Settings => cwd.join(".claude").join("settings.local.json"),
     })
 }
 
 fn config_home_dir() -> Result<PathBuf, String> {
-    if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") {
+    if let Ok(path) = std::env::var("CLAUDE_CONFIG_HOME") {
         return Ok(PathBuf::from(path));
     }
     let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
-    Ok(PathBuf::from(home).join(".claw"))
+    Ok(PathBuf::from(home).join(".claude"))
 }
 
 fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
@@ -2911,9 +3195,13 @@ mod tests {
     use super::{
         agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
         execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
-        AgentInput, AgentJob, SubagentToolExecutor,
+        response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
+    };
+    use api::{MessageResponse, OutputContentBlock, Usage};
+    use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
+    use runtime::{
+        ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
     };
-    use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
     use serde_json::json;
 
     fn env_lock() -> &'static Mutex<()> {
@@ -2929,6 +3217,17 @@ mod tests {
         std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
     }
 
+    fn make_executable(path: &PathBuf) {
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+
+            let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
+            permissions.set_mode(0o755);
+            std::fs::set_permissions(path, permissions).expect("chmod");
+        }
+    }
+
     #[test]
     fn exposes_mvp_tools() {
         let names = mvp_tool_specs()
@@ -2958,6 +3257,170 @@ mod tests {
         assert!(error.contains("unsupported tool"));
     }
 
+    #[test]
+    fn global_registry_registers_and_executes_plugin_tools() {
+        let script = temp_path("plugin-tool.sh");
+        std::fs::write(
+            &script,
+            "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
+        )
+        .expect("write script");
+        make_executable(&script);
+
+        let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
+            "demo@external",
+            "demo",
+            PluginToolDefinition {
+                name: "plugin_echo".to_string(),
+                description: Some("Echo plugin input".to_string()),
+                input_schema: json!({
+                    "type": "object",
+                    "properties": { "message": { "type": "string" } },
+                    "required": ["message"],
+                    "additionalProperties": false
+                }),
+            },
+            "sh".to_string(),
+            vec![script.display().to_string()],
+            PluginToolPermission::WorkspaceWrite,
+            script.parent().map(PathBuf::from),
+        )])
+        .expect("registry should build");
+
+        let names = registry
+            .definitions(None)
+            .into_iter()
+            .map(|definition| definition.name)
+            .collect::<Vec<_>>();
+        assert!(names.contains(&"bash".to_string()));
+        assert!(names.contains(&"plugin_echo".to_string()));
+
+        let output = registry
+            .execute("plugin_echo", &json!({ "message": "hello" }))
+            .expect("plugin tool should execute");
+        let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
+        assert_eq!(payload["plugin"], "demo@external");
+        assert_eq!(payload["tool"], "plugin_echo");
+        assert_eq!(payload["input"]["message"], "hello");
+
+        let _ = std::fs::remove_file(script);
+    }
+
+    #[test]
+    fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() {
+        let script = temp_path("plugin-tool-normalized.sh");
+        std::fs::write(
+            &script,
+            "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
+        )
+        .expect("write script");
+        make_executable(&script);
+
+        let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
+            "demo@external",
+            "demo",
+            PluginToolDefinition {
+                name: "plugin_echo".to_string(),
+                description: Some("Echo plugin input".to_string()),
+                input_schema: json!({
+                    "type": "object",
+                    "properties": { "message": { "type": "string" } },
+                    "required": ["message"],
+                    "additionalProperties": false
+                }),
+            },
+            script.display().to_string(),
+            Vec::new(),
+            PluginToolPermission::WorkspaceWrite,
+            script.parent().map(PathBuf::from),
+        )])
+        .expect("registry should build");
+
+        let allowed = registry
+            .normalize_allowed_tools(&[String::from("PLUGIN-ECHO")])
+            .expect("plugin tool allowlist should normalize")
+            .expect("allowlist should be present");
+        assert!(allowed.contains("plugin_echo"));
+
+        let output = registry
+            .execute("plugin-echo", &json!({ "message": "hello" }))
+            .expect("normalized plugin tool name should execute");
+        let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
+        assert_eq!(payload["tool"], "plugin_echo");
+        assert_eq!(payload["input"]["message"], "hello");
+
+        let builtin_output = GlobalToolRegistry::builtin()
+            .execute("structured-output", &json!({ "ok": true }))
+            .expect("normalized builtin tool name should execute");
+        let builtin_payload: serde_json::Value =
+            serde_json::from_str(&builtin_output).expect("valid json");
+        assert_eq!(builtin_payload["structured_output"]["ok"], true);
+
+        let _ = std::fs::remove_file(script);
+    }
+
+    #[test]
+    fn subagent_executor_executes_allowed_plugin_tools() {
+        let script = temp_path("subagent-plugin-tool.sh");
+        std::fs::write(
+            &script,
+            "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
+        )
+        .expect("write script");
+        make_executable(&script);
+
+        let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
+            "demo@external",
+            "demo",
+            PluginToolDefinition {
+                name: "plugin_echo".to_string(),
+                description: Some("Echo plugin input".to_string()),
+                input_schema: json!({
+                    "type": "object",
+                    "properties": { "message": { "type": "string" } },
+                    "required": ["message"],
+                    "additionalProperties": false
+                }),
+            },
+            script.display().to_string(),
+            Vec::new(),
+            PluginToolPermission::WorkspaceWrite,
+            script.parent().map(PathBuf::from),
+        )])
+        .expect("registry should build");
+
+        let mut executor =
+            SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry);
+        let output = executor
+            .execute("plugin-echo", r#"{"message":"hello"}"#)
+            .expect("plugin tool should execute for subagent");
+        let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
+        assert_eq!(payload["tool"], "plugin_echo");
+        assert_eq!(payload["input"]["message"], "hello");
+
+        let _ = std::fs::remove_file(script);
+    }
+
+    #[test]
+    fn global_registry_rejects_conflicting_plugin_tool_names() {
+        let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
+            "demo@external",
+            "demo",
+            PluginToolDefinition {
+                name: "read-file".to_string(),
+                description: Some("Conflicts with builtin".to_string()),
+                input_schema: json!({ "type": "object" }),
+            },
+            "echo".to_string(),
+            Vec::new(),
+            PluginToolPermission::ReadOnly,
+            None,
+        )])
+        .expect_err("conflicting plugin tool should fail");
+
+        assert!(error.contains("conflicts with already-registered tool `read_file`"));
+    }
+
     #[test]
     fn web_fetch_returns_prompt_aware_summary() {
         let server = TestServer::spawn(Arc::new(|request_line: &str| {
@@ -3537,8 +4000,11 @@ mod tests {
                 calls: 0,
                 input_path: path.display().to_string(),
             },
-            SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
-            agent_permission_policy(),
+            SubagentToolExecutor::new(
+                BTreeSet::from([String::from("read_file")]),
+                GlobalToolRegistry::builtin(),
+            ),
+            agent_permission_policy(&GlobalToolRegistry::builtin()),
             vec![String::from("system prompt")],
         );
 
@@ -3564,6 +4030,42 @@ mod tests {
         let _ = std::fs::remove_file(path);
     }
 
+    #[test]
+    fn response_to_events_ignores_thinking_blocks() {
+        let events = response_to_events(MessageResponse {
+            id: "msg-1".to_string(),
+            kind: "message".to_string(),
+            model: "claude-opus-4-6".to_string(),
+            role: "assistant".to_string(),
+            content: vec![
+                OutputContentBlock::Thinking {
+                    thinking: "step 1".to_string(),
+                    signature: Some("sig_123".to_string()),
+                },
+                OutputContentBlock::Text {
+                    text: "Final answer".to_string(),
+                },
+            ],
+            stop_reason: Some("end_turn".to_string()),
+            stop_sequence: None,
+            usage: Usage {
+                input_tokens: 1,
+                output_tokens: 1,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            },
+            request_id: None,
+        });
+
+        assert!(matches!(
+            &events[0],
+            AssistantEvent::TextDelta(text) if text == "Final answer"
+        ));
+        assert!(!events
+            .iter()
+            .any(|event| matches!(event, AssistantEvent::ToolUse { .. })));
+    }
+
     #[test]
     fn agent_rejects_blank_required_fields() {
         let missing_description = execute_tool(
@@ -3988,19 +4490,19 @@ mod tests {
         ));
         let home = root.join("home");
         let cwd = root.join("cwd");
-        std::fs::create_dir_all(home.join(".claw")).expect("home dir");
-        std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
+        std::fs::create_dir_all(home.join(".claude")).expect("home dir");
+        std::fs::create_dir_all(cwd.join(".claude")).expect("cwd dir");
         std::fs::write(
-            home.join(".claw").join("settings.json"),
+            home.join(".claude").join("settings.json"),
             r#"{"verbose":false}"#,
         )
         .expect("write global settings");
 
         let original_home = std::env::var("HOME").ok();
-        let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
+        let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
         let original_dir = std::env::current_dir().expect("cwd");
         std::env::set_var("HOME", &home);
-        std::env::remove_var("CLAW_CONFIG_HOME");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         std::env::set_current_dir(&cwd).expect("set cwd");
 
         let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
@@ -4033,9 +4535,9 @@ mod tests {
             Some(value) => std::env::set_var("HOME", value),
             None => std::env::remove_var("HOME"),
         }
-        match original_config_home {
-            Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
-            None => std::env::remove_var("CLAW_CONFIG_HOME"),
+        match original_claude_home {
+            Some(value) => std::env::set_var("CLAUDE_CONFIG_HOME", value),
+            None => std::env::remove_var("CLAUDE_CONFIG_HOME"),
         }
         let _ = std::fs::remove_dir_all(root);
     }

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно