diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index 52bae64d..a91c31c7 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{RwLock, mpsc, oneshot}; use super::history::ConversationHistory; @@ -59,6 +59,12 @@ pub struct BotContext { pub transport: Arc, /// Persistent store for pending deferred-start timers. pub timer_store: Arc, + /// In gateway mode: the currently active project (shared with the gateway HTTP handler). + /// `None` in standalone single-project mode. + pub gateway_active_project: Option>>, + /// In gateway mode: valid project names accepted by the `switch` command. + /// Empty in standalone mode. + pub gateway_projects: Vec, } // --------------------------------------------------------------------------- @@ -110,6 +116,8 @@ mod tests { timer_store: Arc::new(crate::chat::timer::TimerStore::load( std::path::PathBuf::from("/tmp/timers.json"), )), + gateway_active_project: None, + gateway_projects: vec![], }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index 4c355f1c..c14f1e08 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -450,6 +450,47 @@ pub(super) async fn on_room_message( return; } + // In gateway mode, handle the "switch " command to change the + // active project without invoking the LLM. + if let Some(ref active_project) = ctx.gateway_active_project { + let stripped = crate::chat::util::strip_bot_mention( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()) + .to_string(); + + let (cmd, arg) = match stripped.split_once(char::is_whitespace) { + Some((c, a)) => (c.to_string(), a.trim().to_string()), + None => (stripped.clone(), String::new()), + }; + + if cmd.eq_ignore_ascii_case("switch") { + let response = if arg.is_empty() { + let available = ctx.gateway_projects.join(", "); + format!("Usage: `switch `. Available projects: {available}") + } else if ctx.gateway_projects.iter().any(|p| p == &arg) { + *active_project.write().await = arg.clone(); + format!("Switched to project **{arg}**.") + } else { + let available = ctx.gateway_projects.join(", "); + format!("Unknown project `{arg}`. Available: {available}") + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + } + // Check for the timer command, which requires async file I/O and cannot // be handled by the sync command registry. if let Some(timer_cmd) = crate::chat::timer::extract_timer_command( @@ -501,8 +542,14 @@ pub(super) async fn handle_message( // The prompt is just the current message with sender attribution. // Prior conversation context is carried by the Claude Code session. let bot_name = &ctx.bot_name; + let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project { + let name = ap.read().await.clone(); + format!("[Active project: {name}]\n") + } else { + String::new() + }; let prompt = format!( - "[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{}", + "[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}", format_user_prompt(&sender, &user_message) ); diff --git a/server/src/chat/transport/matrix/bot/run.rs b/server/src/chat/transport/matrix/bot/run.rs index e64eec1f..254197bb 100644 --- a/server/src/chat/transport/matrix/bot/run.rs +++ b/server/src/chat/transport/matrix/bot/run.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use tokio::sync::Mutex as TokioMutex; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{RwLock, mpsc, watch}; use super::context::BotContext; use super::format::{format_startup_announcement, markdown_to_html}; @@ -19,6 +19,7 @@ use super::verification::{on_room_verification_request, on_to_device_verificatio /// Connect to the Matrix homeserver, join all configured rooms, and start /// listening for messages. Runs the full Matrix sync loop — call from a /// `tokio::spawn` task so it doesn't block the main thread. +#[allow(clippy::too_many_arguments)] pub async fn run_bot( config: super::super::config::BotConfig, project_root: PathBuf, @@ -27,6 +28,8 @@ pub async fn run_bot( perm_rx: Arc>>, agents: Arc, shutdown_rx: watch::Receiver>, + gateway_active_project: Option>>, + gateway_projects: Vec, ) -> Result<(), String> { let store_path = project_root.join(".huskies").join("matrix_store"); let client = Client::builder() @@ -242,6 +245,8 @@ pub async fn run_bot( htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), transport: Arc::clone(&transport), timer_store, + gateway_active_project, + gateway_projects, }; slog!( diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index 786d3775..68dfafb8 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -37,7 +37,7 @@ use crate::io::watcher::WatcherEvent; use crate::rebuild::ShutdownReason; use std::path::Path; use std::sync::Arc; -use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch}; +use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch}; /// Attempt to start the Matrix bot. /// @@ -64,6 +64,8 @@ pub fn spawn_bot( perm_rx: Arc>>, agents: Arc, shutdown_rx: watch::Receiver>, + gateway_active_project: Option>>, + gateway_projects: Vec, ) { let config = match BotConfig::load(project_root) { Some(c) => c, @@ -100,6 +102,8 @@ pub fn spawn_bot( perm_rx, agents, shutdown_rx, + gateway_active_project, + gateway_projects, ) .await { diff --git a/server/src/gateway.rs b/server/src/gateway.rs index f063d76e..2d49a962 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -19,6 +19,9 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::RwLock; +// Re-export active_project type alias for clarity in gateway bot helpers. +type ActiveProject = Arc>; + // ── Config ─────────────────────────────────────────────────────────── /// A single project entry in `projects.toml`. @@ -563,6 +566,27 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .join(", ") ); + // Locate the gateway config directory (parent of `projects.toml`). + let config_dir = config_path + .parent() + .unwrap_or(std::path::Path::new(".")) + .to_path_buf(); + + // Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI + // connects to this gateway's MCP endpoint (which proxies to the active project). + if let Err(e) = write_gateway_mcp_json(&config_dir, port) { + crate::slog!("[gateway] Warning: could not write .mcp.json: {e}"); + } + + // Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory. + let gateway_projects: Vec = state_arc.config.projects.keys().cloned().collect(); + spawn_gateway_bot( + &config_dir, + Arc::clone(&state_arc.active_project), + gateway_projects, + port, + ); + let route = poem::Route::new() .at( "/mcp", @@ -580,6 +604,72 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .await } +// ── Matrix bot integration ─────────────────────────────────────────── + +/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code +/// CLI at the gateway's own `/mcp` endpoint. This lets the gateway's Matrix +/// bot use gateway-proxied tool calls instead of a project-specific server. +fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::Error> { + let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let url = format!("http://{host}:{port}/mcp"); + let content = serde_json::json!({ + "mcpServers": { + "huskies": { + "type": "http", + "url": url + } + } + }); + let path = config_dir.join(".mcp.json"); + std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())?; + crate::slog!("[gateway] Wrote {} pointing to {}", path.display(), url); + Ok(()) +} + +/// Attempt to spawn the Matrix bot against the gateway config directory. +/// +/// Reads `/.huskies/bot.toml`. If absent or disabled the function +/// returns immediately without spawning anything. When the bot is enabled it +/// receives a shared reference to the gateway's active-project `RwLock` so the +/// `switch` command can change the active project without going through HTTP. +fn spawn_gateway_bot( + config_dir: &Path, + active_project: ActiveProject, + gateway_projects: Vec, + port: u16, +) { + use crate::agents::AgentPool; + use tokio::sync::{broadcast, mpsc}; + + // Create a watcher broadcast channel (no file-system watcher in gateway mode). + let (watcher_tx, _) = broadcast::channel(16); + + // Create a dummy permission channel — permission prompts are not forwarded + // across the proxy boundary in this initial implementation. + let (_perm_tx, perm_rx) = mpsc::unbounded_channel(); + let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx)); + + // Create a shutdown watch channel. Gateway process exit signals Ctrl-C + // via OS signal, not through a watch channel, so we leave this at None + // (no shutdown announcement). The sender is kept alive for the duration. + let (shutdown_tx, shutdown_rx) = + tokio::sync::watch::channel::>(None); + // Keep sender alive so the receiver is never prematurely closed. + std::mem::forget(shutdown_tx); + + let agents = Arc::new(AgentPool::new(port, watcher_tx.clone())); + + crate::chat::transport::matrix::spawn_bot( + config_dir, + watcher_tx, + perm_rx, + agents, + shutdown_rx, + Some(active_project), + gateway_projects, + ); +} + // ── Tests ──────────────────────────────────────────────────────────── #[cfg(test)] @@ -747,4 +837,83 @@ url = "http://localhost:9999" let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml")); assert!(result.is_err()); } + + // ── bot.toml in gateway and standalone modes ───────────────────────── + // + // Both gateway and standalone modes load bot.toml via `BotConfig::load(dir)` + // which looks for `dir/.huskies/bot.toml`. These tests document that the + // same loading convention works from a gateway config directory. + + #[test] + fn bot_config_loads_from_gateway_config_dir() { + use crate::chat::transport::matrix::BotConfig; + + let tmp = tempfile::tempdir().unwrap(); + let huskies_dir = tmp.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write( + huskies_dir.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +"#, + ) + .unwrap(); + + // Gateway passes config_dir (parent of projects.toml) to spawn_bot, + // which calls BotConfig::load(config_dir). Verify this resolves correctly. + let config = BotConfig::load(tmp.path()); + assert!( + config.is_some(), + "bot.toml should load from gateway config dir" + ); + let config = config.unwrap(); + assert_eq!( + config.homeserver.as_deref(), + Some("https://matrix.example.com") + ); + } + + #[test] + fn bot_config_absent_returns_none_in_gateway_mode() { + use crate::chat::transport::matrix::BotConfig; + + // A gateway config directory without a .huskies/bot.toml should yield None, + // allowing the gateway to start without a Matrix bot. + let tmp = tempfile::tempdir().unwrap(); + let config = BotConfig::load(tmp.path()); + assert!( + config.is_none(), + "absent bot.toml must return None in gateway mode" + ); + } + + #[test] + fn bot_config_disabled_returns_none_in_gateway_mode() { + use crate::chat::transport::matrix::BotConfig; + + let tmp = tempfile::tempdir().unwrap(); + let huskies_dir = tmp.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write( + huskies_dir.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = false +"#, + ) + .unwrap(); + + let config = BotConfig::load(tmp.path()); + assert!( + config.is_none(), + "disabled bot.toml must return None in gateway mode" + ); + } } diff --git a/server/src/main.rs b/server/src/main.rs index a3633955..ded89e92 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -824,6 +824,8 @@ async fn main() -> Result<(), std::io::Error> { perm_rx_for_bot, Arc::clone(&startup_agents), matrix_shutdown_rx, + None, + vec![], ); } else { // Keep the receiver alive (drop it) so the sender never errors.