use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand, ValueEnum};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::cli::format::{sanitize_for_terminal, TableOrJson};
use crate::daemon::client::DaemonClient;
use crate::daemon::paths;
use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
use crate::daemon::server;
use crate::sessions::journal::{self, JournalRecord};
use crate::sessions::{Agent, EventStamp, NotificationKind, ObserveRequest, SessionEvent};
const SERVICE: &str = "sessions";
const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Parser)]
pub struct SessionsCommand {
#[command(subcommand)]
pub command: SessionsSubcommands,
}
#[derive(Subcommand)]
pub enum SessionsSubcommands {
List(ListCommand),
Hook(HookCommand),
InstallHooks(InstallHooksCommand),
UninstallHooks(UninstallHooksCommand),
InstallWrapper(InstallWrapperCommand),
UninstallWrapper(UninstallWrapperCommand),
Window(WindowCommand),
WindowUnregister(WindowUnregisterCommand),
}
impl SessionsCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
SessionsSubcommands::List(cmd) => cmd.execute().await,
SessionsSubcommands::Hook(cmd) => cmd.execute().await,
SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
SessionsSubcommands::InstallWrapper(cmd) => cmd.execute(),
SessionsSubcommands::UninstallWrapper(cmd) => cmd.execute(),
SessionsSubcommands::Window(cmd) => cmd.execute().await,
SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let result = call(&socket, "list", Value::Null).await?;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
TableOrJson::Table => println!("{}", render_sessions(&result)),
}
Ok(())
}
}
#[derive(Parser)]
pub struct WindowCommand {
#[arg(long, value_name = "KEY")]
pub key: String,
#[arg(long = "folder", value_name = "PATH")]
pub folders: Vec<PathBuf>,
#[arg(long, value_name = "N", default_value_t = 0)]
pub tabs: usize,
#[arg(long, value_name = "N", default_value_t = 0)]
pub terminals: usize,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl WindowCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let payload = json!({
"key": self.key,
"folders": self.folders,
"tabs": self.tabs,
"terminals": self.terminals,
});
call(&socket, "window", payload).await?;
println!("Reported window {}", self.key);
Ok(())
}
}
#[derive(Parser)]
pub struct WindowUnregisterCommand {
#[arg(long, value_name = "KEY")]
pub key: String,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl WindowUnregisterCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
let removed = reply
.get("removed")
.and_then(Value::as_bool)
.unwrap_or(false);
println!("removed: {removed}");
Ok(())
}
}
#[derive(Parser)]
pub struct HookCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = HookAgent::Claude)]
pub agent: HookAgent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookAgent {
Claude,
Codex,
}
impl HookAgent {
fn agent(self) -> Agent {
match self {
Self::Claude => Agent::Claude,
Self::Codex => Agent::Codex,
}
}
}
impl HookCommand {
pub async fn execute(self) -> Result<()> {
let pid = agent_pid(std::os::unix::process::parent_id());
if let Some(input) = self.read_input(std::io::stdin()) {
self.report(&input, pid).await;
}
Ok(())
}
fn read_input(&self, mut reader: impl Read) -> Option<String> {
let mut input = String::new();
if reader.read_to_string(&mut input).is_err() {
tracing::debug!(agent = ?self.agent, outcome = "stdin_read_failed", "session_hook_skipped");
return None;
}
Some(input)
}
async fn report(&self, input: &str, pid: Option<u32>) {
let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
tracing::debug!(agent = ?self.agent, outcome = "invalid_json", "session_hook_skipped");
return;
};
let Some(hook_op) = hook.to_hook_op(self.agent, pid) else {
return;
};
let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
tracing::debug!(agent = ?self.agent, outcome = "socket_resolution_failed", "session_hook_skipped");
return;
};
let stamp = journal::new_stamp();
self.journal(&socket, &hook_op, &stamp);
let Some((op, payload)) = hook_op.wire(self.agent, Some(&stamp)) else {
return; };
let env = DaemonEnvelope::service(SERVICE, op, payload);
let outcome =
match tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await
{
Err(_) => "timeout",
Ok(Err(_)) => "transport_failed",
Ok(Ok(reply)) if !reply.ok => "daemon_rejected",
Ok(Ok(_)) => "delivered",
};
tracing::debug!(agent = ?self.agent, session_id = ?hook.session_id, ?pid, op, outcome, "session_hook_report");
}
fn journal(&self, socket: &Path, op: &HookOp, stamp: &EventStamp) {
let dir = paths::sessions_journal_dir_for_socket(socket);
match journal::append(&dir, &op.record(stamp)) {
Ok(()) => {
tracing::debug!(agent = ?self.agent, outcome = "journaled", "session_hook_journal");
}
Err(error) => {
tracing::debug!(agent = ?self.agent, outcome = "journal_failed", %error, "session_hook_journal");
}
}
}
}
fn agent_pid(parent: u32) -> Option<u32> {
(parent > 1).then_some(parent)
}
#[derive(Debug, Clone, Default, Deserialize)]
struct HookPayload {
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
transcript_path: Option<PathBuf>,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
hook_event_name: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
notification_type: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Clone)]
enum HookOp {
Observe(ObserveRequest),
End {
agent: Agent,
session_id: String,
reason: Option<String>,
pid: Option<u32>,
},
}
impl HookOp {
fn record(&self, stamp: &EventStamp) -> JournalRecord {
match self {
Self::Observe(req) => JournalRecord::observe(req, stamp),
Self::End {
agent,
session_id,
reason,
pid,
} => JournalRecord::end(*agent, session_id, reason.as_deref(), *pid, stamp),
}
}
fn wire(&self, agent: HookAgent, stamp: Option<&EventStamp>) -> Option<(&'static str, Value)> {
let (op, mut payload) = match self {
Self::Observe(request) => {
let Ok(payload) = serde_json::to_value(request) else {
tracing::debug!(
?agent,
outcome = "serialization_failed",
"session_hook_skipped"
);
return None;
};
("observe", payload)
}
Self::End {
session_id,
reason,
pid,
..
} => {
let mut payload = json!({ "session_id": session_id });
if let Some(reason) = reason {
payload["reason"] = Value::String(reason.clone());
}
if let Some(pid) = pid {
payload["pid"] = Value::from(*pid);
}
("end", payload)
}
};
if let Some(stamp) = stamp {
payload["stamp"] = json!({ "ts": stamp.ts, "seq": stamp.seq });
}
Some((op, payload))
}
}
impl HookPayload {
fn to_hook_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<HookOp> {
let Some(session_id) = self.session_id.clone().filter(|s| !s.trim().is_empty()) else {
tracing::debug!(
?agent,
outcome = "missing_session_id",
"session_hook_skipped"
);
return None;
};
let Some(event_name) = self.hook_event_name.as_deref() else {
tracing::debug!(?agent, outcome = "missing_event", "session_hook_skipped");
return None;
};
if event_name == "SessionEnd" {
return Some(HookOp::End {
agent: agent.agent(),
session_id,
reason: self.reason.clone().or_else(|| self.message.clone()),
pid,
});
}
let mapped = match agent {
HookAgent::Claude => session_event_for(
event_name,
self.source.as_deref(),
self.notification_type.as_deref(),
self.message.as_deref(),
),
HookAgent::Codex => codex_session_event_for(
event_name,
self.source.as_deref(),
self.tool_name.as_deref(),
),
};
let Some(mut event) = mapped else {
tracing::debug!(?agent, outcome = "unmapped_event", "session_hook_skipped");
return None;
};
let agent_id = (agent == HookAgent::Claude)
.then(|| self.agent_id.clone())
.flatten()
.filter(|id| !id.trim().is_empty());
if agent_id.is_some() && event_name == "SubagentStop" {
event = SessionEvent::PostToolUse;
}
Some(HookOp::Observe(ObserveRequest {
agent_id,
agent: agent.agent(),
session_id,
cwd: self.cwd.clone(),
transcript_path: self.transcript_path.clone(),
event,
repo: None,
model: self.model.clone(),
pid,
}))
}
#[cfg(test)]
fn to_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<(&'static str, Value)> {
self.to_hook_op(agent, pid)?.wire(agent, None)
}
}
fn session_event_for(
event_name: &str,
source: Option<&str>,
notification_type: Option<&str>,
message: Option<&str>,
) -> Option<SessionEvent> {
Some(match event_name {
"SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
"SessionStart" => SessionEvent::SessionStart,
"UserPromptSubmit" => SessionEvent::UserPromptSubmit,
"PreToolUse" => SessionEvent::PreToolUse,
"PostToolUse" | "PostToolUseFailure" | "PermissionDenied" | "ElicitationResult"
| "SubagentStart" => SessionEvent::PostToolUse,
"SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
"Stop" | "StopFailure" => SessionEvent::Stop,
"PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
"Elicitation" => SessionEvent::Notification(NotificationKind::AgentNeedsInput),
"Notification" => {
let kind = classify_notification(notification_type, message);
let has_notification_type = notification_type.is_some();
let has_message = message.is_some();
tracing::debug!(
?kind,
has_notification_type,
has_message,
"session_notification_classified"
);
SessionEvent::Notification(kind)
}
_ => return None,
})
}
fn codex_session_event_for(
event_name: &str,
source: Option<&str>,
tool_name: Option<&str>,
) -> Option<SessionEvent> {
Some(match event_name {
"SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
"SessionStart" => SessionEvent::SessionStart,
"UserPromptSubmit" => SessionEvent::UserPromptSubmit,
"PreToolUse" if tool_name == Some("request_user_input") => {
SessionEvent::Notification(NotificationKind::AgentNeedsInput)
}
"PreToolUse" => SessionEvent::PreToolUse,
"PostToolUse" | "SubagentStart" => SessionEvent::PostToolUse,
"SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
"PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
"Stop" | "Interrupt" => SessionEvent::Stop,
_ => return None,
})
}
fn classify_notification(
notification_type: Option<&str>,
message: Option<&str>,
) -> NotificationKind {
match notification_type {
Some("permission_prompt") => return NotificationKind::PermissionPrompt,
Some("idle_prompt") => return NotificationKind::IdlePrompt,
Some("elicitation_dialog") => return NotificationKind::AgentNeedsInput,
Some(_) => return NotificationKind::Other,
None => {}
}
let Some(message) = message else {
return NotificationKind::Other;
};
let lower = message.to_lowercase();
if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
NotificationKind::PermissionPrompt
} else if lower.contains("waiting for your input")
|| lower.contains("idle")
|| lower.contains("needs your input")
{
NotificationKind::IdlePrompt
} else {
NotificationKind::Other
}
}
#[derive(Parser)]
pub struct InstallHooksCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub pi_agent_dir: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub codex_home: Option<PathBuf>,
}
impl InstallHooksCommand {
pub fn execute(self) -> Result<()> {
let path = settings_path(self.settings)?;
let mut settings = read_settings(&path)?;
let command = hook_command();
let sync = sync_claude_hooks(&mut settings, &command);
if sync.is_noop() {
println!(
"sessions hooks already installed in {} (no change)",
path.display()
);
} else {
write_settings(&path, &settings)?;
if sync.replaced > 0 {
println!(
"rewrote {} stale `sessions hook` entry(ies) in {} in place",
sync.replaced,
path.display()
);
}
if sync.deduped > 0 {
println!(
"removed {} duplicate `sessions hook` entry(ies) from {}",
sync.deduped,
path.display()
);
}
if sync.added > 0 {
println!(
"installed {} sessions hook event(s) into {}",
sync.added,
path.display()
);
}
println!("command: {command}");
}
if sync.customised > 0 {
println!("{}", customised_note(sync.customised));
}
install_pi_extension(self.pi_agent_dir)?;
install_codex_hooks(self.codex_home)
}
}
#[derive(Parser)]
pub struct UninstallHooksCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub pi_agent_dir: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub codex_home: Option<PathBuf>,
}
impl UninstallHooksCommand {
pub fn execute(self) -> Result<()> {
let path = settings_path(self.settings)?;
if path.exists() {
let mut settings = read_settings(&path)?;
let current = hook_command();
let removed = remove_hooks(&mut settings, |c| c == current || is_stale_claude_sink(c));
write_settings(&path, &settings)?;
println!(
"removed {removed} sessions hook entry(ies) from {}",
path.display()
);
let customised = count_customised_sinks(&settings, ¤t);
if customised > 0 {
println!("{}", customised_note(customised));
}
} else {
println!("no settings file at {} (nothing to remove)", path.display());
}
uninstall_pi_extension(self.pi_agent_dir)?;
uninstall_codex_hooks(self.codex_home)
}
}
const PI_EXTENSION_NAME: &str = "omni-dev-sessions.ts";
const PI_EXTENSION_TEMPLATE: &str = include_str!("../templates/pi-sessions-extension.ts");
const PI_SOCKET_PLACEHOLDER: &str = "__OMNI_DEV_SOCKET__";
fn pi_extension_marker() -> &'static str {
PI_EXTENSION_TEMPLATE.lines().next().unwrap_or_default()
}
fn render_pi_extension(socket: &Path) -> String {
let literal =
serde_json::to_string(&socket.display().to_string()).unwrap_or_else(|_| "\"\"".to_string());
PI_EXTENSION_TEMPLATE.replace(PI_SOCKET_PLACEHOLDER, &literal)
}
fn default_pi_agent_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("PI_CODING_AGENT_DIR").filter(|d| !d.is_empty()) {
return Ok(PathBuf::from(dir));
}
let home = dirs::home_dir().context("could not resolve the home directory")?;
Ok(home.join(".pi").join("agent"))
}
fn pi_is_present(agent_dir: &Path, path: Option<&std::ffi::OsStr>) -> bool {
agent_dir.is_dir()
|| path.is_some_and(|path| {
std::env::split_paths(path).any(|dir| is_executable(&dir.join("pi")))
})
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[derive(Debug, PartialEq, Eq)]
enum PiInstall {
Written,
Unchanged,
}
fn write_pi_extension(agent_dir: &Path, contents: &str) -> Result<(PathBuf, PiInstall)> {
let dir = agent_dir.join("extensions");
let file = dir.join(PI_EXTENSION_NAME);
if file.exists() {
let existing = std::fs::read_to_string(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
if existing == contents {
return Ok((file, PiInstall::Unchanged));
}
if !existing.starts_with(pi_extension_marker()) {
bail!(
"{} exists and was not written by omni-dev; refusing to overwrite it",
file.display()
);
}
}
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
std::fs::write(&file, contents)
.with_context(|| format!("failed to write {}", file.display()))?;
Ok((file, PiInstall::Written))
}
fn remove_pi_extension(agent_dir: &Path) -> Result<Option<PathBuf>> {
let file = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
let Ok(existing) = std::fs::read_to_string(&file) else {
return Ok(None);
};
if !existing.starts_with(pi_extension_marker()) {
return Ok(None);
}
std::fs::remove_file(&file).with_context(|| format!("failed to remove {}", file.display()))?;
Ok(Some(file))
}
fn install_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
let agent_dir = if let Some(dir) = explicit {
dir
} else {
let dir = default_pi_agent_dir()?;
if !pi_is_present(&dir, std::env::var_os("PATH").as_deref()) {
println!(
"pi.dev not found (no {} and no `pi` on PATH); skipped its extension",
dir.display()
);
return Ok(());
}
dir
};
let socket = server::resolve_socket(None)?;
let (file, outcome) = write_pi_extension(&agent_dir, &render_pi_extension(&socket))?;
match outcome {
PiInstall::Unchanged => println!(
"pi.dev extension already installed at {} (no change)",
file.display()
),
PiInstall::Written => {
println!(
"installed the pi.dev sessions extension at {}",
file.display()
);
println!("pi sessions started after this report their state");
}
}
Ok(())
}
fn uninstall_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
let agent_dir = match explicit {
Some(dir) => dir,
None => default_pi_agent_dir()?,
};
if let Some(file) = remove_pi_extension(&agent_dir)? {
println!("removed {}", file.display());
}
Ok(())
}
const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
const SHIM_NAME: &str = "claude-wrap";
#[derive(Parser)]
pub struct InstallWrapperCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub shim: Option<PathBuf>,
}
impl InstallWrapperCommand {
pub fn execute(self) -> Result<()> {
let shim = shim_path(self.shim)?;
write_shim(&shim)?;
let path = vscode_settings_path(self.settings)?;
let target = shim.display().to_string();
let mut settings =
read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
let changed = set_wrapper(&mut settings, &target);
write_settings(&path, &settings)?;
println!("wrapper shim: {target}");
if changed {
println!("set {WRAPPER_SETTING_KEY} in {}", path.display());
println!("reload VS Code; Claude tabs opened after that report their exact state");
} else {
println!(
"{WRAPPER_SETTING_KEY} already points there in {} (no change)",
path.display()
);
}
Ok(())
}
}
#[derive(Parser)]
pub struct UninstallWrapperCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub shim: Option<PathBuf>,
}
impl UninstallWrapperCommand {
pub fn execute(self) -> Result<()> {
let shim = shim_path(self.shim)?;
let target = shim.display().to_string();
let path = vscode_settings_path(self.settings)?;
if path.exists() {
let mut settings =
read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
if clear_wrapper(&mut settings, &target) {
write_settings(&path, &settings)?;
println!("cleared {WRAPPER_SETTING_KEY} in {}", path.display());
} else {
println!(
"{WRAPPER_SETTING_KEY} in {} does not point at our shim; left alone",
path.display()
);
}
} else {
println!("no settings file at {} (nothing to clear)", path.display());
}
if shim.exists() {
std::fs::remove_file(&shim)
.with_context(|| format!("failed to remove {}", shim.display()))?;
println!("removed {target}");
}
Ok(())
}
}
fn shim_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
match explicit {
Some(path) => Ok(path),
None => Ok(paths::runtime_dir()?.join(SHIM_NAME)),
}
}
fn vscode_settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
return Ok(path);
}
let base = dirs::config_dir().context("could not determine the user config directory")?;
Ok(base.join("Code").join("User").join("settings.json"))
}
fn shim_contents() -> String {
let exe = std::env::current_exe()
.map_or_else(|_| "omni-dev".to_string(), |exe| exe.display().to_string());
format!("#!/bin/sh\nexec \"{exe}\" claude-wrap -- \"$@\"\n")
}
fn write_shim(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
paths::ensure_dir_0700(parent)?;
}
std::fs::write(path, shim_contents())
.with_context(|| format!("failed to write {}", path.display()))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("failed to make {} executable", path.display()))
}
fn set_wrapper(settings: &mut Value, shim: &str) -> bool {
let Some(root) = settings.as_object_mut() else {
return false;
};
if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) == Some(shim) {
return false;
}
root.insert(WRAPPER_SETTING_KEY.to_string(), json!(shim));
true
}
fn clear_wrapper(settings: &mut Value, shim: &str) -> bool {
let Some(root) = settings.as_object_mut() else {
return false;
};
if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) != Some(shim) {
return false;
}
root.remove(WRAPPER_SETTING_KEY);
true
}
fn manual_setup_hint(error: &anyhow::Error, shim: &str) -> anyhow::Error {
anyhow!(
"{error:#}\n\n\
VS Code settings files may contain comments or trailing commas, which cannot be \
rewritten safely. The shim is installed, so add this line by hand instead:\n\n \
\"{WRAPPER_SETTING_KEY}\": \"{shim}\""
)
}
#[derive(Debug, Clone, Copy)]
struct HookSpec {
event: &'static str,
matcher: bool,
timeout: Option<u64>,
}
impl HookSpec {
const fn new(event: &'static str) -> Self {
Self {
event,
matcher: false,
timeout: None,
}
}
const fn matched(event: &'static str) -> Self {
Self {
matcher: true,
..Self::new(event)
}
}
const fn capped(event: &'static str, secs: u64) -> Self {
Self {
timeout: Some(secs),
..Self::new(event)
}
}
}
const HOOK_EVENTS: &[HookSpec] = &[
HookSpec::new("SessionStart"),
HookSpec::new("UserPromptSubmit"),
HookSpec::matched("PreToolUse"),
HookSpec::matched("PostToolUse"),
HookSpec::new("Notification"),
HookSpec::new("Stop"),
HookSpec::new("SessionEnd"),
HookSpec::matched("PermissionRequest"),
HookSpec::matched("PermissionDenied"),
HookSpec::matched("PostToolUseFailure"),
HookSpec::new("StopFailure"),
HookSpec::new("Elicitation"),
HookSpec::new("ElicitationResult"),
HookSpec::new("SubagentStart"),
HookSpec::new("SubagentStop"),
HookSpec::new("PreCompact"),
HookSpec::new("PostCompact"),
];
const CODEX_HOOK_EVENTS: &[HookSpec] = &[
HookSpec::new("SessionStart"),
HookSpec::new("UserPromptSubmit"),
HookSpec::matched("PreToolUse"),
HookSpec::matched("PostToolUse"),
HookSpec::new("PermissionRequest"),
HookSpec::new("Stop"),
HookSpec::capped("Interrupt", 3),
HookSpec::capped("SessionEnd", 3),
HookSpec::new("PreCompact"),
HookSpec::new("PostCompact"),
HookSpec::new("SubagentStart"),
HookSpec::new("SubagentStop"),
];
fn hook_command() -> String {
match std::env::current_exe() {
Ok(exe) => format!("{} sessions hook", exe.display()),
Err(_) => "omni-dev sessions hook".to_string(),
}
}
fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
return Ok(path);
}
if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("settings.json"));
}
let home = dirs::home_dir().context("could not resolve the home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
fn read_settings(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(json!({}));
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
if text.trim().is_empty() {
return Ok(json!({}));
}
let value: Value = serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON; refusing to overwrite it",
path.display()
)
})?;
if !value.is_object() {
bail!(
"{} is not a JSON object; refusing to overwrite it",
path.display()
);
}
Ok(value)
}
fn write_settings(path: &Path, settings: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut text = serde_json::to_string_pretty(settings)?;
text.push('\n');
std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn merge_hooks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
let Some(root) = settings.as_object_mut() else {
return 0;
};
let hooks = root
.entry("hooks")
.or_insert_with(|| json!({}))
.as_object_mut();
let Some(hooks) = hooks else {
return 0;
};
let mut added = 0;
for spec in specs {
let groups = hooks
.entry(spec.event.to_string())
.or_insert_with(|| json!([]));
let Some(groups) = groups.as_array_mut() else {
continue;
};
if groups.iter().any(|g| group_has_command(g, command)) {
continue; }
groups.push(hook_group(command, spec));
added += 1;
}
added
}
fn remove_hooks(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> usize {
let Some(root) = settings.as_object_mut() else {
return 0;
};
let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let mut removed = 0;
let mut empty_events = Vec::new();
for (event, groups) in hooks.iter_mut() {
let Some(groups) = groups.as_array_mut() else {
continue;
};
for group in groups.iter_mut() {
if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
let before = inner.len();
inner.retain(|h| {
!h.get("command")
.and_then(Value::as_str)
.is_some_and(&is_ours)
});
removed += before - inner.len();
}
}
groups.retain(|g| {
g.get("hooks")
.and_then(Value::as_array)
.is_none_or(|h| !h.is_empty())
});
if groups.is_empty() {
empty_events.push(event.clone());
}
}
for event in empty_events {
hooks.remove(&event);
}
removed
}
fn hook_group(command: &str, spec: &HookSpec) -> Value {
let mut group = json!({ "hooks": [hook_entry(command, spec)] });
if spec.matcher {
group["matcher"] = Value::String("*".to_string());
}
group
}
fn hook_entry(command: &str, spec: &HookSpec) -> Value {
let mut hook = json!({ "type": "command", "command": command });
if let Some(secs) = spec.timeout {
hook["timeout"] = json!(secs);
}
hook
}
fn group_has_command(group: &Value, command: &str) -> bool {
group
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
}
fn hook_has_command(hook: &Value, command: &str) -> bool {
hook.get("command").and_then(Value::as_str) == Some(command)
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ClaudeSync {
replaced: usize,
deduped: usize,
added: usize,
customised: usize,
}
impl ClaudeSync {
fn is_noop(&self) -> bool {
self.replaced == 0 && self.deduped == 0 && self.added == 0
}
}
fn is_stale_claude_sink(command: &str) -> bool {
let Some((launcher, rest)) = split_sink(command) else {
return false;
};
let args: Vec<&str> = rest.split_whitespace().collect();
!launcher.contains(char::is_whitespace)
&& matches!(
args.as_slice(),
[] | ["--agent", "claude"] | ["--agent=claude"]
)
}
fn is_customised_sink(command: &str, current: &str) -> bool {
command != current && is_sessions_sink(command) && !is_stale_claude_sink(command)
}
fn count_customised_sinks(settings: &Value, current: &str) -> usize {
settings
.get("hooks")
.and_then(Value::as_object)
.into_iter()
.flat_map(|hooks| hooks.values())
.filter_map(Value::as_array)
.flatten()
.filter_map(|g| g.get("hooks").and_then(Value::as_array))
.flatten()
.filter_map(|h| h.get("command").and_then(Value::as_str))
.filter(|c| is_customised_sink(c, current))
.count()
}
fn customised_note(count: usize) -> String {
format!(
"left {count} customised `sessions hook` entry(ies) (extra arguments such as \
`--socket`) untouched: edit or remove them by hand if they point at a moved binary"
)
}
fn dedupe_command(settings: &mut Value, command: &str) -> usize {
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let mut removed = 0;
for groups in hooks.values_mut() {
let Some(groups) = groups.as_array_mut() else {
continue;
};
let mut seen: Vec<Option<String>> = Vec::new();
let mut dropped_here = 0;
for group in groups.iter_mut() {
let matcher = group
.get("matcher")
.and_then(Value::as_str)
.map(str::to_string);
let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
continue;
};
let before = inner.len();
inner.retain(|h| {
if !hook_has_command(h, command) {
return true;
}
if seen.contains(&matcher) {
return false;
}
seen.push(matcher.clone());
true
});
let dropped = before - inner.len();
if dropped > 0 && inner.is_empty() {
*group = Value::Null; }
dropped_here += dropped;
}
if dropped_here == 0 {
continue; }
removed += dropped_here;
groups.retain(|g| !g.is_null());
}
removed
}
fn sync_claude_hooks(settings: &mut Value, command: &str) -> ClaudeSync {
let replaced = replace_stale_sinks(settings, command, HOOK_EVENTS, is_stale_claude_sink);
let deduped = dedupe_command(settings, command);
let added = merge_hooks(settings, command, HOOK_EVENTS);
ClaudeSync {
replaced,
deduped,
added,
customised: count_customised_sinks(settings, command),
}
}
const CODEX_TRUST_STEP: &str =
"Codex skips untrusted hooks silently: open the Codex CLI, run `/hooks`, \
and trust the omni-dev entries (once, and again after the command changes). \
One approval covers the VS Code extension and Codex Desktop.";
fn codex_hook_command() -> String {
format!("{} --agent codex", hook_command())
}
fn default_codex_home() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
return Ok(PathBuf::from(dir));
}
let home = dirs::home_dir().context("could not resolve the home directory")?;
Ok(home.join(".codex"))
}
fn codex_is_present(codex_home: &Path, path: Option<&std::ffi::OsStr>) -> bool {
codex_home.is_dir()
|| path.is_some_and(|path| {
std::env::split_paths(path).any(|dir| is_executable(&dir.join("codex")))
})
}
fn is_sessions_sink(command: &str) -> bool {
split_sink(command).is_some()
}
fn split_sink(command: &str) -> Option<(&str, &str)> {
let command = command.trim();
let at = command.find(" sessions hook")?;
let rest = &command[at + " sessions hook".len()..];
let launcher = command[..at].trim();
((rest.is_empty() || rest.starts_with(char::is_whitespace))
&& Path::new(launcher)
.file_name()
.is_some_and(|name| name == "omni-dev"))
.then_some((launcher, rest))
}
fn replace_stale_sinks(
settings: &mut Value,
command: &str,
specs: &[HookSpec],
is_stale: impl Fn(&str) -> bool,
) -> usize {
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let mut replaced = 0;
for (event, groups) in hooks.iter_mut() {
let spec = specs.iter().find(|s| s.event == event);
let Some(groups) = groups.as_array_mut() else {
continue;
};
for hook in groups
.iter_mut()
.filter_map(|g| g.get_mut("hooks").and_then(Value::as_array_mut))
.flatten()
{
let stale = hook
.get("command")
.and_then(Value::as_str)
.is_some_and(|c| c != command && is_stale(c));
if !stale {
continue;
}
hook["command"] = Value::String(command.to_string());
if let Some(secs) = spec.and_then(|s| s.timeout) {
hook["timeout"] = json!(secs);
}
replaced += 1;
}
}
replaced
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Removal {
removed: usize,
shifted: bool,
}
fn remove_hooks_stable(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> Removal {
let mut outcome = Removal::default();
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return outcome;
};
let mut empty_events = Vec::new();
for (event, groups) in hooks.iter_mut() {
let Some(groups) = groups.as_array_mut() else {
continue;
};
for group in groups.iter_mut() {
let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
continue;
};
let mut removed_before = false;
inner.retain(|h| {
let ours = h
.get("command")
.and_then(Value::as_str)
.is_some_and(&is_ours);
if ours {
removed_before = true;
outcome.removed += 1;
} else if removed_before {
outcome.shifted = true;
}
!ours
});
if removed_before && inner.is_empty() {
*group = json!({ "hooks": [] });
}
}
while groups.last().is_some_and(is_empty_group) {
groups.pop();
}
if groups.is_empty() {
empty_events.push(event.clone());
}
}
for event in empty_events {
hooks.remove(&event);
}
outcome
}
fn is_empty_group(group: &Value) -> bool {
group
.get("hooks")
.and_then(Value::as_array)
.is_some_and(Vec::is_empty)
}
fn install_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
let codex_home = if let Some(dir) = explicit {
dir
} else {
let dir = default_codex_home()?;
if !codex_is_present(&dir, std::env::var_os("PATH").as_deref()) {
println!(
"Codex not found (no {} and no `codex` on PATH); skipped its hooks",
dir.display()
);
return Ok(());
}
dir
};
let path = codex_home.join("hooks.json");
let mut hooks = read_settings(&path)?;
let command = codex_hook_command();
let replaced = replace_stale_sinks(&mut hooks, &command, CODEX_HOOK_EVENTS, is_sessions_sink);
let added = merge_hooks(&mut hooks, &command, CODEX_HOOK_EVENTS);
if replaced == 0 && added == 0 {
println!(
"Codex sessions hooks already installed in {} (no change)",
path.display()
);
return Ok(());
}
write_settings(&path, &hooks)?;
if replaced > 0 {
println!(
"rewrote {replaced} existing `sessions hook` entry(ies) in {} in place",
path.display()
);
}
if added > 0 {
println!(
"installed {added} Codex sessions hook event(s) into {}",
path.display()
);
}
println!("command: {command}\n{CODEX_TRUST_STEP}");
Ok(())
}
fn uninstall_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
let codex_home = match explicit {
Some(dir) => dir,
None => default_codex_home()?,
};
let path = codex_home.join("hooks.json");
if !path.exists() {
return Ok(());
}
let mut hooks = read_settings(&path)?;
let current = codex_hook_command();
let Removal { removed, shifted } =
remove_hooks_stable(&mut hooks, |c| c == current || is_sessions_sink(c));
if removed == 0 {
return Ok(());
}
write_settings(&path, &hooks)?;
println!(
"removed {removed} Codex sessions hook entry(ies) from {}",
path.display()
);
if shifted {
println!(
"a hook that shared a group with ours moved up within it, so Codex no \
longer trusts it: run `/hooks` in the Codex CLI to trust it again"
);
} else {
println!("no other Codex hook moved, so their `/hooks` trust stands");
}
Ok(())
}
async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
let reply = DaemonClient::new(socket)
.request(DaemonEnvelope::service(SERVICE, op, payload))
.await?;
reply_payload(reply)
}
fn reply_payload(reply: DaemonReply) -> Result<Value> {
if reply.ok {
Ok(reply.payload)
} else {
bail!(
"daemon returned an error: {}",
reply.error.as_deref().unwrap_or("unknown error")
)
}
}
fn render_sessions(result: &Value) -> String {
let sessions = result
.get("sessions")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if sessions.is_empty() {
return "No active agent sessions.".to_string();
}
let mut out = format!(
"{:<13} {:<6} {:<8} {:<20} {:>5} {}",
"STATE", "AGENT", "SOURCE", "REPO", "AGE", "CWD"
);
for session in sessions {
let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
let agent = agent_label(session);
let source = source_label(session);
let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
let age = age_secs(session.get("last_seen").and_then(Value::as_str));
out.push_str(&format!(
"\n{state:<13} {agent:<6} {source:<8} {repo:<20} {age:>4}s {cwd}"
));
}
out
}
fn state_display(state: &str) -> String {
match state {
"waiting_for_permission" => "waiting-perm".to_string(),
"waiting_for_input" => "waiting-input".to_string(),
other => sanitize(other),
}
}
fn agent_label(session: &Value) -> &'static str {
match session.get("agent").and_then(Value::as_str) {
Some("pi") => "pi",
Some("codex") => "codex",
_ => "claude",
}
}
fn source_label(session: &Value) -> &'static str {
match session.pointer("/source/kind").and_then(Value::as_str) {
Some("vs_code") => "vscode",
_ => "terminal",
}
}
fn sanitize(s: &str) -> String {
sanitize_for_terminal(s)
}
fn age_secs(ts: Option<&str>) -> i64 {
ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or(0, |t| {
(Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
#[tokio::test]
async fn hook_diagnostics_identify_gates_without_logging_payload_content() {
let dir = tempfile::tempdir().unwrap();
let command = HookCommand {
socket: Some(dir.path().join("missing.sock")),
agent: HookAgent::Claude,
};
let ((), logs) = crate::test_support::capture_future_at(tracing::Level::DEBUG, async {
for input in ["not json HOOK_CONTENT_SECRET", "{}",
r#"{"session_id":"test"}"#,
r#"{"session_id":"test","hook_event_name":"future-event"}"#,
r#"{"session_id":"test","hook_event_name":"Notification","message":"HOOK_CONTENT_SECRET"}"#] {
command.report(input, None).await;
}
}).await;
for reason in [
"invalid_json",
"missing_session_id",
"missing_event",
"unmapped_event",
"transport_failed",
] {
assert!(logs.contains(reason), "missing {reason}: {logs}");
}
assert!(logs.contains("session_notification_classified"));
assert!(!logs.contains("HOOK_CONTENT_SECRET"));
}
const JOURNAL_ID: &str = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
fn journal_hook(event: &str) -> String {
format!(
r#"{{"session_id":"{JOURNAL_ID}","cwd":"/work/repo","hook_event_name":"{event}","prompt":"PROMPT_CONTENT_SECRET","tool_input":{{"command":"TOOL_CONTENT_SECRET"}}}}"#
)
}
fn read_journal(dir: &Path, agent: &str) -> Vec<JournalRecord> {
let path = dir
.join("sessions")
.join(agent)
.join(format!("{JOURNAL_ID}.jsonl"));
let text = std::fs::read_to_string(path).unwrap();
assert!(!text.contains("CONTENT_SECRET"), "{text}");
text.lines()
.map(|line| JournalRecord::parse(line).unwrap())
.collect()
}
#[tokio::test]
async fn a_hook_is_journaled_even_when_the_daemon_is_unreachable() {
let dir = tempfile::tempdir().unwrap();
let command = HookCommand {
socket: Some(dir.path().join("missing.sock")),
agent: HookAgent::Claude,
};
command
.report(&journal_hook("UserPromptSubmit"), Some(77))
.await;
command.report(&journal_hook("Stop"), Some(77)).await;
command.report(&journal_hook("SessionEnd"), Some(77)).await;
let records = read_journal(dir.path(), "claude");
assert_eq!(records.len(), 3);
assert!(records
.iter()
.all(|r| r.agent == Agent::Claude && r.pid() == Some(77)));
assert!(matches!(
&records[0].body,
journal::JournalBody::Observe { event: SessionEvent::UserPromptSubmit, cwd: Some(cwd), .. }
if cwd == Path::new("/work/repo")
));
assert!(matches!(
&records[1].body,
journal::JournalBody::Observe {
event: SessionEvent::Stop,
..
}
));
assert!(matches!(&records[2].body, journal::JournalBody::End { .. }));
let seqs: std::collections::HashSet<_> = records.iter().map(|r| r.seq.clone()).collect();
assert_eq!(seqs.len(), 3);
}
#[tokio::test]
async fn a_codex_hook_is_journaled_under_its_own_agent() {
let dir = tempfile::tempdir().unwrap();
let command = HookCommand {
socket: Some(dir.path().join("missing.sock")),
agent: HookAgent::Codex,
};
command
.report(&journal_hook("PermissionRequest"), None)
.await;
let records = read_journal(dir.path(), "codex");
assert_eq!(records.len(), 1);
assert_eq!(records[0].agent, Agent::Codex);
assert!(!dir.path().join("sessions/claude").exists());
}
#[tokio::test]
async fn events_the_tracker_ignores_and_ids_no_journal_is_named_for_leave_no_journal() {
let dir = tempfile::tempdir().unwrap();
let command = HookCommand {
socket: Some(dir.path().join("missing.sock")),
agent: HookAgent::Claude,
};
command.report(&journal_hook("FutureEvent"), None).await;
command
.report(
r#"{"session_id":"../../escape","hook_event_name":"Stop"}"#,
None,
)
.await;
assert!(!dir.path().join("sessions").exists());
assert!(!dir.path().join("escape").exists());
}
#[tokio::test]
async fn a_journal_write_failure_is_swallowed_and_never_logs_content() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("sessions"), "").unwrap();
let command = HookCommand {
socket: Some(dir.path().join("missing.sock")),
agent: HookAgent::Claude,
};
let ((), logs) = crate::test_support::capture_future_at(tracing::Level::DEBUG, async {
command.report(&journal_hook("Stop"), None).await;
})
.await;
assert!(logs.contains("journal_failed"), "{logs}");
assert!(logs.contains("transport_failed"), "{logs}");
assert!(!logs.contains("CONTENT_SECRET"), "{logs}");
}
#[test]
fn the_wire_payload_carries_the_stamp_only_when_given_one() {
let stamp = journal::new_stamp();
let hook = |event: &str| serde_json::from_str::<HookPayload>(&journal_hook(event)).unwrap();
for (event, op) in [("Stop", "observe"), ("SessionEnd", "end")] {
let hook_op = hook(event).to_hook_op(HookAgent::Claude, Some(5)).unwrap();
let (name, bare) = hook_op.wire(HookAgent::Claude, None).unwrap();
assert_eq!(name, op);
assert!(bare.get("stamp").is_none(), "{event}");
let (_, stamped) = hook_op.wire(HookAgent::Claude, Some(&stamp)).unwrap();
assert_eq!(stamped["stamp"]["seq"], stamp.seq.as_str());
assert_eq!(
serde_json::from_value::<EventStamp>(stamped["stamp"].clone()).unwrap(),
stamp
);
let mut without = stamped.clone();
without.as_object_mut().unwrap().remove("stamp");
assert_eq!(without, bare);
let record = hook_op.record(&stamp);
assert_eq!(record.stamp(), stamp);
assert_eq!(record.session_id, JOURNAL_ID);
}
}
use super::*;
use crate::sessions::SessionState;
#[derive(Parser)]
struct Wrapper {
#[command(subcommand)]
cmd: SessionsSubcommands,
}
fn parse(args: &[&str]) -> SessionsSubcommands {
let mut full = vec!["omni-dev"];
full.extend_from_slice(args);
Wrapper::try_parse_from(full).unwrap().cmd
}
#[test]
fn subcommands_parse() {
assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
assert!(matches!(
parse(&["install-hooks"]),
SessionsSubcommands::InstallHooks(_)
));
assert!(matches!(
parse(&["uninstall-hooks"]),
SessionsSubcommands::UninstallHooks(_)
));
assert!(matches!(
parse(&["install-wrapper"]),
SessionsSubcommands::InstallWrapper(_)
));
assert!(matches!(
parse(&["uninstall-wrapper"]),
SessionsSubcommands::UninstallWrapper(_)
));
}
#[test]
fn list_parses_flags() {
let cmd =
ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
serde_json::from_str::<HookPayload>(json_str)
.unwrap()
.to_op(HookAgent::Claude, None)
}
#[test]
fn hook_maps_lifecycle_events_to_observe() {
let (op, payload) = hook_op(
r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
)
.unwrap();
assert_eq!(op, "observe");
assert_eq!(payload["session_id"], "s1");
assert_eq!(payload["cwd"], "/p");
assert_eq!(payload["event"], "pre_tool_use");
}
#[test]
fn hook_maps_session_start_and_stop() {
assert_eq!(
hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
.unwrap()
.1["event"],
"session_start"
);
assert_eq!(
hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
.unwrap()
.1["event"],
"stop"
);
}
#[test]
fn hook_maps_session_end_to_end_op() {
let (op, payload) =
hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
.unwrap();
assert_eq!(op, "end");
assert_eq!(payload["session_id"], "s1");
assert_eq!(payload["reason"], "exit");
}
#[test]
fn hook_sends_the_agent_pid_on_both_ops_when_known() {
let hook = |json_str: &str, pid| {
serde_json::from_str::<HookPayload>(json_str)
.unwrap()
.to_op(HookAgent::Claude, pid)
.unwrap()
};
let start = r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#;
let end = r#"{"session_id":"s1","hook_event_name":"SessionEnd"}"#;
assert_eq!(hook(start, Some(42)).1["pid"], 42);
assert_eq!(hook(end, Some(42)).1["pid"], 42);
assert!(hook(start, None).1.get("pid").is_none());
assert!(hook(end, None).1.get("pid").is_none());
}
#[test]
fn agent_pid_treats_an_orphaned_hook_as_unknown() {
assert_eq!(agent_pid(4242), Some(4242));
assert_eq!(agent_pid(1), None);
assert_eq!(agent_pid(0), None);
}
#[test]
fn hook_classifies_notifications() {
let permission = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
)
.unwrap();
assert_eq!(permission.1["event"]["notification"], "permission_prompt");
let idle = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
)
.unwrap();
assert_eq!(idle.1["event"]["notification"], "idle_prompt");
let other = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
)
.unwrap();
assert_eq!(other.1["event"]["notification"], "other");
}
#[test]
fn hook_ignores_unknown_events_and_missing_session_id() {
assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"CwdChanged"}"#).is_none());
assert!(hook_op("{}").is_none());
}
fn codex_op(json_str: &str) -> Option<(&'static str, Value)> {
serde_json::from_str::<HookPayload>(json_str)
.unwrap()
.to_op(HookAgent::Codex, None)
}
fn codex_event(event: &str, tool: Option<&str>) -> Value {
let mut hook = json!({ "session_id": "c1", "hook_event_name": event });
if let Some(tool) = tool {
hook["tool_name"] = json!(tool);
}
codex_op(&hook.to_string()).unwrap().1["event"].clone()
}
#[test]
fn codex_hooks_are_tagged_from_the_first_event() {
let (op, payload) = codex_op(
r#"{"session_id":"c1","cwd":"/p","model":"gpt-5.5","hook_event_name":"SessionStart","source":"startup"}"#,
)
.unwrap();
assert_eq!(op, "observe");
assert_eq!(payload["agent"], "codex");
assert_eq!(payload["model"], "gpt-5.5");
assert_eq!(payload["event"], "session_start");
let claude = hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#).unwrap();
assert!(claude.1.get("agent").is_none());
}
#[test]
fn codex_events_map_onto_the_existing_session_events() {
assert_eq!(codex_event("UserPromptSubmit", None), "user_prompt_submit");
assert_eq!(codex_event("PreToolUse", Some("Bash")), "pre_tool_use");
assert_eq!(codex_event("PostToolUse", Some("Bash")), "post_tool_use");
assert_eq!(codex_event("Stop", None), "stop");
let compact =
json!({ "session_id": "c1", "hook_event_name": "SessionStart", "source": "compact" });
assert_eq!(
codex_op(&compact.to_string()).unwrap().1["event"],
"transcript_discovered",
"a compaction start is not a new session (#1946)"
);
assert_eq!(
codex_event("PermissionRequest", Some("Bash"))["notification"],
"permission_prompt"
);
assert_eq!(
codex_event("PreToolUse", Some("request_user_input"))["notification"],
"agent_needs_input"
);
assert_eq!(
codex_event("PostToolUse", Some("request_user_input")),
"post_tool_use"
);
assert_eq!(codex_event("Interrupt", None), "stop");
assert_eq!(codex_event("SubagentStart", None), "post_tool_use");
for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
assert_eq!(
codex_event(preserving, None),
"transcript_discovered",
"{preserving}"
);
}
assert!(codex_op(r#"{"session_id":"c1","hook_event_name":"Notification"}"#).is_none());
}
#[test]
fn codex_session_end_is_the_end_op_with_its_reason() {
let (op, payload) =
codex_op(r#"{"session_id":"c1","hook_event_name":"SessionEnd","reason":"other"}"#)
.unwrap();
assert_eq!(op, "end");
assert_eq!(payload["reason"], "other");
}
#[test]
fn the_claude_sink_ignores_codex_only_events() {
assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"Interrupt"}"#).is_none());
assert_eq!(
hook_op(r#"{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"request_user_input"}"#)
.unwrap()
.1["event"],
"pre_tool_use"
);
}
fn claude_event(event: &str) -> Value {
let hook = json!({ "session_id": "s1", "hook_event_name": event });
hook_op(&hook.to_string()).unwrap().1["event"].clone()
}
#[test]
fn claude_newer_events_map_onto_the_existing_session_events() {
assert_eq!(
claude_event("PermissionRequest")["notification"],
"permission_prompt"
);
assert_eq!(
claude_event("Elicitation")["notification"],
"agent_needs_input"
);
for working in [
"PostToolUseFailure",
"PermissionDenied",
"ElicitationResult",
"SubagentStart",
] {
assert_eq!(claude_event(working), "post_tool_use", "{working}");
}
assert_eq!(claude_event("StopFailure"), "stop");
for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
assert_eq!(
claude_event(preserving),
"transcript_discovered",
"{preserving}"
);
}
}
#[test]
fn every_installed_claude_event_is_mapped_by_the_sink() {
for HookSpec { event, .. } in HOOK_EVENTS {
let hook = json!({ "session_id": "s1", "hook_event_name": event });
assert!(hook_op(&hook.to_string()).is_some(), "{event} is unmapped");
}
}
#[test]
fn a_finished_subagent_or_compaction_leaves_an_idle_session_idle() {
for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
let hook = json!({ "session_id": "s1", "hook_event_name": preserving });
let (_, payload) = hook_op(&hook.to_string()).unwrap();
let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
assert_eq!(
SessionState::for_event(&event, Some(SessionState::Idle)),
SessionState::Idle,
"{preserving}"
);
assert_eq!(
SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
SessionState::WaitingForPermission,
"{preserving}"
);
}
}
fn observe_claude_hook(reg: &crate::sessions::SessionsRegistry, event: &str, id: Option<&str>) {
let hook = json!({"session_id": "s1", "hook_event_name": event, "agent_id": id});
let (_, payload) = hook_op(&hook.to_string()).unwrap();
reg.observe(serde_json::from_value(payload).unwrap());
}
#[test]
fn background_subagent_tools_preserve_parent_state_and_refresh_liveness() {
use crate::sessions::SessionsRegistry;
for (parent, expected) in [
("Stop", SessionState::Idle),
("UserPromptSubmit", SessionState::Working),
] {
let reg = SessionsRegistry::new();
observe_claude_hook(®, parent, None);
for event in [
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionDenied",
"SubagentStop",
] {
let before = reg.list()[0].last_seen;
observe_claude_hook(®, event, Some("child"));
assert_eq!(reg.list()[0].state, expected, "{parent}: {event}");
assert!(reg.list()[0].last_seen >= before);
}
}
}
#[test]
fn subagent_completion_releases_only_its_own_wait_to_parent_state() {
use crate::sessions::SessionsRegistry;
for (parent, expected) in [
("Stop", SessionState::Idle),
("UserPromptSubmit", SessionState::Working),
] {
for (wait, waiting, completions) in [
(
"PermissionRequest",
SessionState::WaitingForPermission,
vec![
"PostToolUse",
"PostToolUseFailure",
"PermissionDenied",
"SubagentStop",
],
),
(
"Elicitation",
SessionState::WaitingForInput,
vec!["ElicitationResult", "SubagentStop"],
),
] {
for completion in completions {
let reg = SessionsRegistry::new();
observe_claude_hook(®, parent, None);
observe_claude_hook(®, wait, Some("a"));
observe_claude_hook(®, "PostToolUse", Some("b"));
observe_claude_hook(®, "PreToolUse", Some("a"));
observe_claude_hook(®, "Notification", Some("a"));
assert_eq!(reg.list()[0].state, waiting);
observe_claude_hook(®, completion, Some("a"));
assert_eq!(
reg.list()[0].state,
expected,
"{parent}: {wait}/{completion}"
);
}
}
}
}
#[test]
fn overlapping_subagent_waits_resolve_independently() {
let reg = crate::sessions::SessionsRegistry::new();
observe_claude_hook(®, "Stop", None);
observe_claude_hook(®, "PermissionRequest", Some("a"));
observe_claude_hook(®, "Elicitation", Some("b"));
assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
observe_claude_hook(®, "PostToolUse", Some("a"));
assert_eq!(reg.list()[0].state, SessionState::WaitingForInput);
observe_claude_hook(®, "ElicitationResult", Some("b"));
assert_eq!(reg.list()[0].state, SessionState::Idle);
}
#[test]
fn first_subagent_wait_and_parent_wait_release_remain_supported() {
let reg = crate::sessions::SessionsRegistry::new();
observe_claude_hook(®, "PermissionRequest", Some("a"));
assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
observe_claude_hook(®, "PostToolUse", None);
assert_eq!(reg.list()[0].state, SessionState::Working);
observe_claude_hook(®, "Stop", None);
observe_claude_hook(®, "PostToolUse", Some("a"));
assert_eq!(reg.list()[0].state, SessionState::Idle);
observe_claude_hook(®, "PermissionRequest", None);
observe_claude_hook(®, "PostToolUse", Some("b"));
assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
}
#[test]
fn subagent_notifications_survive_passive_evidence_and_end_ignores_late_tools() {
let reg = crate::sessions::SessionsRegistry::new();
observe_claude_hook(®, "Stop", None);
let hook = json!({"session_id":"s1", "hook_event_name":"Notification", "agent_id":"a", "notification_type":"permission_prompt"});
let (_, payload) = hook_op(&hook.to_string()).unwrap();
reg.observe(serde_json::from_value(payload).unwrap());
for event in [
SessionEvent::TranscriptDiscovered,
SessionEvent::TranscriptGrew,
] {
let req: ObserveRequest =
serde_json::from_value(json!({"session_id":"s1", "event":event})).unwrap();
reg.observe(req);
assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
}
observe_claude_hook(®, "PostToolUse", Some("a"));
assert_eq!(reg.list()[0].state, SessionState::Idle);
reg.end("s1", None, None);
let before = reg.list()[0].last_seen;
observe_claude_hook(®, "PostToolUse", Some("a"));
assert_eq!(reg.list()[0].state, SessionState::Ended);
assert_eq!(reg.list()[0].last_seen, before);
}
#[test]
fn subagent_identity_is_optional_and_claude_only() {
for id in [None, Some(""), Some(" ")] {
let hook = json!({"session_id":"s1", "hook_event_name":"PostToolUse", "agent_id":id});
let payload = hook_op(&hook.to_string()).unwrap().1;
assert!(payload.get("agent_id").is_none());
let request: ObserveRequest = serde_json::from_value(payload).unwrap();
assert!(request.agent_id.is_none());
}
let hook = json!({"session_id":"c1", "hook_event_name":"PostToolUse", "agent_id":"child"});
assert!(codex_op(&hook.to_string())
.unwrap()
.1
.get("agent_id")
.is_none());
}
#[test]
fn a_codex_subagent_stop_or_compaction_leaves_an_idle_session_idle() {
for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
let hook = json!({ "session_id": "c1", "hook_event_name": preserving });
let (_, payload) = codex_op(&hook.to_string()).unwrap();
let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
assert_eq!(
SessionState::for_event(&event, Some(SessionState::Idle)),
SessionState::Idle,
"{preserving}"
);
assert_eq!(
SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
SessionState::WaitingForPermission,
"{preserving}"
);
}
}
#[test]
fn a_compaction_session_start_preserves_state() {
let event_for = |source: Option<&str>| {
let mut hook = json!({ "session_id": "s1", "hook_event_name": "SessionStart" });
if let Some(source) = source {
hook["source"] = json!(source);
}
let (_, payload) = hook_op(&hook.to_string()).unwrap();
serde_json::from_value::<SessionEvent>(payload["event"].clone()).unwrap()
};
let compact = event_for(Some("compact"));
assert_eq!(compact, SessionEvent::TranscriptDiscovered);
for current in [SessionState::Working, SessionState::Idle] {
assert_eq!(SessionState::for_event(&compact, Some(current)), current);
}
for start in [Some("startup"), Some("resume"), Some("clear"), None] {
assert_eq!(event_for(start), SessionEvent::SessionStart, "{start:?}");
}
}
#[test]
fn hook_classifies_notifications_by_type_before_message() {
let typed = |ty: &str, msg: &str| {
let hook = json!({
"session_id": "s1",
"hook_event_name": "Notification",
"notification_type": ty,
"message": msg,
});
hook_op(&hook.to_string()).unwrap().1["event"]["notification"].clone()
};
assert_eq!(
typed("permission_prompt", "something else"),
"permission_prompt"
);
assert_eq!(typed("idle_prompt", "Please approve"), "idle_prompt");
assert_eq!(typed("elicitation_dialog", "x"), "agent_needs_input");
assert_eq!(typed("auth_success", "You can now allow access"), "other");
let untyped = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission"}"#,
)
.unwrap();
assert_eq!(untyped.1["event"]["notification"], "permission_prompt");
}
#[test]
fn hook_parses_the_agent_flag() {
match parse(&["hook", "--agent", "codex"]) {
SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Codex),
_ => panic!("expected hook"),
}
match parse(&["hook"]) {
SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Claude),
_ => panic!("expected hook"),
}
match parse(&["install-hooks", "--codex-home", "/x/codex"]) {
SessionsSubcommands::InstallHooks(cmd) => {
assert_eq!(cmd.codex_home, Some(PathBuf::from("/x/codex")));
}
_ => panic!("expected install-hooks"),
}
}
const TAGGED: &str = "/bin/omni-dev sessions hook --agent codex";
fn layout(hooks: &Value, event: &str) -> Vec<Vec<String>> {
hooks["hooks"][event]
.as_array()
.map(|groups| {
groups
.iter()
.map(|g| {
g["hooks"]
.as_array()
.unwrap()
.iter()
.map(|h| h["command"].as_str().unwrap().to_string())
.collect()
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn is_sessions_sink_matches_any_omni_dev_sink_and_nothing_else() {
assert!(is_sessions_sink(
"/Users/me/.cargo/bin/omni-dev sessions hook"
));
assert!(is_sessions_sink("omni-dev sessions hook"));
assert!(is_sessions_sink(TAGGED));
assert!(is_sessions_sink("omni-dev sessions hook --socket /x"));
assert!(is_sessions_sink(
"/old/omni-dev sessions hook --agent codex"
));
assert!(!is_sessions_sink("/bin/other sessions hook"));
assert!(!is_sessions_sink("omni-dev sessions list"));
assert!(!is_sessions_sink("omni-dev sessions hooks"));
assert!(!is_sessions_sink("my-omni-dev sessions hook"));
}
#[test]
fn codex_install_rewrites_a_moved_binarys_tagged_entry_in_place() {
let mut hooks = json!({ "hooks": { "Stop": [
{ "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook --agent codex" }] },
{ "hooks": [{ "type": "command", "command": "omni-dev sessions hook --socket /x" }] },
{ "hooks": [{ "type": "command", "command": "mine" }] }
] } });
assert_eq!(
replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
2
);
assert_eq!(
layout(&hooks, "Stop"),
vec![vec![TAGGED], vec![TAGGED], vec!["mine"]]
);
merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
assert_eq!(layout(&hooks, "Stop").len(), 3);
}
#[test]
fn codex_install_replaces_untagged_sinks_in_place_and_never_reorders() {
let mut hooks = json!({ "hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard" }] },
{ "matcher": "*", "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook" }] },
{ "hooks": [{ "type": "command", "command": "audit" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] }
]
}});
let replaced = replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink);
assert_eq!(replaced, 2);
let added = merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
assert_eq!(added, CODEX_HOOK_EVENTS.len() - 2);
assert_eq!(
layout(&hooks, "PreToolUse"),
vec![vec!["guard"], vec![TAGGED], vec!["audit"]]
);
assert_eq!(hooks["hooks"]["PreToolUse"][1]["matcher"], "*");
assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
assert_eq!(hooks["hooks"]["PostToolUse"][0]["matcher"], "*");
assert_eq!(hooks["hooks"]["Interrupt"][0]["hooks"][0]["timeout"], 3);
assert!(hooks["hooks"]["Stop"][0]["hooks"][0]
.get("timeout")
.is_none());
assert!(hooks["hooks"].get("Notification").is_none());
assert_eq!(
replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
0
);
assert_eq!(merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS), 0);
}
#[test]
fn codex_uninstall_never_shifts_a_surviving_group() {
let mut hooks = json!({ "hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": TAGGED }] },
{ "hooks": [{ "type": "command", "command": "user-a" }] },
{ "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
{ "hooks": [{ "type": "command", "command": "user-b" }] },
{ "hooks": [{ "type": "command", "command": TAGGED }] }
],
"PreToolUse": [
{ "hooks": [
{ "type": "command", "command": TAGGED },
{ "type": "command", "command": "shared" }
] }
],
"Interrupt": [
{ "hooks": [] },
{ "hooks": [{ "type": "command", "command": TAGGED }] }
],
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": "keep" }] }
]
}});
let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
assert_eq!(removal.removed, 5);
assert!(removal.shifted);
let stop = hooks["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 4);
assert_eq!(stop[0], json!({ "hooks": [] }));
assert_eq!(layout(&hooks, "Stop")[1], vec!["user-a"]);
assert_eq!(stop[2], json!({ "hooks": [] }));
assert_eq!(layout(&hooks, "Stop")[3], vec!["user-b"]);
assert_eq!(layout(&hooks, "PreToolUse"), vec![vec!["shared"]]);
assert!(hooks["hooks"].get("Interrupt").is_none());
assert_eq!(layout(&hooks, "PostToolUse"), vec![vec!["keep"]]);
assert_eq!(
remove_hooks_stable(&mut hooks, is_sessions_sink),
Removal::default()
);
}
#[test]
fn remove_hooks_stable_does_not_report_a_shift_for_a_trailing_hook() {
let mut hooks = json!({ "hooks": { "Stop": [ { "hooks": [
{ "type": "command", "command": "shared" },
{ "type": "command", "command": TAGGED }
] } ] } });
let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
assert_eq!(
removal,
Removal {
removed: 1,
shifted: false
}
);
assert_eq!(layout(&hooks, "Stop"), vec![vec!["shared"]]);
}
#[test]
fn remove_hooks_stable_tolerates_malformed_shapes() {
let none = Removal::default();
assert_eq!(remove_hooks_stable(&mut json!([]), |_| true), none);
assert_eq!(remove_hooks_stable(&mut json!({}), |_| true), none);
let mut odd = json!({ "hooks": { "Stop": 5, "Start": [ { "matcher": "x" } ] } });
assert_eq!(remove_hooks_stable(&mut odd, |_| true), none);
assert_eq!(odd["hooks"]["Stop"], 5);
assert_eq!(
replace_stale_sinks(&mut json!({}), TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
0
);
assert_eq!(
replace_stale_sinks(
&mut json!({ "hooks": { "Stop": 5 } }),
TAGGED,
CODEX_HOOK_EVENTS,
is_sessions_sink
),
0
);
}
#[test]
fn codex_is_present_via_its_home_or_a_codex_executable_on_path() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("codex-home");
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let path = std::env::join_paths([&bin]).unwrap();
assert!(!codex_is_present(&home, Some(&path)));
assert!(!codex_is_present(&home, None));
let exe = bin.join("codex");
std::fs::write(&exe, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(codex_is_present(&home, Some(&path)));
std::fs::create_dir_all(&home).unwrap();
assert!(codex_is_present(&home, None));
}
#[test]
fn codex_hook_command_is_the_sink_tagged_codex() {
assert_eq!(
codex_hook_command(),
format!("{} --agent codex", hook_command())
);
assert!(codex_hook_command().ends_with(" sessions hook --agent codex"));
}
#[test]
fn codex_install_and_uninstall_round_trip_through_the_file() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("codex-home");
std::fs::create_dir_all(&home).unwrap();
let file = home.join("hooks.json");
std::fs::write(
&file,
json!({ "hooks": { "Stop": [
{ "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
{ "hooks": [{ "type": "command", "command": "mine" }] }
] } })
.to_string(),
)
.unwrap();
install_codex_hooks(Some(home.clone())).unwrap();
let tagged = codex_hook_command();
let installed = read_settings(&file).unwrap();
assert_eq!(
layout(&installed, "Stop"),
vec![vec![tagged.clone()], vec!["mine".to_string()]]
);
assert_eq!(layout(&installed, "Interrupt"), vec![vec![tagged]]);
install_codex_hooks(Some(home.clone())).unwrap();
assert_eq!(read_settings(&file).unwrap(), installed);
uninstall_codex_hooks(Some(home.clone())).unwrap();
let removed = read_settings(&file).unwrap();
assert_eq!(
removed,
json!({ "hooks": { "Stop": [ { "hooks": [] }, { "hooks": [{ "type": "command", "command": "mine" }] } ] } })
);
uninstall_codex_hooks(Some(home)).unwrap();
uninstall_codex_hooks(Some(tmp.path().join("absent"))).unwrap();
}
#[test]
fn install_codex_hooks_via_the_default_home_skips_absent_and_writes_present() {
let _guard = PI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let saved = ["CODEX_HOME", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)));
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("codex-home");
let empty_bin = tmp.path().join("bin");
std::fs::create_dir_all(&empty_bin).unwrap();
std::env::set_var("CODEX_HOME", &home);
std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
assert_eq!(default_codex_home().unwrap(), home);
install_codex_hooks(None).unwrap();
assert!(
!home.exists(),
"install must not create an absent Codex home"
);
uninstall_codex_hooks(None).unwrap();
std::fs::create_dir_all(&home).unwrap();
install_codex_hooks(None).unwrap();
assert!(home.join("hooks.json").exists());
uninstall_codex_hooks(None).unwrap();
assert_eq!(
read_settings(&home.join("hooks.json")).unwrap(),
json!({ "hooks": {} })
);
std::env::set_var("CODEX_HOME", "");
std::env::set_var("HOME", "/home/tester");
assert_eq!(
default_codex_home().unwrap(),
PathBuf::from("/home/tester/.codex")
);
for (k, v) in saved {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
#[test]
fn classify_notification_covers_cases() {
assert_eq!(
classify_notification(None, Some("Please approve this")),
NotificationKind::PermissionPrompt
);
assert_eq!(
classify_notification(None, Some("Claude is idle")),
NotificationKind::IdlePrompt
);
assert_eq!(classify_notification(None, None), NotificationKind::Other);
assert_eq!(
classify_notification(Some("idle_prompt"), None),
NotificationKind::IdlePrompt
);
}
#[test]
fn merge_hooks_is_idempotent_and_additive() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
]
},
"model": "sonnet"
});
let cmd = "/usr/bin/omni-dev sessions hook";
let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
assert_eq!(added, HOOK_EVENTS.len());
for HookSpec { event, .. } in HOOK_EVENTS {
let groups = settings["hooks"][event].as_array().unwrap();
assert!(
groups.iter().any(|g| group_has_command(g, cmd)),
"missing under {event}"
);
}
let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
assert_eq!(settings["model"], "sonnet");
assert_eq!(merge_hooks(&mut settings, cmd, HOOK_EVENTS), 0);
}
#[test]
fn reinstalling_over_the_original_seven_events_adds_only_the_new_ones() {
let cmd = "/usr/bin/omni-dev sessions hook";
let original = &HOOK_EVENTS[..7];
assert_eq!(original.last().unwrap().event, "SessionEnd");
let mut settings = json!({});
merge_hooks(&mut settings, cmd, original);
let before = settings.clone();
let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
assert_eq!(added, HOOK_EVENTS.len() - 7);
for HookSpec { event, .. } in original {
assert_eq!(settings["hooks"][event], before["hooks"][event], "{event}");
}
assert_eq!(settings["hooks"]["PermissionRequest"][0]["matcher"], "*");
assert!(settings["hooks"]["StopFailure"][0].get("matcher").is_none());
assert_eq!(remove_hooks(&mut settings, |c| c == cmd), HOOK_EVENTS.len());
assert!(settings["hooks"].as_object().unwrap().is_empty());
}
#[test]
fn merge_then_remove_round_trips_and_preserves_others() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
]
}
});
let cmd = "/usr/bin/omni-dev sessions hook";
merge_hooks(&mut settings, cmd, HOOK_EVENTS);
let removed = remove_hooks(&mut settings, |c| c == cmd);
assert_eq!(removed, HOOK_EVENTS.len());
for HookSpec { event, .. } in HOOK_EVENTS {
let empty = settings["hooks"]
.get(event)
.and_then(Value::as_array)
.is_none_or(|g| g.iter().all(|g| !group_has_command(g, cmd)));
assert!(empty, "our hook survived under {event}");
}
let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
}
const OLD: &str = "/old/bin/omni-dev sessions hook";
const NEW: &str = "/new/bin/omni-dev sessions hook";
fn sinks_under(settings: &Value, event: &str) -> Vec<String> {
settings["hooks"][event]
.as_array()
.into_iter()
.flatten()
.filter_map(|g| g["hooks"].as_array())
.flatten()
.filter_map(|h| h["command"].as_str().map(str::to_string))
.collect()
}
#[test]
fn is_stale_claude_sink_matches_only_uncustomised_sinks() {
assert!(is_stale_claude_sink(OLD));
assert!(is_stale_claude_sink("omni-dev sessions hook"));
assert!(is_stale_claude_sink(
"/x/omni-dev sessions hook --agent claude"
));
assert!(is_stale_claude_sink(
"/x/omni-dev sessions hook --agent=claude"
));
assert!(!is_stale_claude_sink(
"/x/omni-dev sessions hook --socket /s"
));
assert!(!is_stale_claude_sink(
"/x/omni-dev sessions hook --agent codex"
));
assert!(!is_stale_claude_sink(
"/x/omni-dev sessions hook --agent claude --socket /s"
));
assert!(!is_stale_claude_sink("/x/other sessions hook"));
assert!(!is_stale_claude_sink("/x/omni-dev sessions list"));
}
#[test]
fn install_after_the_binary_moves_rewrites_the_old_sink_in_place() {
let mut settings = json!({ "model": "sonnet" });
merge_hooks(&mut settings, OLD, HOOK_EVENTS);
settings["hooks"]["Stop"].as_array_mut().unwrap().insert(
0,
json!({ "hooks": [{ "type": "command", "command": "other" }] }),
);
let sync = sync_claude_hooks(&mut settings, NEW);
assert_eq!(
sync,
ClaudeSync {
replaced: HOOK_EVENTS.len(),
..ClaudeSync::default()
}
);
for HookSpec { event, .. } in HOOK_EVENTS {
assert!(
sinks_under(&settings, event).contains(&NEW.to_string()),
"{event}"
);
assert!(
!sinks_under(&settings, event).contains(&OLD.to_string()),
"{event}"
);
}
assert_eq!(sinks_under(&settings, "Stop"), ["other", NEW]);
assert_eq!(settings["model"], "sonnet");
assert!(sync_claude_hooks(&mut settings, NEW).is_noop());
}
#[test]
fn install_heals_settings_that_already_hold_both_sinks() {
let mut settings = json!({});
merge_hooks(&mut settings, OLD, HOOK_EVENTS);
merge_hooks(&mut settings, NEW, HOOK_EVENTS);
let sync = sync_claude_hooks(&mut settings, NEW);
assert_eq!(sync.replaced, HOOK_EVENTS.len());
assert_eq!(sync.deduped, HOOK_EVENTS.len());
assert_eq!(sync.added, 0);
for HookSpec { event, .. } in HOOK_EVENTS {
assert_eq!(sinks_under(&settings, event), [NEW], "{event}");
}
assert!(sync_claude_hooks(&mut settings, NEW).is_noop());
}
#[test]
fn install_adds_new_events_while_rewriting_an_old_seven_event_install() {
let mut settings = json!({});
merge_hooks(&mut settings, OLD, &HOOK_EVENTS[..7]);
let sync = sync_claude_hooks(&mut settings, NEW);
assert_eq!(sync.replaced, 7);
assert_eq!(sync.added, HOOK_EVENTS.len() - 7);
for HookSpec { event, .. } in HOOK_EVENTS {
assert_eq!(sinks_under(&settings, event), [NEW], "{event}");
}
}
#[test]
fn install_and_uninstall_preserve_a_customised_sink() {
let custom = "/old/bin/omni-dev sessions hook --socket /my/d.sock";
let mut settings = json!({
"hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": custom }] }] }
});
let sync = sync_claude_hooks(&mut settings, NEW);
assert_eq!(sync.replaced, 0);
assert_eq!(sync.customised, 1);
assert_eq!(sinks_under(&settings, "Stop"), [custom, NEW]);
let removed = remove_hooks(&mut settings, |c| c == NEW || is_stale_claude_sink(c));
assert_eq!(removed, HOOK_EVENTS.len());
assert_eq!(sinks_under(&settings, "Stop"), [custom]);
assert_eq!(count_customised_sinks(&settings, NEW), 1);
assert!(customised_note(1).contains("customised"));
}
#[test]
fn uninstall_from_a_new_path_removes_the_old_path_sink() {
let mut settings = json!({});
merge_hooks(&mut settings, OLD, HOOK_EVENTS);
merge_hooks(&mut settings, NEW, HOOK_EVENTS);
let removed = remove_hooks(&mut settings, |c| c == NEW || is_stale_claude_sink(c));
assert_eq!(removed, 2 * HOOK_EVENTS.len());
assert!(settings["hooks"].as_object().unwrap().is_empty());
}
#[test]
fn is_stale_claude_sink_rejects_wrapped_launchers() {
for wrapped in [
"FOO=1 /x/omni-dev sessions hook",
"sudo -u me /usr/bin/omni-dev sessions hook",
"cd /repo && /x/omni-dev sessions hook",
] {
assert!(is_sessions_sink(wrapped), "{wrapped}");
assert!(!is_stale_claude_sink(wrapped), "{wrapped}");
assert!(is_customised_sink(wrapped, NEW), "{wrapped}");
}
assert!(!is_sessions_sink("nice -n 10 omni-dev sessions hook"));
assert!(!is_customised_sink(NEW, NEW));
assert!(!is_customised_sink(OLD, NEW));
}
#[test]
fn dedupe_command_keys_on_the_matcher_and_leaves_untouched_events_alone() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": NEW }] },
{ "matcher": "*", "hooks": [{ "type": "command", "command": NEW }] },
{ "matcher": "*", "hooks": [{ "type": "command", "command": NEW }] }
],
"Stop": [],
"Notification": [{ "hooks": [] }]
}
});
assert_eq!(dedupe_command(&mut settings, NEW), 1);
assert_eq!(sinks_under(&settings, "PreToolUse"), [NEW, NEW]);
assert_eq!(settings["hooks"]["PreToolUse"][1]["matcher"], "*");
assert_eq!(settings["hooks"]["Stop"], json!([]));
assert_eq!(settings["hooks"]["Notification"], json!([{ "hooks": [] }]));
}
#[test]
fn install_command_execute_rewrites_an_old_sink_and_a_rerun_changes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let mut old = json!({});
merge_hooks(&mut old, OLD, HOOK_EVENTS);
merge_hooks(
&mut old,
"/x/omni-dev sessions hook --socket /s",
&HOOK_EVENTS[..1],
);
write_settings(&path, &old).unwrap();
let install = || {
InstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
};
install();
let current = hook_command();
let settings = read_settings(&path).unwrap();
for HookSpec { event, .. } in HOOK_EVENTS {
let sinks = sinks_under(&settings, event);
assert!(sinks.contains(¤t), "{event}: {sinks:?}");
assert!(!sinks.contains(&OLD.to_string()), "{event}: {sinks:?}");
}
assert_eq!(count_customised_sinks(&settings, ¤t), 1);
let after_first = std::fs::read_to_string(&path).unwrap();
install();
assert_eq!(std::fs::read_to_string(&path).unwrap(), after_first);
let mut stale = read_settings(&path).unwrap();
merge_hooks(&mut stale, OLD, HOOK_EVENTS);
write_settings(&path, &stale).unwrap();
UninstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
let settings = read_settings(&path).unwrap();
assert_eq!(
sinks_under(&settings, HOOK_EVENTS[0].event),
["/x/omni-dev sessions hook --socket /s"]
);
assert_eq!(count_customised_sinks(&settings, ¤t), 1);
}
#[test]
fn install_command_execute_reports_and_removes_a_duplicate_sink() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let mut both = json!({});
merge_hooks(&mut both, OLD, HOOK_EVENTS);
merge_hooks(&mut both, &hook_command(), HOOK_EVENTS);
write_settings(&path, &both).unwrap();
InstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
let settings = read_settings(&path).unwrap();
for HookSpec { event, .. } in HOOK_EVENTS {
assert_eq!(sinks_under(&settings, event), [hook_command()], "{event}");
}
}
#[test]
fn dedupe_command_keeps_the_first_and_tolerates_odd_shapes() {
let mut settings = json!({
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": NEW }] },
{ "matcher": "Bash" },
{ "hooks": [{ "type": "command", "command": "other" },
{ "type": "command", "command": NEW }] }
],
"Notification": 5
}
});
assert_eq!(dedupe_command(&mut settings, NEW), 1);
assert_eq!(sinks_under(&settings, "Stop"), [NEW, "other"]);
assert_eq!(settings["hooks"]["Stop"][1], json!({ "matcher": "Bash" }));
assert_eq!(settings["hooks"]["Notification"], 5);
assert_eq!(dedupe_command(&mut json!({}), NEW), 0);
}
#[test]
fn remove_hooks_prunes_empty_events_entirely() {
let mut settings = json!({});
let cmd = "cmd sessions hook";
merge_hooks(&mut settings, cmd, HOOK_EVENTS);
remove_hooks(&mut settings, |c| c == cmd);
let hooks = settings["hooks"].as_object().unwrap();
assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
}
#[test]
fn install_uninstall_via_files_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let mut settings = read_settings(&path).unwrap();
merge_hooks(&mut settings, "cmd sessions hook", HOOK_EVENTS);
write_settings(&path, &settings).unwrap();
assert!(path.exists());
let reloaded = read_settings(&path).unwrap();
assert!(reloaded["hooks"]["Stop"].is_array());
let mut settings = read_settings(&path).unwrap();
remove_hooks(&mut settings, |c| c == "cmd sessions hook");
write_settings(&path, &settings).unwrap();
let reloaded = read_settings(&path).unwrap();
assert!(reloaded["hooks"].as_object().unwrap().is_empty());
}
#[test]
fn read_settings_rejects_non_json() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "not json {").unwrap();
let err = read_settings(&path).unwrap_err();
assert!(err.to_string().contains("not valid JSON"), "{err}");
}
#[test]
fn read_settings_handles_empty_and_non_object() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, " \n").unwrap();
assert_eq!(read_settings(&path).unwrap(), json!({}));
std::fs::write(&path, "[1, 2, 3]").unwrap();
let err = read_settings(&path).unwrap_err();
assert!(err.to_string().contains("not a JSON object"), "{err}");
}
#[test]
fn hook_command_targets_sessions_hook() {
assert!(hook_command().ends_with("sessions hook"));
}
#[test]
fn render_sessions_handles_empty() {
assert_eq!(
render_sessions(&json!({ "sessions": [] })),
"No active agent sessions."
);
assert_eq!(render_sessions(&json!({})), "No active agent sessions.");
}
#[test]
fn render_sessions_renders_rows_and_source() {
let result = json!({ "sessions": [{
"session_id": "s1",
"state": "working",
"source": { "kind": "vs_code", "window_key": "w1" },
"repo": "omni-dev",
"cwd": "/home/me/omni-dev",
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_sessions(&result);
assert!(table.contains("working"), "{table}");
assert!(table.contains("vscode"), "{table}");
assert!(table.contains("omni-dev"), "{table}");
assert!(table.contains("claude"), "{table}");
assert_eq!(table.lines().count(), 2, "{table}");
}
#[test]
fn render_sessions_strips_control_bytes() {
let result = json!({ "sessions": [{
"session_id": "s1",
"state": "wor\x1b[31mking",
"source": { "kind": "terminal" },
"repo": "ev\x07il",
"cwd": "/tmp/a\rb",
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_sessions(&result);
assert!(
!table.contains(|c: char| c.is_control() && c != '\n'),
"{table:?}"
);
assert_eq!(table.lines().count(), 2, "{table:?}");
}
#[test]
fn source_label_maps_kinds() {
assert_eq!(
source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
"vscode"
);
assert_eq!(
source_label(&json!({ "source": { "kind": "terminal" } })),
"terminal"
);
assert_eq!(source_label(&json!({})), "terminal");
}
#[test]
fn reply_payload_unwraps_ok_and_maps_errors() {
assert_eq!(
reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
json!({ "a": 1 })
);
let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
}
#[test]
fn render_pi_extension_bakes_in_the_socket_as_a_json_string() {
let rendered = render_pi_extension(Path::new("/tmp/we \"ird\"/daemon.sock"));
assert!(
!rendered.contains(PI_SOCKET_PLACEHOLDER),
"placeholder left behind"
);
assert!(
rendered.contains(r#"const SOCKET: string = "/tmp/we \"ird\"/daemon.sock";"#),
"socket not escaped as a JSON string"
);
assert!(rendered.starts_with(pi_extension_marker()));
}
#[test]
fn the_template_reports_only_state_and_tags_the_agent() {
for needle in [
r#""working""#,
r#""idle""#,
r#""waiting_for_input""#,
r#"agent: "pi""#,
"stream_state: next",
r#"send("end""#,
r#""agent_settled""#,
r#""ui_prompt_start""#,
r#""session_shutdown""#,
] {
assert!(
PI_EXTENSION_TEMPLATE.contains(needle),
"template lacks {needle}"
);
}
assert!(!PI_EXTENSION_TEMPLATE.contains("waiting_for_permission"));
assert!(!PI_EXTENSION_TEMPLATE.contains("\"starting\""));
assert_eq!(
PI_EXTENSION_TEMPLATE.matches(PI_SOCKET_PLACEHOLDER).count(),
1
);
}
#[test]
fn pi_is_present_via_its_agent_dir_or_a_pi_executable_on_path() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let agent = tmp.path().join("agent");
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let path = std::env::join_paths([bin.clone()]).unwrap();
assert!(!pi_is_present(&agent, Some(&path)));
assert!(!pi_is_present(&agent, None));
let pi = bin.join("pi");
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(!pi_is_present(&agent, Some(&path)));
std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(pi_is_present(&agent, Some(&path)));
std::fs::create_dir_all(&agent).unwrap();
assert!(pi_is_present(&agent, None));
}
#[test]
fn write_pi_extension_is_idempotent_and_updates_its_own_file() {
let tmp = tempfile::tempdir().unwrap();
let v1 = render_pi_extension(Path::new("/a.sock"));
let (file, outcome) = write_pi_extension(tmp.path(), &v1).unwrap();
assert_eq!(outcome, PiInstall::Written);
assert_eq!(file, tmp.path().join("extensions").join(PI_EXTENSION_NAME));
assert_eq!(std::fs::read_to_string(&file).unwrap(), v1);
assert_eq!(
write_pi_extension(tmp.path(), &v1).unwrap().1,
PiInstall::Unchanged
);
let v2 = render_pi_extension(Path::new("/b.sock"));
assert_eq!(
write_pi_extension(tmp.path(), &v2).unwrap().1,
PiInstall::Written
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), v2);
}
#[test]
fn pi_extension_install_and_remove_leave_foreign_files_alone() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("extensions");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("someone-elses.ts");
std::fs::write(&other, "export default () => {};\n").unwrap();
let ours = dir.join(PI_EXTENSION_NAME);
std::fs::write(&ours, "// hand-written\n").unwrap();
let err = write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s")))
.unwrap_err()
.to_string();
assert!(err.contains("not written by omni-dev"), "{err}");
assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
assert_eq!(std::fs::read_to_string(&ours).unwrap(), "// hand-written\n");
std::fs::remove_file(&ours).unwrap();
write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s"))).unwrap();
assert_eq!(remove_pi_extension(tmp.path()).unwrap(), Some(ours.clone()));
assert!(!ours.exists());
assert!(other.exists());
assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
assert_eq!(remove_pi_extension(&tmp.path().join("nope")).unwrap(), None);
}
#[test]
fn hook_commands_parse_the_pi_agent_dir_flag() {
match parse(&["install-hooks", "--pi-agent-dir", "/x/agent"]) {
SessionsSubcommands::InstallHooks(cmd) => {
assert_eq!(cmd.pi_agent_dir, Some(PathBuf::from("/x/agent")));
}
_ => panic!("expected install-hooks"), }
match parse(&["uninstall-hooks"]) {
SessionsSubcommands::UninstallHooks(cmd) => assert_eq!(cmd.pi_agent_dir, None),
_ => panic!("expected uninstall-hooks"), }
}
static PI_ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
fn snapshot_pi_env() -> [(&'static str, Option<std::ffi::OsString>); 3] {
["PI_CODING_AGENT_DIR", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)))
}
fn restore_pi_env(snap: [(&'static str, Option<std::ffi::OsString>); 3]) {
for (k, v) in snap {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
#[test]
fn default_pi_agent_dir_honors_the_env_var_and_falls_back_to_home() {
let _guard = PI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let snap = snapshot_pi_env();
std::env::set_var("PI_CODING_AGENT_DIR", "/explicit/agent");
assert_eq!(
default_pi_agent_dir().unwrap(),
PathBuf::from("/explicit/agent")
);
std::env::set_var("PI_CODING_AGENT_DIR", "");
std::env::set_var("HOME", "/home/tester");
assert_eq!(
default_pi_agent_dir().unwrap(),
PathBuf::from("/home/tester/.pi/agent")
);
std::env::remove_var("PI_CODING_AGENT_DIR");
assert_eq!(
default_pi_agent_dir().unwrap(),
PathBuf::from("/home/tester/.pi/agent")
);
restore_pi_env(snap);
}
#[test]
fn install_pi_extension_via_the_default_dir_skips_absent_and_writes_present() {
let _guard = PI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let snap = snapshot_pi_env();
let tmp = tempfile::tempdir().unwrap();
let agent_dir = tmp.path().join("nonexistent-agent");
let empty_bin = tmp.path().join("bin");
std::fs::create_dir_all(&empty_bin).unwrap();
std::env::set_var("PI_CODING_AGENT_DIR", &agent_dir);
std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
install_pi_extension(None).unwrap();
assert!(
!agent_dir.exists(),
"install must not create the agent dir when pi is absent"
);
uninstall_pi_extension(None).unwrap();
std::fs::create_dir_all(&agent_dir).unwrap();
install_pi_extension(None).unwrap();
let extension = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
assert!(extension.exists());
uninstall_pi_extension(None).unwrap();
assert!(!extension.exists());
restore_pi_env(snap);
}
#[test]
fn agent_label_defaults_to_claude() {
assert_eq!(agent_label(&json!({ "agent": "pi" })), "pi");
assert_eq!(agent_label(&json!({ "agent": "codex" })), "codex");
assert_eq!(agent_label(&json!({ "agent": "claude" })), "claude");
assert_eq!(agent_label(&json!({})), "claude");
}
#[test]
fn install_then_uninstall_command_execute_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
InstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
let extension = tmp
.path()
.join("pi-agent/extensions")
.join(PI_EXTENSION_NAME);
assert!(extension.exists());
InstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
UninstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
assert!(read_settings(&path).unwrap()["hooks"]
.as_object()
.unwrap()
.is_empty());
assert!(!extension.exists());
}
fn wrapper_commands(tmp: &Path) -> (InstallWrapperCommand, UninstallWrapperCommand) {
let settings = tmp.join("settings.json");
let shim = tmp.join("bin").join("claude-wrap");
(
InstallWrapperCommand {
settings: Some(settings.clone()),
shim: Some(shim.clone()),
},
UninstallWrapperCommand {
settings: Some(settings),
shim: Some(shim),
},
)
}
#[test]
fn install_wrapper_writes_an_executable_shim_and_sets_the_setting() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (install, _) = wrapper_commands(tmp.path());
let shim = install.shim.clone().unwrap();
let settings = install.settings.clone().unwrap();
install.execute().unwrap();
let script = std::fs::read_to_string(&shim).unwrap();
assert!(script.starts_with("#!/bin/sh\nexec \"/"), "{script}");
assert!(script.contains(" claude-wrap -- \"$@\""), "{script}");
let mode = std::fs::metadata(&shim).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
assert_eq!(
read_settings(&settings).unwrap()[WRAPPER_SETTING_KEY],
json!(shim.display().to_string())
);
}
#[test]
fn install_wrapper_is_idempotent_and_preserves_other_settings() {
let tmp = tempfile::tempdir().unwrap();
let (install, _) = wrapper_commands(tmp.path());
let settings_path = install.settings.clone().unwrap();
write_settings(&settings_path, &json!({ "editor.fontSize": 13 })).unwrap();
install.execute().unwrap();
let (again, _) = wrapper_commands(tmp.path());
again.execute().unwrap();
let settings = read_settings(&settings_path).unwrap();
assert_eq!(settings["editor.fontSize"], json!(13));
assert!(settings[WRAPPER_SETTING_KEY].is_string());
}
#[test]
fn uninstall_wrapper_clears_only_our_own_setting() {
let tmp = tempfile::tempdir().unwrap();
let (install, uninstall) = wrapper_commands(tmp.path());
let settings_path = install.settings.clone().unwrap();
let shim = install.shim.clone().unwrap();
install.execute().unwrap();
uninstall.execute().unwrap();
assert!(read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY].is_null());
assert!(!shim.exists());
write_settings(
&settings_path,
&json!({ WRAPPER_SETTING_KEY: "/opt/someone-elses-wrapper" }),
)
.unwrap();
let (_, uninstall) = wrapper_commands(tmp.path());
uninstall.execute().unwrap();
assert_eq!(
read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY],
json!("/opt/someone-elses-wrapper")
);
}
#[test]
fn uninstall_wrapper_on_a_missing_settings_file_is_a_noop() {
let tmp = tempfile::tempdir().unwrap();
let (_, uninstall) = wrapper_commands(tmp.path());
let settings = uninstall.settings.clone().unwrap();
uninstall.execute().unwrap();
assert!(!settings.exists());
}
#[test]
fn install_wrapper_on_a_jsonc_settings_file_explains_the_manual_step() {
let tmp = tempfile::tempdir().unwrap();
let (install, _) = wrapper_commands(tmp.path());
let settings = install.settings.clone().unwrap();
let shim = install.shim.clone().unwrap();
std::fs::write(
&settings,
"{\n // a comment\n \"editor.fontSize\": 13\n}\n",
)
.unwrap();
let error = format!("{:#}", install.execute().unwrap_err());
assert!(error.contains("not valid JSON"), "{error}");
assert!(error.contains(WRAPPER_SETTING_KEY), "{error}");
assert!(error.contains(&shim.display().to_string()), "{error}");
assert!(shim.exists());
}
#[test]
fn install_wrapper_fails_when_the_shim_directory_cannot_be_made() {
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, b"").unwrap();
let error = InstallWrapperCommand {
settings: Some(tmp.path().join("settings.json")),
shim: Some(blocker.join("claude-wrap")),
}
.execute()
.unwrap_err();
assert!(format!("{error:#}").contains("not-a-dir"), "{error:#}");
assert!(!tmp.path().join("settings.json").exists());
}
#[test]
fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
let mut scalar = json!("not an object");
assert!(!set_wrapper(&mut scalar, "/shim"));
assert!(!clear_wrapper(&mut scalar, "/shim"));
assert_eq!(scalar, json!("not an object"));
}
#[test]
fn the_wrapper_paths_default_outside_the_claude_config_dir() {
let shim = shim_path(None).unwrap();
assert_eq!(shim.file_name().unwrap(), SHIM_NAME);
assert_eq!(shim.parent().unwrap(), paths::runtime_dir().unwrap());
let settings = vscode_settings_path(None).unwrap();
assert!(
settings.ends_with("Code/User/settings.json"),
"{settings:?}"
);
}
#[test]
fn uninstall_command_on_a_missing_file_is_a_noop() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does-not-exist.json");
UninstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}
.execute()
.unwrap();
assert!(!path.exists());
}
#[tokio::test]
async fn sessions_command_dispatches_to_subcommands() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
SessionsCommand {
command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}),
}
.execute()
.await
.unwrap();
SessionsCommand {
command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
settings: Some(path.clone()),
pi_agent_dir: Some(tmp.path().join("pi-agent")),
codex_home: Some(tmp.path().join("codex-home")),
}),
}
.execute()
.await
.unwrap();
let (install, uninstall) = wrapper_commands(tmp.path());
SessionsCommand {
command: SessionsSubcommands::InstallWrapper(install),
}
.execute()
.await
.unwrap();
SessionsCommand {
command: SessionsSubcommands::UninstallWrapper(uninstall),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn hook_report_is_silent_when_the_daemon_is_down() {
let tmp = tempfile::tempdir_in("/tmp").unwrap();
let sock = tmp.path().join("nope.sock");
let cmd = HookCommand {
socket: Some(sock),
agent: HookAgent::Claude,
};
cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None)
.await;
cmd.report("not json", None).await;
cmd.report(r#"{"hook_event_name":"Stop"}"#, None).await; }
#[test]
fn hook_input_that_cannot_be_read_is_skipped_with_a_debug_line() {
struct Broken;
impl Read for Broken {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("stdin went away"))
}
}
let cmd = HookCommand {
socket: None,
agent: HookAgent::Claude,
};
let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
assert_eq!(cmd.read_input(&b"\xff\xfe"[..]), None);
assert_eq!(cmd.read_input(Broken), None);
});
assert_eq!(logs.matches("stdin_read_failed").count(), 2, "{logs}");
assert_eq!(
cmd.read_input(&b"{\"session_id\":\"s1\"}"[..]).as_deref(),
Some("{\"session_id\":\"s1\"}")
);
}
#[tokio::test]
async fn hook_report_logs_a_daemon_rejection_without_its_content() {
let (_dir, sock, server) = fake_daemon(json!({"ok": false, "error": "HOOK_REPLY_SECRET"}));
let cmd = HookCommand {
socket: Some(sock),
agent: HookAgent::Claude,
};
let ((), logs) = crate::test_support::capture_future_at(
tracing::Level::DEBUG,
cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
)
.await;
server.await.unwrap();
assert!(logs.contains("daemon_rejected"), "{logs}");
assert!(!logs.contains("HOOK_REPLY_SECRET"), "{logs}");
}
#[tokio::test(start_paused = true)]
async fn hook_report_gives_up_on_a_wedged_daemon() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("d.sock");
let _wedged = tokio::net::UnixListener::bind(&sock).unwrap();
let cmd = HookCommand {
socket: Some(sock),
agent: HookAgent::Claude,
};
let ((), logs) = crate::test_support::capture_future_at(
tracing::Level::DEBUG,
cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
)
.await;
assert!(logs.contains("timeout"), "{logs}");
}
#[tokio::test]
async fn hook_report_logs_a_delivered_event() {
let (_dir, sock, server) = fake_daemon(json!({"ok": true, "payload": {}}));
let cmd = HookCommand {
socket: Some(sock),
agent: HookAgent::Claude,
};
let ((), logs) = crate::test_support::capture_future_at(
tracing::Level::DEBUG,
cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
)
.await;
server.await.unwrap();
assert!(logs.contains("delivered"), "{logs}");
}
fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
use futures::{SinkExt, StreamExt};
use tokio::net::UnixListener;
use tokio_util::codec::{Framed, LinesCodec};
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("d.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut framed = Framed::new(stream, LinesCodec::new());
let _req = framed.next().await.unwrap().unwrap();
framed
.send(serde_json::to_string(&reply).unwrap())
.await
.unwrap();
});
(dir, sock, server)
}
fn fake_daemon_capture(
reply: Value,
) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
use futures::{SinkExt, StreamExt};
use tokio::net::UnixListener;
use tokio_util::codec::{Framed, LinesCodec};
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("d.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut framed = Framed::new(stream, LinesCodec::new());
let req = framed.next().await.unwrap().unwrap();
framed
.send(serde_json::to_string(&reply).unwrap())
.await
.unwrap();
serde_json::from_str::<Value>(&req).unwrap()
});
(dir, sock, server)
}
#[tokio::test]
async fn list_command_execute_renders_from_a_socket() {
let payload = json!({
"ok": true,
"payload": { "sessions": [{
"session_id": "s1", "state": "working",
"source": { "kind": "terminal" }, "repo": "omni-dev",
"cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
}]}
});
let (_dir, sock, server) = fake_daemon(payload.clone());
SessionsCommand {
command: SessionsSubcommands::List(ListCommand {
socket: Some(sock),
output: TableOrJson::Table,
}),
}
.execute()
.await
.unwrap();
server.await.unwrap();
let (_dir, sock, server) = fake_daemon(payload);
ListCommand {
socket: Some(sock),
output: TableOrJson::Json,
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[test]
fn merge_and_remove_hooks_tolerate_malformed_shapes() {
let cmd = "cmd sessions hook";
assert_eq!(merge_hooks(&mut json!([]), cmd, HOOK_EVENTS), 0);
assert_eq!(remove_hooks(&mut json!([]), |c| c == cmd), 0);
assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd, HOOK_EVENTS), 0);
assert_eq!(remove_hooks(&mut json!({}), |c| c == cmd), 0);
assert_eq!(
merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd, HOOK_EVENTS),
HOOK_EVENTS.len() - 1
);
assert_eq!(
remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), |c| c == cmd),
0
);
}
#[test]
fn window_subcommands_route_and_require_key() {
assert!(matches!(
parse(&["window", "--key", "w1"]),
SessionsSubcommands::Window(_)
));
assert!(matches!(
parse(&["window-unregister", "--key", "w1"]),
SessionsSubcommands::WindowUnregister(_)
));
assert!(WindowCommand::try_parse_from(["window"]).is_err());
assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
}
#[test]
fn window_parses_counts_and_folders() {
let cmd = WindowCommand::try_parse_from([
"window",
"--key",
"w1",
"--folder",
"/a",
"--folder",
"/b",
"--tabs",
"2",
"--terminals",
"3",
])
.unwrap();
assert_eq!(cmd.key, "w1");
assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
assert_eq!(cmd.tabs, 2);
assert_eq!(cmd.terminals, 3);
let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
assert_eq!(cmd.tabs, 0);
assert_eq!(cmd.terminals, 0);
}
#[tokio::test]
async fn window_and_window_unregister_send_their_ops() {
let (_dir, sock, server) =
fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
SessionsCommand {
command: SessionsSubcommands::Window(WindowCommand {
key: "w1".to_string(),
folders: vec![PathBuf::from("/a")],
tabs: 1,
terminals: 0,
socket: Some(sock),
}),
}
.execute()
.await
.unwrap();
let req = server.await.unwrap();
assert_eq!(req["op"], "window");
assert_eq!(req["payload"]["key"], json!("w1"));
assert_eq!(req["payload"]["folders"], json!(["/a"]));
assert_eq!(req["payload"]["tabs"], json!(1));
assert_eq!(req["payload"]["terminals"], json!(0));
let (_dir, sock, server) =
fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
SessionsCommand {
command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
key: "w1".to_string(),
socket: Some(sock),
}),
}
.execute()
.await
.unwrap();
let req = server.await.unwrap();
assert_eq!(req["op"], "window-unregister");
assert_eq!(req["payload"]["key"], json!("w1"));
}
}