use exomonad::config;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use exomonad_core::mcp;
use exomonad_core::protocol::{Runtime as HookRuntime, ServiceRequest};
use exomonad_core::services::external::otel::OtelService;
use exomonad_core::services::external::ExternalService;
use exomonad_core::services::{git, zellij_events};
use exomonad_core::{
ClaudePreToolUseOutput, HookEventType, HookInput, HookSpecificOutput, InternalStopHookOutput,
PluginManager, Runtime, RuntimeBuilder, Services, StopDecision, ToolPermission,
};
use std::collections::HashMap;
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
use tracing::{debug, info, warn};
use tracing_subscriber::prelude::*;
#[derive(Parser)]
#[command(name = "exomonad")]
#[command(about = "ExoMonad: Rust host with embedded Haskell WASM plugin for agent orchestration")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Hook {
#[arg(value_enum)]
event: HookEventType,
#[arg(long, default_value = "claude")]
runtime: HookRuntime,
},
McpStdio,
Init {
#[arg(long)]
session: Option<String>,
#[arg(long)]
recreate: bool,
},
Reply {
#[arg(long)]
id: String,
#[arg(long)]
payload: Option<String>,
#[arg(long)]
cancel: bool,
},
}
#[allow(clippy::too_many_arguments)]
async fn emit_hook_span(
otel: &OtelService,
trace_id: &str,
event_type: HookEventType,
runtime: HookRuntime,
hook_input: &HookInput,
decision_str: &str,
start_ns: u64,
extra_attributes: HashMap<String, String>,
) {
let end_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let mut attributes = HashMap::new();
attributes.insert("session.id".to_string(), hook_input.session_id.to_string());
attributes.insert("jsonl.file".to_string(), hook_input.transcript_path.clone());
if let Some(tid) = &hook_input.tool_use_id {
attributes.insert("tool_use_id".to_string(), tid.clone());
}
attributes.insert("hook.type".to_string(), format!("{:?}", event_type));
attributes.insert("hook.runtime".to_string(), runtime.to_string());
attributes.insert("hook.decision".to_string(), decision_str.to_string());
for (k, v) in extra_attributes {
attributes.insert(k, v);
}
let req = ServiceRequest::OtelSpan {
trace_id: trace_id.to_string(),
span_id: uuid::Uuid::new_v4().simple().to_string()[..16].to_string(),
name: format!("hook:{:?}", event_type),
start_ns: Some(start_ns),
end_ns: Some(end_ns),
attributes: Some(attributes),
};
if let Err(e) = otel.call(req).await {
warn!("Failed to emit OTel span: {}", e);
}
}
#[tracing::instrument(skip(plugin, runtime, event_type, zellij_session), fields(event = ?event_type))]
async fn handle_hook(
plugin: &PluginManager,
event_type: HookEventType,
runtime: HookRuntime,
zellij_session: &str,
) -> Result<()> {
use std::io::Read;
let otel = OtelService::from_env().ok();
let trace_id = uuid::Uuid::new_v4().simple().to_string();
let start_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let mut stdin_content = String::new();
std::io::stdin()
.read_to_string(&mut stdin_content)
.context("Failed to read stdin")?;
debug!(
runtime = ?runtime,
payload_len = stdin_content.len(),
"Received hook event"
);
if let Ok(branch) = git::get_current_branch() {
if let Some(agent_id_str) = git::extract_agent_id(&branch) {
match exomonad_core::ui_protocol::AgentId::try_from(agent_id_str.clone()) {
Ok(agent_id) => {
let event = exomonad_core::ui_protocol::AgentEvent::HookReceived {
agent_id,
hook_type: event_type.to_string(),
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(zellij_session, &event) {
warn!("Failed to emit hook:received event: {}", e);
}
}
Err(e) => warn!("Invalid agent_id in branch '{}': {}", agent_id_str, e),
}
} else {
warn!("Could not extract agent_id from branch: {}", branch);
}
} else {
warn!("Could not determine current git branch for HookReceived event");
}
let mut hook_input: HookInput =
serde_json::from_str(&stdin_content).context("Failed to parse hook input")?;
hook_input.runtime = Some(runtime);
let is_stop_hook = matches!(
event_type,
HookEventType::Stop
| HookEventType::AfterAgent
| HookEventType::SubagentStop
| HookEventType::SessionEnd
);
let normalized_event_name = match event_type {
HookEventType::Stop | HookEventType::AfterAgent => "Stop",
HookEventType::SubagentStop => "SubagentStop",
HookEventType::SessionEnd => "SessionEnd",
HookEventType::PreToolUse => "PreToolUse",
_ => {
debug!(event = ?event_type, "Hook type not implemented in WASM, allowing");
let output_json = serde_json::to_string(&ClaudePreToolUseOutput::default())
.context("Failed to serialize output")?;
println!("{}", output_json);
if let Some(otel) = &otel {
emit_hook_span(
otel,
&trace_id,
event_type,
runtime,
&hook_input,
"allow",
start_ns,
HashMap::new(),
)
.await;
}
return Ok(());
}
};
hook_input.hook_event_name = normalized_event_name.to_string();
if is_stop_hook {
let internal_output: InternalStopHookOutput = plugin
.call("handle_pre_tool_use", &hook_input)
.await
.context("WASM handle_pre_tool_use failed")?;
let output_json = internal_output.to_runtime_json(&runtime);
println!("{}", output_json);
let decision_str = match internal_output.decision {
StopDecision::Allow => "allow",
StopDecision::Block => "block",
};
if let Some(otel) = &otel {
let mut extra_attributes = HashMap::new();
extra_attributes.insert("routing.decision".to_string(), decision_str.to_string());
if let Some(reason) = &internal_output.reason {
extra_attributes.insert("routing.reason".to_string(), reason.clone());
}
emit_hook_span(
otel,
&trace_id,
event_type,
runtime,
&hook_input,
decision_str,
start_ns,
extra_attributes,
)
.await;
}
if internal_output.decision == StopDecision::Block {
if event_type == HookEventType::SubagentStop {
if let Ok(branch) = git::get_current_branch() {
if let Some(agent_id_str) = git::extract_agent_id(&branch) {
let reason = internal_output
.reason
.clone()
.unwrap_or_else(|| "Hook blocked agent stop".to_string());
match exomonad_core::ui_protocol::AgentId::try_from(agent_id_str.clone()) {
Ok(agent_id) => {
let event =
exomonad_core::ui_protocol::AgentEvent::StopHookBlocked {
agent_id,
reason,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(zellij_session, &event) {
warn!("Failed to emit stop_hook:blocked event: {}", e);
}
}
Err(e) => warn!("Invalid agent_id in branch '{}': {}", agent_id_str, e),
}
}
}
}
}
} else {
let output: ClaudePreToolUseOutput = plugin
.call("handle_pre_tool_use", &hook_input)
.await
.context("WASM handle_pre_tool_use failed")?;
let output_json = serde_json::to_string(&output).context("Failed to serialize output")?;
println!("{}", output_json);
let decision_str = if !output.continue_ {
"block"
} else {
match output.hook_specific_output {
Some(HookSpecificOutput::PreToolUse {
permission_decision,
..
}) => match permission_decision {
ToolPermission::Allow => "allow",
ToolPermission::Deny => "deny",
ToolPermission::Ask => "ask",
},
_ => "allow",
}
};
if let Some(otel) = &otel {
emit_hook_span(
otel,
&trace_id,
event_type,
runtime,
&hook_input,
decision_str,
start_ns,
HashMap::new(),
)
.await;
}
if !output.continue_ {
std::process::exit(2);
}
}
Ok(())
}
fn run_init(session_override: Option<String>, recreate: bool) -> Result<()> {
if std::env::var("XDG_RUNTIME_DIR").is_err() {
eprintln!("Warning: XDG_RUNTIME_DIR not set. Zellij may fail to find sessions.");
}
let session = match session_override {
Some(s) => s,
None => {
let config = config::Config::discover()?;
config.zellij_session
}
};
let output = std::process::Command::new("zellij")
.args(["list-sessions", "--short"])
.output()
.context("Failed to run zellij list-sessions")?;
let sessions_str = String::from_utf8_lossy(&output.stdout);
let session_alive = sessions_str.lines().any(|l| l.trim() == session);
let session_exited = sessions_str
.lines()
.any(|l| l.starts_with(&session) && l.contains("EXITED"));
if recreate && (session_alive || session_exited) {
eprintln!("Deleting session (--recreate): {}", session);
let status = std::process::Command::new("zellij")
.args(["delete-session", &session])
.status()
.context("Failed to run zellij delete-session")?;
if !status.success() {
eprintln!("Warning: zellij delete-session exited with {}", status);
}
} else if session_alive {
eprintln!("Attaching to session: {}", session);
let err = std::process::Command::new("zellij")
.args(["attach", &session])
.exec();
return Err(err).context("Failed to exec zellij attach");
} else if session_exited {
eprintln!("Cleaning up exited session: {}", session);
let status = std::process::Command::new("zellij")
.args(["delete-session", &session])
.status()
.context("Failed to run zellij delete-session")?;
if !status.success() {
eprintln!("Warning: zellij delete-session exited with {}", status);
}
}
eprintln!("Creating session: {}", session);
let layout_path = generate_tl_layout()?;
let err = std::process::Command::new("zellij")
.arg("--session")
.arg(&session)
.arg("--new-session-with-layout")
.arg(&layout_path)
.exec();
Err(err).context("Failed to exec zellij with layout")
}
fn generate_tl_layout() -> Result<std::path::PathBuf> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let cwd = std::env::current_dir()?;
let params = exomonad_core::layout::AgentTabParams {
tab_name: "TL",
pane_name: "Main",
command: "nix develop",
cwd: &cwd,
shell: &shell,
focus: true,
close_on_exit: false,
};
let layout = exomonad_core::layout::generate_agent_layout(¶ms)
.context("Failed to generate TL layout")?;
let layout_path = std::env::temp_dir().join("exomonad-tl-layout.kdl");
std::fs::write(&layout_path, layout)?;
Ok(layout_path)
}
fn init_logging(command: &Commands) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let use_json = std::env::var("EXOMONAD_LOG_FORMAT")
.map(|v| v.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let is_mcp = matches!(command, Commands::McpStdio);
let log_dir = PathBuf::from(".exomonad/logs");
let file_ok = std::fs::create_dir_all(&log_dir).is_ok();
if !file_ok {
eprintln!("Failed to create .exomonad/logs/. Falling back to stderr-only logging.");
}
let env_filter = tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into());
let (file_layer_plain, file_layer_json, guard) = if file_ok {
let appender = tracing_appender::rolling::daily(&log_dir, "sidecar.log");
let (nb, g) = tracing_appender::non_blocking(appender);
if use_json {
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(nb)
.with_ansi(false);
(None, Some(layer), Some(g))
} else {
let layer = tracing_subscriber::fmt::layer()
.with_writer(nb)
.with_ansi(false);
(Some(layer), None, Some(g))
}
} else {
(None, None, None)
};
let (stderr_layer_plain, stderr_layer_json) = if is_mcp && file_ok {
(None, None)
} else if use_json {
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(std::io::stderr);
(None, Some(layer))
} else {
let layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
(Some(layer), None)
};
tracing_subscriber::registry()
.with(env_filter)
.with(file_layer_plain)
.with(file_layer_json)
.with(stderr_layer_plain)
.with(stderr_layer_json)
.init();
if is_mcp && file_ok {
eprintln!("MCP stdio logging to .exomonad/logs/sidecar.log.YYYY-MM-DD (daily rotation)");
}
guard
}
fn get_agent_id_from_env() -> String {
let branch = git::get_current_branch().unwrap_or_default();
git::extract_agent_id(&branch).unwrap_or_else(|| {
if branch.is_empty() {
"no-branch".to_string()
} else {
"unknown".to_string()
}
})
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let _guard = init_logging(&cli.command);
let config = config::Config::discover().unwrap_or_else(|e| {
debug!(error = %e, "No config found, using defaults");
config::Config::default()
});
let wasm_bytes = exomonad::wasm::get(config.role)?;
info!(
role = ?config.role,
wasm_size = wasm_bytes.len(),
"Using embedded WASM"
);
match cli.command {
Commands::Hook { event, runtime } => {
let services = Arc::new(
Services::new()
.validate()
.context("Failed to validate services")?,
);
let rt = build_runtime(wasm_bytes, &services).await?;
info!("WASM plugin loaded and initialized");
handle_hook(rt.plugin_manager(), event, runtime, &config.zellij_session).await?;
}
Commands::McpStdio => {
let project_dir = if config.project_dir.is_absolute() {
config.project_dir.clone()
} else {
std::env::current_dir()
.context("Failed to get current directory")?
.join(&config.project_dir)
};
let pid_file = project_dir.join(".exomonad/sidecar.pid");
let _pid_guard = exomonad::pid::PidGuard::new(&pid_file)?;
let services = Arc::new(
Services::new()
.validate()
.context("Failed to validate services")?,
);
let rt = build_runtime(wasm_bytes, &services).await?;
let state = rt.into_mcp_state(project_dir);
let agent_id = get_agent_id_from_env();
match exomonad_core::ui_protocol::AgentId::try_from(agent_id.clone()) {
Ok(id) => {
let start_event = exomonad_core::ui_protocol::AgentEvent::AgentStarted {
agent_id: id,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(&config.zellij_session, &start_event)
{
warn!("Failed to emit agent:started event: {}", e);
}
}
Err(e) => warn!("Invalid agent_id '{}': {}", agent_id, e),
}
mcp::stdio::run_stdio_server(state).await?;
let stop_agent_id = get_agent_id_from_env();
match exomonad_core::ui_protocol::AgentId::try_from(stop_agent_id.clone()) {
Ok(id) => {
let stop_event = exomonad_core::ui_protocol::AgentEvent::AgentStopped {
agent_id: id,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(&config.zellij_session, &stop_event) {
warn!("Failed to emit agent:stopped event: {}", e);
}
}
Err(e) => warn!("Invalid agent_id '{}': {}", stop_agent_id, e),
}
}
Commands::Init { session, recreate } => {
run_init(session, recreate)?;
}
Commands::Reply {
id,
payload,
cancel,
} => {
let socket_path = std::env::var("EXOMONAD_CONTROL_SOCKET")
.unwrap_or_else(|_| ".exomonad/sockets/control.sock".to_string());
debug!(socket = %socket_path, "Connecting to control socket");
let mut stream = UnixStream::connect(&socket_path).await.context(format!(
"Failed to connect to control socket at {}",
socket_path
))?;
let parsed_payload = if let Some(p) = payload {
Some(serde_json::from_str(&p).context("Invalid JSON payload")?)
} else {
None
};
let request = ServiceRequest::UserInteraction {
request_id: id,
payload: parsed_payload,
cancel,
};
let mut json = serde_json::to_vec(&request).context("Serialization failed")?;
json.push(b'\n');
stream
.write_all(&json)
.await
.context("Failed to write to socket")?;
info!("Sent reply to control socket");
}
}
Ok(())
}
async fn build_runtime(
wasm_bytes: &[u8],
services: &Arc<exomonad_core::ValidatedServices>,
) -> Result<Runtime> {
let builder = RuntimeBuilder::new().with_wasm_bytes(wasm_bytes.to_vec());
let builder = exomonad_core::register_builtin_handlers(builder, services);
builder.build().await.context("Failed to build runtime")
}