use crate::{inbox, store::Store, tmux};
use anyhow::Result;
use std::path::{Path, PathBuf};
pub async fn deliver_message(
store: &Store,
sessions_dir: &Path,
session_id: &str,
message: &str,
inbox_enabled: bool,
) -> Result<()> {
if !inbox_enabled || !target_can_drain_inbox(store, session_id) {
return tmux::send_keys(session_id, message).await;
}
inbox::write_message(sessions_dir, session_id, message)?;
if let Err(e) = tmux::wake_idle_session(session_id).await {
tracing::warn!(
"idle-wake nudge failed for {session_id} (message already delivered via inbox): {e}"
);
}
Ok(())
}
fn target_can_drain_inbox(store: &Store, session_id: &str) -> bool {
let Ok(Some(session)) = store.get_session(session_id) else {
return false;
};
if session.agent_type != "claude-code" {
return false;
}
let Some(workspace) = session.workspace_path else {
return false;
};
inbox_hooks_installed(Path::new(&workspace))
}
const STOP_HOOK_MARKER: &str = "inbox drain-stop";
fn inbox_hooks_installed(workspace: &Path) -> bool {
let settings_path: PathBuf = workspace.join(".claude").join("settings.json");
let Ok(raw) = std::fs::read_to_string(&settings_path) else {
return false;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return false;
};
let Some(stop_groups) = json.get("hooks").and_then(|h| h.get("Stop")).and_then(|s| s.as_array()) else {
return false;
};
stop_groups.iter().any(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains(STOP_HOOK_MARKER))
})
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::AgentConfig, types::{Session, SessionStatus}};
use tempfile::tempdir;
fn session_with(id: &str, agent_type: &str, workspace_path: Option<String>) -> Session {
Session {
id: id.to_string(), orchestrator_id: None, name: id.to_string(),
repo: String::new(), status: SessionStatus::Working,
agent_type: agent_type.to_string(), cost_usd: 0.0, started_at: 0,
pr_number: None, pr_id: None, workspace_path, pid: None,
model: None, context_tokens: None, catalogue_path: None,
context_used_pct: None, context_total_tokens: None, context_window_size: None,
claude_session_id: None, summary: None, terminal_at: None, gate_status: None,
}
}
fn write_installed_hooks(workspace: &Path) {
std::fs::create_dir_all(workspace.join(".claude")).unwrap();
std::fs::write(
workspace.join(".claude").join("settings.json"),
serde_json::json!({
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "ninox inbox drain-stop"}]}],
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "ninox inbox drain-prompt"}]}]
}
})
.to_string(),
)
.unwrap();
}
#[test]
fn inbox_hooks_installed_true_when_stop_hooks_present() {
let dir = tempdir().unwrap();
write_installed_hooks(dir.path());
assert!(inbox_hooks_installed(dir.path()));
}
#[test]
fn inbox_hooks_installed_false_without_a_settings_file() {
let dir = tempdir().unwrap();
assert!(!inbox_hooks_installed(dir.path()));
}
#[test]
fn inbox_hooks_installed_false_when_settings_has_no_hooks_table() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(
dir.path().join(".claude").join("settings.json"),
r#"{"statusLine": {"type": "command", "command": "ninox statusline"}}"#,
)
.unwrap();
assert!(!inbox_hooks_installed(dir.path()));
}
#[test]
fn inbox_hooks_installed_false_when_stop_array_is_empty() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(
dir.path().join(".claude").join("settings.json"),
r#"{"hooks": {"Stop": []}}"#,
)
.unwrap();
assert!(!inbox_hooks_installed(dir.path()));
}
#[test]
fn inbox_hooks_installed_false_for_a_foreign_stop_hook() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(
dir.path().join(".claude").join("settings.json"),
serde_json::json!({
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "node .claude/lint-on-stop.cjs"}]}]
}
})
.to_string(),
)
.unwrap();
assert!(!inbox_hooks_installed(dir.path()));
}
#[test]
fn target_can_drain_inbox_false_for_unknown_session() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
assert!(!target_can_drain_inbox(&store, "no-such-session"));
}
#[test]
fn target_can_drain_inbox_false_for_non_claude_harness() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let ws = tempdir().unwrap().keep();
write_installed_hooks(&ws);
store.upsert_session(&session_with("codex-worker", "codex", Some(ws.to_string_lossy().to_string()))).unwrap();
assert!(!target_can_drain_inbox(&store, "codex-worker"));
}
#[test]
fn target_can_drain_inbox_false_for_orchestrator_with_no_workspace() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
store.upsert_session(&session_with("orch-1", "claude-code", None)).unwrap();
assert!(!target_can_drain_inbox(&store, "orch-1"));
}
#[test]
fn target_can_drain_inbox_false_for_workspace_without_installed_hooks() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let ws = tempdir().unwrap().keep();
store.upsert_session(&session_with("worker-1", "claude-code", Some(ws.to_string_lossy().to_string()))).unwrap();
assert!(!target_can_drain_inbox(&store, "worker-1"));
}
#[test]
fn target_can_drain_inbox_true_for_claude_code_worker_with_installed_hooks() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let ws = tempdir().unwrap().keep();
write_installed_hooks(&ws);
store.upsert_session(&session_with("worker-1", "claude-code", Some(ws.to_string_lossy().to_string()))).unwrap();
assert!(target_can_drain_inbox(&store, "worker-1"));
}
#[tokio::test]
async fn inbox_enabled_writes_the_message_when_target_can_drain() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let ws = tempdir().unwrap().keep();
write_installed_hooks(&ws);
store.upsert_session(&session_with("worker-1", "claude-code", Some(ws.to_string_lossy().to_string()))).unwrap();
let sessions_dir = tempdir().unwrap();
deliver_message(&store, sessions_dir.path(), "worker-1", "hello worker", true).await.unwrap();
let pending = inbox::read_pending_messages(sessions_dir.path(), "worker-1").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].text, "hello worker");
}
#[tokio::test]
async fn falls_back_to_keystrokes_when_target_cannot_drain_an_inbox() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let sessions_dir = tempdir().unwrap();
let result = deliver_message(&store, sessions_dir.path(), "orch-1", "hello", true).await;
assert!(result.is_err(), "must fall back to (and surface failures from) send_keys");
assert!(
inbox::read_pending_messages(sessions_dir.path(), "orch-1").unwrap().is_empty(),
"message must never be silently written to an inbox nobody can drain"
);
}
#[tokio::test]
async fn inbox_disabled_never_touches_the_inbox() {
let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
let ws = tempdir().unwrap().keep();
write_installed_hooks(&ws);
store.upsert_session(&session_with("worker-1", "claude-code", Some(ws.to_string_lossy().to_string()))).unwrap();
let sessions_dir = tempdir().unwrap();
let result = deliver_message(&store, sessions_dir.path(), "worker-1", "hello", false).await;
assert!(result.is_err());
assert!(inbox::read_pending_messages(sessions_dir.path(), "worker-1").unwrap().is_empty());
}
#[test]
fn agent_config_default_harness_is_claude_code() {
assert_eq!(AgentConfig::default().harness, "claude-code");
}
}