From 9c9d1566a32b7c1923fb1436435ad95999495fd1 Mon Sep 17 00:00:00 2001 From: Robert Pelloni Date: Mon, 6 Jul 2026 16:53:40 -0400 Subject: [PATCH 1/3] feat: add runtime extension API crate with lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces codewhale-extension — a generic runtime extension system for CodeWhale that mirrors the Pi Coding Agent's ExtensionAPI pattern. Extension trait with 12 lifecycle hooks: - SessionStart, BeforeAgentStart, ToolCall, ToolResult - TurnEnd, Input, UserBash, ModelSelect - SessionBeforeCompact, SessionCompact, SessionShutdown Extensions can also register: - MCP servers via mcp_servers() - Slash commands via slash_commands() - Keyboard shortcuts via shortcuts() - Custom tools via tools() Includes ExtensionManager for registration + dispatch, and generic McpServerDef, SlashCommandDef, ShortcutDef, ToolDef types. Also fixes Ctrl+Up/Down keybinding to scroll by 1 line. --- Cargo.lock | 14 ++ Cargo.toml | 5 + crates/codewhale-extension/Cargo.toml | 20 ++ crates/codewhale-extension/src/extension.rs | 209 ++++++++++++++++++++ crates/codewhale-extension/src/lib.rs | 52 +++++ crates/codewhale-extension/src/prelude.rs | 5 + crates/tui/src/tui/ui.rs | 6 + 7 files changed, 311 insertions(+) create mode 100644 crates/codewhale-extension/Cargo.toml create mode 100644 crates/codewhale-extension/src/extension.rs create mode 100644 crates/codewhale-extension/src/lib.rs create mode 100644 crates/codewhale-extension/src/prelude.rs diff --git a/Cargo.lock b/Cargo.lock index 6ffc4a475a..ebb2fe2c81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,6 +901,20 @@ dependencies = [ "serde", ] +[[package]] +name = "codewhale-extension" +version = "0.8.66" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "tempfile", + "tokio", + "uuid", +] + [[package]] name = "codewhale-hooks" version = "0.8.66" diff --git a/Cargo.toml b/Cargo.toml index a58012c260..d000f595d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ members = [ "crates/tools", "crates/tui", "crates/whaleflow", + "crates/codewhale-extension", + ] default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" @@ -55,3 +57,6 @@ tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } uuid = { version = "1.11", features = ["v4"] } + + + diff --git a/crates/codewhale-extension/Cargo.toml b/crates/codewhale-extension/Cargo.toml new file mode 100644 index 0000000000..9731974fbc --- /dev/null +++ b/crates/codewhale-extension/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codewhale-extension" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Runtime extension API for CodeWhale — lifecycle hooks, tool registration, MCP server auto-config, slash commands, keyboard shortcuts" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +anyhow.workspace = true +tokio.workspace = true +async-trait.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/codewhale-extension/src/extension.rs b/crates/codewhale-extension/src/extension.rs new file mode 100644 index 0000000000..ec325ed39f --- /dev/null +++ b/crates/codewhale-extension/src/extension.rs @@ -0,0 +1,209 @@ +//! Extension API for agent lifecycle hooks. +//! +//! Mirrors the Pi Coding Agent's `ExtensionAPI` pattern: +//! extensions register callbacks for lifecycle events (session start, +//! tool call, turn end, etc.) and the runtime dispatches to them. + +use async_trait::async_trait; +use serde_json::Value; + +/// Data available at each hook point. +#[derive(Debug, Clone)] +pub enum HookEvent { + /// A new session has started. + SessionStart { + session_id: String, + reason: String, + }, + /// Before the agent sends a prompt to the LLM. + BeforeAgentStart { + system_prompt: String, + prompt: String, + is_first_turn: bool, + }, + /// A tool is about to be called. + ToolCall { + tool_name: String, + args: Value, + turn_id: String, + }, + /// A tool has returned a result. + ToolResult { + tool_name: String, + args: Value, + result: Value, + is_error: bool, + turn_id: String, + }, + /// The current turn has ended. + TurnEnd { + turn_id: String, + message_count: usize, + tool_count: usize, + }, + /// User input is being processed (for @memory:key expansion). + Input { + text: String, + }, + /// User ran a shell command. + UserBash { + command: String, + }, + /// Model was selected. + ModelSelect { + model: String, + provider: String, + }, + /// Before session compaction. + SessionBeforeCompact { + session_id: String, + entry_count: usize, + }, + /// After session compaction. + SessionCompact { + session_id: String, + summary: String, + }, + /// Session is shutting down. + SessionShutdown { + session_id: String, + }, +} + +/// Result returned from a hook, possibly modifying the event. +#[derive(Debug, Clone, Default)] +pub struct HookResult { + /// Modified system prompt (for BeforeAgentStart). + pub system_prompt: Option, + /// Modified user prompt (for Input / BeforeAgentStart). + pub prompt: Option, + /// Whether to suppress the default behavior. + pub suppress: bool, +} + +/// A single extension that hooks into the agent lifecycle. +#[async_trait] +pub trait Extension: Send + Sync { + /// Name of this extension (e.g. "example"). + fn name(&self) -> &str; + + /// Called for each lifecycle event. + async fn on_event(&self, event: &HookEvent) -> HookResult { + let _ = event; + HookResult::default() + } + + /// Register MCP servers this extension provides. + fn mcp_servers(&self) -> Vec<(String, McpServerDef)> { + vec![] + } + + /// Register slash commands this extension provides. + fn slash_commands(&self) -> Vec { + vec![] + } + + /// Register keyboard shortcuts this extension provides. + fn shortcuts(&self) -> Vec { + vec![] + } + + /// Custom tool definitions this extension provides. + fn tools(&self) -> Vec { + vec![] + } +} + +#[derive(Debug, Clone)] +pub struct McpServerDef { + pub command: String, + pub args: Vec, + pub env: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +pub struct SlashCommandDef { + pub name: String, + pub description: String, + pub handler: String, // e.g. "mcp_example_my_tool" +} + +#[derive(Debug, Clone)] +pub struct ShortcutDef { + pub keys: String, // e.g. "ctrl+shift+m" + pub description: String, + pub action: String, // e.g. "tn-memory-store" +} + +#[derive(Debug, Clone)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub parameters: Option, +} + +/// Manages a collection of extensions and dispatches events to all of them. +#[derive(Default)] +pub struct ExtensionManager { + extensions: Vec>, +} + +impl ExtensionManager { + pub fn new() -> Self { + Self { extensions: Vec::new() } + } + + pub fn register(&mut self, ext: Box) { + self.extensions.push(ext); + } + + pub fn extensions(&self) -> &[Box] { + &self.extensions + } + + /// Dispatch an event to all registered extensions and aggregate results. + pub async fn dispatch(&self, event: &HookEvent) -> Vec { + let mut results = Vec::new(); + for ext in &self.extensions { + results.push(ext.on_event(event).await); + } + results + } + + /// Collect all MCP server definitions from all extensions. + pub fn collect_mcp_servers(&self) -> Vec<(String, McpServerDef)> { + let mut servers = Vec::new(); + for ext in &self.extensions { + servers.extend(ext.mcp_servers()); + } + servers + } + + /// Collect all slash commands from all extensions. + pub fn collect_slash_commands(&self) -> Vec { + let mut cmds = Vec::new(); + for ext in &self.extensions { + cmds.extend(ext.slash_commands()); + } + cmds + } + + /// Collect all shortcuts from all extensions. + pub fn collect_shortcuts(&self) -> Vec { + let mut shortcuts = Vec::new(); + for ext in &self.extensions { + shortcuts.extend(ext.shortcuts()); + } + shortcuts + } + + /// Collect all tools from all extensions. + pub fn collect_tools(&self) -> Vec { + let mut tools = Vec::new(); + for ext in &self.extensions { + tools.extend(ext.tools()); + } + tools + } +} + diff --git a/crates/codewhale-extension/src/lib.rs b/crates/codewhale-extension/src/lib.rs new file mode 100644 index 0000000000..d7a2668313 --- /dev/null +++ b/crates/codewhale-extension/src/lib.rs @@ -0,0 +1,52 @@ +//! # CodeWhale Runtime Extension API +//! +//! A generic runtime extension system for CodeWhale that mirrors the +//! Pi Coding Agent's `ExtensionAPI` pattern. Extensions can register +//! lifecycle hooks (session start, tool call, turn end, etc.), define +//! MCP servers, slash commands, keyboard shortcuts, and custom tools. +//! +//! ## Architecture +//! +//! - [`Extension`] trait — implement this to create an extension +//! - [`ExtensionManager`] — registers extensions and dispatches events +//! - [`HookEvent`] — all lifecycle events extensions can hook into +//! - [`HookResult`] — result from a hook that may modify the runtime +//! +//! ## Usage +//! +//! ```ignore +//! use codewhale_extension::prelude::*; +//! +//! struct MyExtension; +//! #[async_trait] +//! impl Extension for MyExtension { +//! fn name(&self) -> &str { "my-ext" } +//! +//! async fn on_event(&self, event: &HookEvent) -> HookResult { +//! match event { +//! HookEvent::SessionStart { session_id, .. } => { +//! println!("Session started: {session_id}"); +//! } +//! _ => {} +//! } +//! HookResult::default() +//! } +//! +//! fn mcp_servers(&self) -> Vec<(String, McpServerDef)> { +//! vec![("my-server".into(), McpServerDef { +//! command: "my-mcp-server".into(), +//! args: vec![], +//! env: vec![], +//! })] +//! } +//! } +//! ``` + +pub mod extension; +pub mod prelude; + +// Re-export key types at crate root +pub use extension::{ + Extension, ExtensionManager, HookEvent, HookResult, + McpServerDef, ShortcutDef, SlashCommandDef, ToolDef, +}; diff --git a/crates/codewhale-extension/src/prelude.rs b/crates/codewhale-extension/src/prelude.rs new file mode 100644 index 0000000000..2c41b6ed5a --- /dev/null +++ b/crates/codewhale-extension/src/prelude.rs @@ -0,0 +1,5 @@ +pub use crate::extension::{ + Extension, ExtensionManager, HookEvent, HookResult, + McpServerDef, ShortcutDef, SlashCommandDef, ToolDef, +}; +pub use async_trait::async_trait; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2d01154143..b1d0270093 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4601,6 +4601,9 @@ async fn run_event_loop( KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { app.scroll_up(3); } + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.scroll_up(1); + } KeyCode::Up if key.modifiers.is_empty() && mention_menu_open @@ -4655,6 +4658,9 @@ async fn run_event_loop( KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => { app.scroll_down(3); } + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.scroll_down(1); + } KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => { app.mention_menu_selected = (app.mention_menu_selected + 1) .min(mention_menu_entries.len().saturating_sub(1)); From 6174d11bd6164fda55596fc8cde4761c3d8781be Mon Sep 17 00:00:00 2001 From: Robert Pelloni Date: Tue, 7 Jul 2026 02:31:27 -0400 Subject: [PATCH 2/3] feat: add TormentNexus extension crate with full Pi extension parity (hooks, tools, commands, shortcuts, L2 memory, RBAC) --- Cargo.lock | 14 + Cargo.toml | 2 + crates/tn-extension/Cargo.toml | 16 + crates/tn-extension/src/lib.rs | 332 +++++++++++++++++++ crates/tui/Cargo.toml | 3 + crates/tui/src/core/engine.rs | 17 + crates/tui/src/core/engine/tool_execution.rs | 4 +- 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 crates/tn-extension/Cargo.toml create mode 100644 crates/tn-extension/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ebb2fe2c81..85919e3981 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "codewhale-tn-extension" +version = "0.8.66" +dependencies = [ + "async-trait", + "chrono", + "codewhale-extension", + "reqwest 0.13.4", + "serde", + "serde_json", +] + [[package]] name = "codewhale-tools" version = "0.8.66" @@ -1013,9 +1025,11 @@ dependencies = [ "clap_complete", "codewhale-config", "codewhale-execpolicy", + "codewhale-extension", "codewhale-protocol", "codewhale-release", "codewhale-secrets", + "codewhale-tn-extension", "codewhale-tools", "colored", "crossterm 0.28.1", diff --git a/Cargo.toml b/Cargo.toml index d000f595d9..1014671792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/tui", "crates/whaleflow", "crates/codewhale-extension", + "crates/tn-extension", ] default-members = ["crates/cli", "crates/app-server", "crates/tui"] @@ -60,3 +61,4 @@ uuid = { version = "1.11", features = ["v4"] } + diff --git a/crates/tn-extension/Cargo.toml b/crates/tn-extension/Cargo.toml new file mode 100644 index 0000000000..5d24481f0b --- /dev/null +++ b/crates/tn-extension/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codewhale-tn-extension" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "TormentNexus extension adapter for CodeWhale — hooks into the extension API" + +[dependencies] +codewhale-extension = { path = "../codewhale-extension" } +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +async-trait.workspace = true +reqwest.workspace = true diff --git a/crates/tn-extension/src/lib.rs b/crates/tn-extension/src/lib.rs new file mode 100644 index 0000000000..24ba07df40 --- /dev/null +++ b/crates/tn-extension/src/lib.rs @@ -0,0 +1,332 @@ +//! TormentNexus extension adapter for CodeWhale — full Pi extension parity. +//! +//! Implements the [`codewhale_extension::Extension`] trait, calling +//! TormentNexus's local API (port 7778) at each lifecycle hook point. +//! Mirrors all Pi extension v4 functionality: +//! - L2 memory integration (store, search, vector search) +//! - MCP tool discovery via TN sidecar +//! - Session / skill / code search +//! - Context harvesting and scratchpad +//! - RBAC / audit logging +//! - @memory:key expansion +//! - Session compaction preservation +//! - Slash commands, shortcuts, custom tool registration + +use async_trait::async_trait; +use codewhale_extension::{ + Extension, ExtensionManager, HookEvent, HookResult, McpServerDef, ShortcutDef, + SlashCommandDef, ToolDef, +}; +use serde_json::{json, Value}; + +const TN_BASE: &str = "http://127.0.0.1:7778"; + +/// Dangerous operation patterns checked against enterprise RBAC. +const DANGEROUS_PATTERNS: &[&str] = &[ + "rm -rf", "sudo ", "chmod -R 777", + "DROP TABLE", "DROP DATABASE", "git push --force", +]; + +/// Tools whose results are auto-stored to L2 memory. +const STORE_TOOLS: &[&str] = &[ + "bash", "read", "grep", "tn_code_search", "tn_tool_search", "exec_shell", +]; + +/// TormentNexus extension adapter — full Pi extension v4 parity. +pub struct TormentNexusExtension { + client: reqwest::Client, +} + +impl TormentNexusExtension { + pub fn new() -> Self { + Self { client: reqwest::Client::new() } + } + + pub fn register(manager: &mut ExtensionManager) { + manager.register(Box::new(Self::new())); + } + + async fn tn_post(&self, path: &str, body: Value) { + let _ = self.client + .post(&format!("{TN_BASE}{path}")) + .json(&body) + .timeout(std::time::Duration::from_secs(3)) + .send().await; + } + + async fn tn_get(&self, path: &str) -> Option { + self.client + .get(&format!("{TN_BASE}{path}")) + .timeout(std::time::Duration::from_secs(3)) + .send().await.ok()?.json().await.ok() + } +} + +#[async_trait] +impl Extension for TormentNexusExtension { + fn name(&self) -> &str { "tormentnexus" } + + async fn on_event(&self, event: &HookEvent) -> HookResult { + match event { + HookEvent::SessionStart { session_id, reason } => { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Session {reason}: {session_id}"), + "tags": ["system:session", format!("reason:{reason}")], + "category": "session", + "timestamp": chrono::Utc::now().to_rfc3339(), + })).unwrap_or_default(), + })).await; + HookResult::default() + } + + HookEvent::BeforeAgentStart { system_prompt, prompt, .. } => { + let mut result = HookResult::default(); + + let guidance = format!( + "{}\n\n## TormentNexus Integration\n\n\ + TN is a local AI control plane on port 7778 with L2 vector memory, \ + tool discovery, sessions, and skill registry.\n\n\ + ### Memory\n- `mcp_tormentnexus_memory_scratchpad_get/set/append` — L1 working memory\n\ + - REST: `POST /api/memory/add` — store to L2\n\ + - REST: `GET /api/memory/search?q=...` — search L2\n\n\ + ### Discovery\n- `mcp_tormentnexus_mcp_list_tools` / `mcp_call_tool` — MCP routing\n\ + - REST: `GET /api/mcp/native/search?query=...` — semantic tool search\n\ + - REST: `GET /api/skills/search?q=...` — skill registry\n\ + - REST: `GET /api/memory/search?q=...&type=session` — session search\n\n\ + ### Slash Commands\n- `/tn-store` — store memory | `/tn-search` — search\n\ + - `/tn-status` — system health | `/tn-plan` — plan management\n\ + - `/tn-summary` — summarize | `/tn-purge` — remove stale\n\n\ + ### Best Practices\n1. Check scratchpad before significant work\n\ + 2. Store patterns after key moments\n3. Search L2 before complex tasks\n\ + 4. Use `@memory:key` inline for auto-expansion", + system_prompt + ); + result.system_prompt = Some(guidance); + + // Per-turn L2 context harvesting + if !prompt.is_empty() { + let q = if prompt.len() > 100 { &prompt[..100] } else { prompt.as_str() }; + if let Some(resp) = self.tn_get(&format!("/api/memory/search?q={}&limit=3", urlencode(q))).await { + if let Some(memories) = resp.get("data").and_then(|d| d.as_array()) { + let ctx: Vec = memories.iter().filter_map(|m| { + let c = m.get("content").or_else(|| m.get("text")) + .and_then(|v| v.as_str()).unwrap_or(""); + if c.is_empty() { return None; } + let s = if c.len() > 200 { &c[..200] } else { c }; + Some(format!(" • {}", s)) + }).collect(); + if !ctx.is_empty() { + result.system_prompt = Some(format!( + "{}\n\n## Relevant Context from TN L2\n{}", + result.system_prompt.as_deref().unwrap_or(""), + ctx.join("\n") + )); + } + } + } + } + result + } + + HookEvent::ToolCall { tool_name, args, .. } => { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Tool call: {tool_name}"), + "tags": ["system:tool_call", format!("tool:{tool_name}")], + "data": args, "category": "tool", + "timestamp": chrono::Utc::now().to_rfc3339(), + })).unwrap_or_default(), + })).await; + + // RBAC: check dangerous patterns + let args_str = serde_json::to_string(args).unwrap_or_default().to_lowercase(); + for pattern in DANGEROUS_PATTERNS { + if args_str.contains(pattern) { + self.tn_post("/api/enterprise/authorize", json!({ + "tool": tool_name, "action": pattern, "args": args, + "timestamp": chrono::Utc::now().to_rfc3339(), + })).await; + self.tn_post("/api/enterprise/audit/log", json!({ + "tool": tool_name, "action": pattern, "args": args, + "timestamp": chrono::Utc::now().to_rfc3339(), + "userId": "codewhale-agent", + })).await; + } + } + HookResult::default() + } + + HookEvent::ToolResult { tool_name, result, is_error, .. } => { + if !is_error && STORE_TOOLS.contains(&tool_name.as_str()) { + let rt = serde_json::to_string(result).unwrap_or_default(); + if rt.len() >= 100 && rt.len() < 2000 { + let s = &rt[..rt.len().min(300)]; + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("[{}] {}", tool_name, s), + "tags": ["system:tool_result", format!("tool:{}", tool_name)], + "category": "tool_result", + })).unwrap_or_default(), + })).await; + } + } + HookResult::default() + } + + HookEvent::TurnEnd { turn_id, tool_count, .. } => { + if *tool_count > 0 { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Turn {turn_id}: {tool_count} tools"), + "tags": ["system:turn_end"], + "category": "turn", + "timestamp": chrono::Utc::now().to_rfc3339(), + })).unwrap_or_default(), + })).await; + } + HookResult::default() + } + + HookEvent::Input { text } => { + if text.contains("@memory:") { + if let Some(key) = text.split("@memory:").nth(1) + .and_then(|s| s.split_whitespace().next()) + { + if !key.is_empty() { + if let Some(resp) = self.tn_get(&format!("/api/memory/search?q={}", urlencode(key))).await { + if let Some(memories) = resp.get("data").and_then(|d| d.as_array()) { + if let Some(first) = memories.first() { + let val = first.get("text").or_else(|| first.get("content")) + .and_then(|v| v.as_str()).unwrap_or(""); + let expanded = text.replace(&format!("@memory:{key}"), val); + if expanded != *text { + return HookResult { prompt: Some(expanded), ..Default::default() }; + } + } + } + } + } + } + } + HookResult::default() + } + + HookEvent::UserBash { command } => { + self.tn_post("/api/enterprise/audit/log", json!({ + "tool": "user_bash", "command": command, + "timestamp": chrono::Utc::now().to_rfc3339(), + })).await; + HookResult::default() + } + + HookEvent::ModelSelect { model, provider } => { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Model: {model} ({provider})"), + "tags": ["system:model_select", format!("model:{model}")], + "category": "model", + })).unwrap_or_default(), + })).await; + HookResult::default() + } + + HookEvent::SessionBeforeCompact { session_id, entry_count } => { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Compacting: {session_id} ({entry_count} entries)"), + "tags": ["system:compaction", format!("session:{session_id}")], + "category": "system", + "timestamp": chrono::Utc::now().to_rfc3339(), + })).unwrap_or_default(), + })).await; + HookResult::default() + } + + HookEvent::SessionCompact { session_id, summary } => { + self.tn_post("/api/memory/add", json!({ + "content": serde_json::to_string(&json!({ + "content": format!("Compacted: {session_id}\n{summary}"), + "tags": ["system:compacted", format!("session:{session_id}")], + "category": "system", + "timestamp": chrono::Utc::now().to_rfc3339(), + })).unwrap_or_default(), + })).await; + HookResult::default() + } + + HookEvent::SessionShutdown { .. } => HookResult::default(), + } + } + + fn mcp_servers(&self) -> Vec<(String, McpServerDef)> { + vec![("tormentnexus".into(), McpServerDef { + command: r#"C:\Users\hyper\workspace\tormentnexus\tormentnexus.exe"#.into(), + args: vec!["mcp".into()], + env: vec![("TORMENTNEXUS_WORKSPACE_ROOT".into(), r#"C:\Users\hyper\workspace\tormentnexus"#.into())], + })] + } + + fn tools(&self) -> Vec { + vec![ + ToolDef { name: "tn_memory_store".into(), + description: "Store a memory in TormentNexus L2 vault. Params: content (required), tags (optional), category (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"content":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"category":{"type":"string"}},"required":["content"]})) }, + ToolDef { name: "tn_memory_search".into(), + description: "Search L2 vault by keyword. Params: query (required), tag (optional), category (optional), limit (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"tag":{"type":"string"},"category":{"type":"string"},"limit":{"type":"integer"}},"required":["query"]})) }, + ToolDef { name: "tn_memory_vector_search".into(), + description: "Semantic vector search L2. Params: query (required), limit (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer"}},"required":["query"]})) }, + ToolDef { name: "tn_tool_search".into(), + description: "Semantic MCP tool search. Params: query (required), limit (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer"}},"required":["query"]})) }, + ToolDef { name: "tn_session_search".into(), + description: "Search imported sessions. Params: query (required), limit (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer"}},"required":["query"]})) }, + ToolDef { name: "tn_skill_manage".into(), + description: "Access skill registry. Params: query (required), action (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"action":{"type":"string"}},"required":["query"]})) }, + ToolDef { name: "tn_code_search".into(), + description: "Multi-engine code search. Params: query (required), scope (optional), path (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"scope":{"type":"string"},"path":{"type":"string"}},"required":["query"]})) }, + ToolDef { name: "tn_context_harvest".into(), + description: "Harvest L2 context + skills. Params: query (required), harvest_memory (optional), harvest_skills (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"query":{"type":"string"},"harvest_memory":{"type":"boolean"},"harvest_skills":{"type":"boolean"}},"required":["query"]})) }, + ToolDef { name: "tn_scratchpad".into(), + description: "Read/write L1 scratchpad. Params: action (required: get/set), content (optional)".into(), + parameters: Some(json!({"type":"object","properties":{"action":{"type":"string"},"content":{"type":"string"}},"required":["action"]})) }, + ] + } + + fn slash_commands(&self) -> Vec { + vec![ + SlashCommandDef { name: "tn-store".into(), description: "Store a memory in TN L2 vault".into(), handler: "tn:memory-store".into() }, + SlashCommandDef { name: "tn-search".into(), description: "Search TN L2 memory".into(), handler: "tn:memory-search".into() }, + SlashCommandDef { name: "tn-status".into(), description: "Show TN system status".into(), handler: "tn:system-status".into() }, + SlashCommandDef { name: "tn-plan".into(), description: "Manage project plans in L2".into(), handler: "tn:plan-manage".into() }, + SlashCommandDef { name: "tn-summary".into(), description: "Summarize session using TN".into(), handler: "tn:session-summary".into() }, + SlashCommandDef { name: "tn-purge".into(), description: "Remove stale memories".into(), handler: "tn:memory-purge".into() }, + ] + } + + fn shortcuts(&self) -> Vec { + vec![ + ShortcutDef { keys: "ctrl+shift+m".into(), description: "TN memory search".into(), action: "tn:memory-search".into() }, + ShortcutDef { keys: "ctrl+shift+t".into(), description: "TN tool search".into(), action: "tn:tool-search".into() }, + ShortcutDef { keys: "ctrl+shift+p".into(), description: "TN system status".into(), action: "tn:system-status".into() }, + ] + } +} + +fn urlencode(s: &str) -> String { + let mut result = String::new(); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => result.push(byte as char), + b' ' => result.push_str("%20"), + _ => result.push_str(&format!("%{:02X}", byte)), + } + } + result +} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f6514be418..024f4cf135 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,6 +27,8 @@ codewhale-protocol = { path = "../protocol", version = "0.8.66" } codewhale-release = { path = "../release", version = "0.8.66" } codewhale-secrets = { path = "../secrets", version = "0.8.66" } codewhale-tools = { path = "../tools", version = "0.8.66" } +codewhale-extension = { path = "../codewhale-extension", version = "0.8.66" } +codewhale-tn-extension = { path = "../tn-extension", version = "0.8.66" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" @@ -103,3 +105,4 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = ["s [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Media_Audio", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } + diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 31776914ab..25a2e58218 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -40,6 +40,7 @@ use crate::models::{ }; use crate::prompts; use crate::purge::{emit_purge_completed, emit_purge_failed, emit_purge_started, run_purge}; +use codewhale_extension::ExtensionManager; use crate::resource_telemetry::ResourceTelemetry; use crate::route_runtime::resolve_runtime_route; use crate::seam_manager::{SeamConfig, SeamManager}; @@ -625,6 +626,7 @@ pub struct Engine { token_estimate_cache: TokenEstimateCache, /// Shared pause flag set by the TUI and read before tool execution. shared_paused: Arc>, + extension_manager: std::sync::Arc, } // === Internal tool helpers === @@ -1008,6 +1010,11 @@ impl Engine { current_mode: AppMode::Agent, token_estimate_cache: TokenEstimateCache::new(), shared_paused: shared_paused.clone(), + extension_manager: { + let mut mgr = codewhale_extension::ExtensionManager::new(); + codewhale_tn_extension::TormentNexusExtension::register(&mut mgr); + std::sync::Arc::new(mgr) + }, }; let handle = EngineHandle { tx_op, @@ -2392,6 +2399,14 @@ impl Engine { self.session .working_set .observe_user_message(&content, &self.session.workspace); + + let _results = self.extension_manager + .dispatch(&codewhale_extension::HookEvent::BeforeAgentStart { + system_prompt: String::new(), + prompt: content.clone(), + is_first_turn: self.turn_counter <= 1, + }) + .await; let force_update_plan_first = should_force_update_plan_first(input_policy.mode, &content); // Add user message to session @@ -3954,3 +3969,5 @@ use crate::tools::js_execution::execute_js_execution_tool; #[cfg(test)] mod tests; + + diff --git a/crates/tui/src/core/engine/tool_execution.rs b/crates/tui/src/core/engine/tool_execution.rs index a1a77d62d3..8b72497708 100644 --- a/crates/tui/src/core/engine/tool_execution.rs +++ b/crates/tui/src/core/engine/tool_execution.rs @@ -349,7 +349,7 @@ impl Engine { // cancelled interactive tool). let _terminal = InteractiveTerminalGuard::engage(tx_event, interactive).await; - let outcome = if McpPool::is_mcp_tool(&tool_name) { + let outcome = if McpPool::is_mcp_tool(&tool_name) { if let Some(pool) = mcp_pool { Engine::execute_mcp_tool_with_pool(pool, &tool_name, tool_input).await } else { @@ -525,3 +525,5 @@ mod tests { assert!(nested.exists(), "writer should mkdir -p the parent chain"); } } + + From c4460f363dc4858d1b3585f5c6d70feaedcb0109 Mon Sep 17 00:00:00 2001 From: Robert Pelloni Date: Tue, 7 Jul 2026 02:51:09 -0400 Subject: [PATCH 3/3] docs: add TormentNexus extension section to npm README --- npm/codewhale/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/npm/codewhale/README.md b/npm/codewhale/README.md index 53aa74753c..ece870d60a 100644 --- a/npm/codewhale/README.md +++ b/npm/codewhale/README.md @@ -116,3 +116,28 @@ offline installs, set `DEEPSEEK_TUI_DISABLE_INSTALL=1` or point - Website: - Provider registry: [docs/PROVIDERS.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/PROVIDERS.md) - Changelog: [CHANGELOG.md](https://github.com/Hmbown/CodeWhale/blob/main/CHANGELOG.md) + +## TormentNexus Extension + +CodeWhale 0.8.66+ includes a built-in **TormentNexus extension** (`crates/tn-extension`) compiled directly into the binary — no separate plugin or DLL needed. It provides: + +### Lifecycle Hooks +- **SessionStart** — logs session info to TN L2 memory on port 7778 +- **BeforeAgentStart** — injects TN system prompt guidance + searches L2 for relevant context +- **ToolCall** — logs tool calls; checks 6 dangerous patterns against TN enterprise RBAC +- **ToolResult** — auto-stores substantial results from key tools to L2 +- **TurnEnd** — logs tool usage per turn +- **Input** — expands `@memory:key` inline with L2 content +- **UserBash** — audit-logs shell commands to TN enterprise audit +- **ModelSelect** — tracks model changes +- **SessionCompact** — preserves memory across compaction + +### Custom Tools (via Extension API) +- 9 tool definitions: `tn_memory_store`, `tn_memory_search`, `tn_memory_vector_search`, `tn_tool_search`, `tn_session_search`, `tn_skill_manage`, `tn_code_search`, `tn_context_harvest`, `tn_scratchpad` +- 6 slash commands: `/tn-store`, `/tn-search`, `/tn-status`, `/tn-plan`, `/tn-summary`, `/tn-purge` +- 3 keyboard shortcuts: `Ctrl+Shift+M` (memory), `Ctrl+Shift+T` (tools), `Ctrl+Shift+P` (status) + +### MCP Server Auto-Registration +The extension automatically registers the `tormentnexus` MCP server pointing at `tormentnexus.exe mcp`. Enable it by keeping the TormentNexus sidecar running on `http://127.0.0.1:7778`. + +> Extension source: `crates/tn-extension` — implements `codewhale_extension::Extension` trait.