use std::io::Read;
use std::path::{Path, PathBuf};
const DEADLINE: std::time::Duration = std::time::Duration::from_millis(250);
const MAX_EVENT: u64 = 256 * 1024;
const MAX_PEERS: usize = 16;
fn socket_dir() -> Option<PathBuf> {
dirs::runtime_dir()
.or_else(dirs::cache_dir)
.map(|d| d.join("cctop").join("hooks.d"))
}
fn peers(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "sock"))
.collect();
found.sort();
found.truncate(MAX_PEERS);
found
}
pub fn emit(args: &[String]) -> i32 {
std::panic::set_hook(Box::new(|_| std::process::exit(0)));
let args = args.to_vec();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(|| forward(&args));
let _ = tx.send(());
});
let _ = rx.recv_timeout(DEADLINE);
0
}
fn forward(args: &[String]) {
let (name, payload) = match args.first().map(String::as_str) {
Some(word) if is_argv_payload(word) => match args.get(1) {
Some(json) => (String::new(), json.as_bytes().to_vec()),
None => return,
},
other => {
let mut buf = Vec::new();
if std::io::stdin()
.take(MAX_EVENT)
.read_to_end(&mut buf)
.is_err()
{
return;
}
(other.unwrap_or_default().to_string(), buf)
}
};
let Some(line) = envelope(&name, &payload) else {
return;
};
deliver(&line);
}
#[cfg(unix)]
fn deliver(line: &[u8]) {
use std::io::Write;
use std::os::unix::net::UnixStream;
let Some(dir) = socket_dir() else { return };
let started = std::time::Instant::now();
for path in peers(&dir) {
if started.elapsed() >= DEADLINE {
return;
}
match UnixStream::connect(&path) {
Ok(mut stream) => {
let _ = stream.set_write_timeout(Some(DEADLINE));
let _ = stream.write_all(line);
let _ = stream.flush();
}
Err(_) => {
let _ = std::fs::remove_file(&path);
}
}
}
}
#[cfg(not(unix))]
fn deliver(_line: &[u8]) {}
fn envelope(name: &str, payload: &[u8]) -> Option<Vec<u8>> {
let body: serde_json::Value = serde_json::from_slice(payload).ok()?;
let field = |key: &str| body.get(key).and_then(|v| v.as_str()).unwrap_or_default();
let first = |keys: &[&str]| keys.iter().map(|k| field(k)).find(|v| !v.is_empty());
let event = serde_json::json!({
"event": match name.is_empty() {
true => first(&["hook_event_name", "type"]).unwrap_or_default(),
false => name,
},
"session_id": first(&["session_id", "thread-id", "conversation_id", "sessionID"])
.unwrap_or_default(),
"cwd": first(&["cwd", "directory"]).unwrap_or_else(|| {
body.get("workspace_roots")
.and_then(|v| v.as_array())
.and_then(|roots| roots.first())
.and_then(|v| v.as_str())
.unwrap_or_default()
}),
"agent_id": first(&["agent_id", "subagent_id"]).unwrap_or_default(),
"notification_type": field("notification_type"),
});
let mut line = serde_json::to_vec(&event).ok()?;
line.push(b'\n');
Some(line)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signal {
NeedsInput,
Idle,
Busy,
Compacting,
Started,
Ended,
}
impl Signal {
pub fn is_working(self) -> bool {
matches!(self, Signal::Busy | Signal::Compacting | Signal::Started)
}
pub fn is_lifecycle(self) -> bool {
matches!(self, Signal::Started | Signal::Ended)
}
pub fn label(self) -> &'static str {
match self {
Signal::NeedsInput => "asking",
Signal::Idle => "idle",
Signal::Busy => "working",
Signal::Compacting => "compacting",
Signal::Started => "started",
Signal::Ended => "ended",
}
}
}
#[derive(Debug, Clone)]
pub struct Event {
pub session_id: String,
pub reported: Reported,
pub finished_agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reported {
pub signal: Signal,
pub cwd: String,
}
fn notification_signal(kind: &str) -> Option<Signal> {
match kind {
"idle_prompt" => Some(Signal::Idle),
"elicitation_response" => Some(Signal::Busy),
"auth_success"
| "computer_use_enter"
| "computer_use_exit"
| "elicitation_complete"
| "agent_completed"
| "push_notification" => None,
_ => Some(Signal::NeedsInput),
}
}
fn signal_of(event: &str, notification: &str) -> Option<Signal> {
match event {
"Stop" | "agent-turn-complete" | "AfterAgent" | "stop" | "session.idle" => {
Some(Signal::Idle)
}
"Notification" => notification_signal(notification),
"UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "SubagentStop"
| "BeforeAgent" | "BeforeTool"
| "beforeSubmitPrompt" | "beforeShellExecution" | "subagentStop"
| "tool.execute.before" => Some(Signal::Busy),
"permission.asked" => Some(Signal::NeedsInput),
"PreCompact" | "PreCompress" | "preCompact" | "session.compacted" => {
Some(Signal::Compacting)
}
"SessionStart" | "sessionStart" | "session.created" => Some(Signal::Started),
"SessionEnd" | "sessionEnd" | "session.deleted" => Some(Signal::Ended),
_ => None,
}
}
pub struct Listener {
pending: std::sync::Arc<std::sync::Mutex<Vec<Event>>>,
path: PathBuf,
}
impl Drop for Listener {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
impl Listener {
#[cfg(unix)]
pub fn start() -> Option<Listener> {
use std::os::unix::net::UnixListener;
let dir = socket_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let _ = std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700));
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let path = dir.join(format!("{}-{stamp}.sock", std::process::id()));
let listener = UnixListener::bind(&path).ok()?;
let pending = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let collector = std::sync::Arc::clone(&pending);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let collector = std::sync::Arc::clone(&collector);
std::thread::spawn(move || {
let mut text = String::new();
if stream.take(MAX_EVENT).read_to_string(&mut text).is_err() {
return;
}
let events: Vec<Event> = text.lines().filter_map(parse).collect();
if events.is_empty() {
return;
}
collector
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend(events);
});
}
});
Some(Listener { pending, path })
}
#[cfg(not(unix))]
pub fn start() -> Option<Listener> {
None
}
pub fn drain(&self) -> Vec<Event> {
let mut pending = self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *pending)
}
pub fn peer_count(&self) -> usize {
socket_dir()
.map(|dir| peers(&dir).len().saturating_sub(1))
.unwrap_or(0)
}
}
fn parse(line: &str) -> Option<Event> {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
let session_id = value.get("session_id")?.as_str()?.to_string();
if session_id.is_empty() {
return None;
}
Some(Event {
session_id,
finished_agent: matches!(
value.get("event").and_then(|v| v.as_str()),
Some("SubagentStop" | "subagentStop")
)
.then(|| value.get("agent_id").and_then(|v| v.as_str()))
.flatten()
.filter(|id| !id.is_empty())
.map(str::to_string),
reported: Reported {
signal: signal_of(
value.get("event")?.as_str()?,
value
.get("notification_type")
.and_then(|v| v.as_str())
.unwrap_or_default(),
)?,
cwd: value
.get("cwd")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
},
})
}
const CLAUDE_EVENTS: &[&str] = &[
"Stop",
"Notification",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"SessionStart",
"SessionEnd",
"PreCompact",
"SubagentStop",
];
const GEMINI_EVENTS: &[&str] = &[
"BeforeAgent",
"AfterAgent",
"BeforeTool",
"Notification",
"SessionStart",
"SessionEnd",
"PreCompress",
];
const CURSOR_EVENTS: &[&str] = &[
"stop",
"beforeSubmitPrompt",
"beforeShellExecution",
"sessionStart",
"sessionEnd",
"preCompact",
"subagentStop",
];
const MARKER: &str = " hook ";
const CODEX_SELECTOR: &str = "codex";
const OPENCODE_SELECTOR: &str = "opencode";
fn is_argv_payload(word: &str) -> bool {
matches!(word, CODEX_SELECTOR | OPENCODE_SELECTOR)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Harness {
Claude,
Gemini,
Cursor,
Codex,
OpenCode,
}
pub const HARNESSES: [Harness; 5] = [
Harness::Claude,
Harness::Gemini,
Harness::Cursor,
Harness::Codex,
Harness::OpenCode,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shape {
Nested,
Flat,
}
enum Config {
Json {
path: PathBuf,
shape: Shape,
events: &'static [&'static str],
},
Notify(PathBuf),
Plugin(PathBuf),
}
impl Harness {
pub fn label(self) -> &'static str {
match self {
Harness::Claude => "Claude Code",
Harness::Gemini => "Gemini CLI",
Harness::Cursor => "Cursor",
Harness::Codex => "Codex",
Harness::OpenCode => "OpenCode",
}
}
fn config(self, scope: &Scope) -> Option<Config> {
let project = match scope {
Scope::User => None,
Scope::Project(dir) => Some(dir.as_path()),
};
Some(match self {
Harness::Claude => Config::Json {
path: match project {
None => crate::config::CLAUDE_CONFIG_DIR.join("settings.json"),
Some(dir) => dir.join(".claude").join("settings.json"),
},
shape: Shape::Nested,
events: CLAUDE_EVENTS,
},
Harness::Gemini => Config::Json {
path: match project {
None => crate::config::GEMINI_HOME.join("settings.json"),
Some(dir) => dir.join(".gemini").join("settings.json"),
},
shape: Shape::Nested,
events: GEMINI_EVENTS,
},
Harness::Cursor => Config::Json {
path: match project {
None => crate::config::CURSOR_HOME.join("hooks.json"),
Some(dir) => dir.join(".cursor").join("hooks.json"),
},
shape: Shape::Flat,
events: CURSOR_EVENTS,
},
Harness::Codex => match project {
Some(_) => return None,
None => Config::Notify(crate::config::CODEX_HOME.join("config.toml")),
},
Harness::OpenCode => Config::Plugin(
match project {
None => crate::config::OPENCODE_CONFIG_DIR.clone(),
Some(dir) => dir.join(".opencode"),
}
.join("plugins")
.join(PLUGIN_FILE),
),
})
}
pub fn config_file(self, scope: &Scope) -> Option<PathBuf> {
Some(match self.config(scope)? {
Config::Json { path, .. } | Config::Notify(path) | Config::Plugin(path) => path,
})
}
fn install(self, scope: &Scope, exe: &str) -> anyhow::Result<String> {
let Some(config) = self.config(scope) else {
anyhow::bail!("{} has no {} scope", self.label(), scope.label());
};
match config {
Config::Json {
path,
shape,
events,
} => {
json_install(&path, shape, events, exe)?;
Ok(format!(
"{}: added {} hooks to {}",
self.label(),
events.len(),
path.display()
))
}
Config::Notify(path) => {
notify_install(&path, exe)?;
Ok(format!("{}: notify points at cctop", self.label()))
}
Config::Plugin(path) => {
plugin_install(&path, exe)?;
Ok(format!("{}: wrote plugin {}", self.label(), path.display()))
}
}
}
fn remove(self, scope: &Scope) -> anyhow::Result<String> {
let Some(config) = self.config(scope) else {
return Ok(String::new());
};
match config {
Config::Json { path, .. } => {
let removed = json_remove(&path)?;
Ok(format!(
"{}: removed {removed} hooks from {}",
self.label(),
path.display()
))
}
Config::Notify(path) => {
notify_remove(&path).map(|what| format!("{}: {what}", self.label()))
}
Config::Plugin(path) => {
let removed = plugin_remove(&path)?;
Ok(match removed {
true => format!("{}: removed plugin {}", self.label(), path.display()),
false => format!("{}: nothing of cctop's installed", self.label()),
})
}
}
}
fn health(self, scope: &Scope) -> Option<Health> {
Some(match self.config(scope)? {
Config::Json {
path,
shape,
events,
} => json_health(&path, shape, events),
Config::Notify(path) => notify_health(&path),
Config::Plugin(path) => plugin_health(&path),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
User,
Project(PathBuf),
}
impl Scope {
pub fn label(&self) -> &'static str {
match self {
Scope::User => "user",
Scope::Project(_) => "project",
}
}
pub fn parse(word: &str, cwd: &Path) -> Option<Scope> {
match word {
"user" | "global" => Some(Scope::User),
"project" | "local" => Some(Scope::Project(cwd.to_path_buf())),
_ => None,
}
}
}
fn own_exe() -> anyhow::Result<String> {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(str::to_string))
.ok_or_else(|| anyhow::anyhow!("could not find cctop's own path"))
}
pub fn install(scope: &Scope) -> Vec<String> {
let exe = match own_exe() {
Ok(exe) => exe,
Err(e) => return vec![e.to_string()],
};
HARNESSES
.iter()
.filter(|h| h.config(scope).is_some())
.map(|h| match h.install(scope, &exe) {
Ok(what) => what,
Err(e) => format!("{}: {e}", h.label()),
})
.collect()
}
pub fn remove(scope: &Scope) -> Vec<String> {
HARNESSES
.iter()
.filter(|h| h.config(scope).is_some())
.map(|h| match h.remove(scope) {
Ok(what) => what,
Err(e) => format!("{}: {e}", h.label()),
})
.filter(|line| !line.is_empty())
.collect()
}
fn json_install(path: &Path, shape: Shape, events: &[&str], exe: &str) -> anyhow::Result<()> {
let mut root = read_settings(path)?;
if shape == Shape::Flat {
root.entry("version")
.or_insert_with(|| serde_json::json!(1));
}
let hooks = root
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("`hooks` in {} is not an object", path.display()))?;
for event in events {
let list = hooks
.entry(*event)
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("`hooks.{event}` is not an array"))?;
list.retain(|entry| !is_ours(entry));
let command = serde_json::json!({
"type": "command",
"command": format!("{exe} hook {event}"),
});
list.push(match shape {
Shape::Nested => serde_json::json!({ "hooks": [command] }),
Shape::Flat => command,
});
}
write_settings(path, &root)
}
fn json_remove(path: &Path) -> anyhow::Result<usize> {
let mut root = read_settings(path)?;
let mut removed = 0;
if let Some(hooks) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) {
for (_, value) in hooks.iter_mut() {
if let Some(list) = value.as_array_mut() {
let before = list.len();
list.retain(|entry| !is_ours(entry));
removed += before - list.len();
}
}
hooks.retain(|_, value| !value.as_array().is_some_and(|l| l.is_empty()));
if hooks.is_empty() {
root.remove("hooks");
}
}
if removed > 0 {
write_settings(path, &root)?;
}
Ok(removed)
}
fn is_ours(entry: &serde_json::Value) -> bool {
entry_commands(entry).any(is_our_command)
}
fn is_our_command(command: &str) -> bool {
let Some((exe, event)) = command.rsplit_once(MARKER) else {
return false;
};
let event = event.trim();
!event.is_empty()
&& !event.contains(char::is_whitespace)
&& Path::new(exe.trim())
.file_name()
.is_some_and(|name| name.to_string_lossy().contains("cctop"))
}
fn entry_commands(entry: &serde_json::Value) -> impl Iterator<Item = &str> {
let nested = entry
.get("hooks")
.and_then(|h| h.as_array())
.map(|inner| inner.as_slice())
.unwrap_or_default()
.iter()
.filter_map(|h| h.get("command").and_then(|c| c.as_str()));
let flat = entry.get("command").and_then(|c| c.as_str());
nested.chain(flat)
}
fn recorded_exe(command: &str) -> Option<&str> {
if !is_our_command(command) {
return None;
}
Some(command.rsplit_once(MARKER)?.0.trim())
}
fn read_settings(path: &Path) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => "{}".to_string(),
Err(e) => return Err(e.into()),
};
let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|e| {
anyhow::anyhow!("{} is not valid JSON ({e}); fix it first", path.display())
})?;
match value {
serde_json::Value::Object(map) => Ok(map),
_ => anyhow::bail!("{} is not a JSON object", path.display()),
}
}
fn write_settings(
path: &Path,
root: &serde_json::Map<String, serde_json::Value>,
) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.cctop-tmp");
std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(root)?))?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn codex_notify(exe: &str) -> Vec<String> {
vec![exe.to_string(), "hook".into(), CODEX_SELECTOR.into()]
}
fn notify_install(path: &Path, exe: &str) -> anyhow::Result<()> {
let mut doc = read_codex(path)?;
if let Some(existing) = codex_notify_argv(&doc)
&& !existing.iter().any(|a| a.contains("cctop"))
{
anyhow::bail!(
"{} already sets notify = {existing:?}; remove it first if you want cctop to have it",
path.display()
);
}
let mut array = toml_edit::Array::new();
for arg in codex_notify(exe) {
array.push(arg);
}
doc["notify"] = toml_edit::value(array);
write_codex(path, &doc)
}
fn notify_remove(path: &Path) -> anyhow::Result<String> {
let mut doc = read_codex(path)?;
match codex_notify_argv(&doc) {
Some(argv) if argv.iter().any(|a| a.contains("cctop")) => {
doc.remove("notify");
write_codex(path, &doc)?;
Ok(format!("removed from notify in {}", path.display()))
}
Some(_) => Ok(format!(
"notify in {} belongs to something else; left alone",
path.display()
)),
None => Ok(format!("was not notifying cctop ({})", path.display())),
}
}
fn codex_notify_argv(doc: &toml_edit::DocumentMut) -> Option<Vec<String>> {
let array = doc.get("notify")?.as_array()?;
Some(
array
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
}
fn read_codex(path: &Path) -> anyhow::Result<toml_edit::DocumentMut> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
text.parse::<toml_edit::DocumentMut>()
.map_err(|e| anyhow::anyhow!("{} is not valid TOML ({e}); fix it first", path.display()))
}
fn write_codex(path: &Path, doc: &toml_edit::DocumentMut) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("toml.cctop-tmp");
std::fs::write(&tmp, doc.to_string())?;
std::fs::rename(&tmp, path)?;
Ok(())
}
const PLUGIN_FILE: &str = "cctop.ts";
const PLUGIN_MARKER: &str = "const CCTOP = ";
fn plugin_source(exe: &str) -> String {
let exe = serde_json::Value::String(exe.to_string());
format!(
r#"// Written by cctop, which watches coding agents. Safe to delete: removing
// this file is all it takes to stop reporting.
//
// Reports the moments a transcript cannot show — a turn finishing, a session
// starting or ending — to whatever cctop is running. Every failure is
// swallowed on purpose: this runs inside OpenCode, and a monitor must never be
// the reason a session breaks.
{PLUGIN_MARKER}{exe}
// Only the events cctop has something to say about. Anything else is ignored
// here rather than spawning a process to be dropped at the other end.
const REPORTED = new Set([
"session.idle",
"session.created",
"session.deleted",
"session.compacted",
"permission.asked",
"tool.execute.before",
])
export const cctop = async ({{ directory, worktree }}) => {{
return {{
event: async ({{ event }}) => {{
try {{
const type = event?.type
if (!type || !REPORTED.has(type)) return
const props = event.properties ?? {{}}
const sessionID = props.sessionID ?? props.info?.id ?? props.sessionId
if (!sessionID) return
const payload = JSON.stringify({{
type,
sessionID,
directory: directory ?? worktree ?? "",
}})
Bun.spawn([CCTOP, "hook", "{OPENCODE_SELECTOR}", payload], {{
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}}).unref()
}} catch {{
// A monitor is never worth an exception in somebody else's agent.
}}
}},
}}
}}
"#
)
}
fn plugin_install(path: &Path, exe: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("ts.cctop-tmp");
std::fs::write(&tmp, plugin_source(exe))?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn plugin_remove(path: &Path) -> anyhow::Result<bool> {
match std::fs::read_to_string(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
Ok(text) if !text.contains(PLUGIN_MARKER) => Ok(false),
Ok(_) => {
std::fs::remove_file(path)?;
Ok(true)
}
}
}
fn plugin_exe(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.starts_with(PLUGIN_MARKER))?;
serde_json::from_str::<String>(line[PLUGIN_MARKER.len()..].trim()).ok()
}
fn json_health(path: &Path, shape: Shape, events: &[&'static str]) -> Health {
let root = match read_settings(path) {
Err(e) => return Health::Unreadable(e.to_string()),
Ok(root) => root,
};
if shape == Shape::Flat && root.get("version").is_none() {
return Health::Absent;
}
let hooks = root.get("hooks").and_then(|h| h.as_object());
let mut missing = Vec::new();
let mut recorded: Option<String> = None;
for event in events {
let entry = hooks
.and_then(|h| h.get(*event))
.and_then(|v| v.as_array())
.and_then(|list| list.iter().find(|e| is_ours(e)));
match entry {
None => missing.push(*event),
Some(entry) => {
recorded = recorded.or_else(|| {
entry_commands(entry)
.find_map(recorded_exe)
.map(str::to_string)
})
}
}
}
verdict(recorded, missing)
}
fn notify_health(path: &Path) -> Health {
match read_codex(path) {
Err(e) => Health::Unreadable(e.to_string()),
Ok(doc) => match codex_notify_argv(&doc).as_deref() {
None | Some([]) => Health::Absent,
Some([exe, ..]) if !exe.contains("cctop") => Health::Other(exe.clone()),
Some([exe, ..]) => verdict(Some(exe.clone()), Vec::new()),
},
}
}
fn plugin_health(path: &Path) -> Health {
match std::fs::read_to_string(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Health::Absent,
Err(e) => Health::Unreadable(e.to_string()),
Ok(text) => match plugin_exe(&text) {
None => Health::Unreadable(format!("{} is not a cctop plugin", path.display())),
Some(exe) => verdict(Some(exe), Vec::new()),
},
}
}
fn verdict(recorded: Option<String>, missing: Vec<&'static str>) -> Health {
match recorded {
None => Health::Absent,
Some(exe) if !Path::new(&exe).exists() => Health::Broken(exe),
Some(exe) if Some(exe.as_str()) != own_exe().ok().as_deref() => Health::Other(exe),
Some(_) if !missing.is_empty() => Health::Partial(missing),
Some(_) => Health::Installed,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Health {
Absent,
Installed,
Partial(Vec<&'static str>),
Other(String),
Broken(String),
Unreadable(String),
}
impl Health {
pub fn is_problem(&self) -> bool {
matches!(
self,
Health::Partial(_) | Health::Broken(_) | Health::Unreadable(_)
)
}
}
#[derive(Debug, Clone)]
pub struct ScopeStatus {
pub harness: Harness,
pub scope: Scope,
pub path: PathBuf,
pub health: Health,
}
#[derive(Debug, Clone)]
pub struct Report {
pub entries: Vec<ScopeStatus>,
pub listening: bool,
pub peers: usize,
}
pub fn harness_status(harness: Harness, scope: Scope) -> Option<ScopeStatus> {
Some(ScopeStatus {
path: harness.config_file(&scope)?,
health: harness.health(&scope)?,
harness,
scope,
})
}
pub fn status(cwd: Option<&Path>, listener: Option<&Listener>) -> Report {
let mut scopes = vec![Scope::User];
if let Some(dir) = cwd {
scopes.push(Scope::Project(dir.to_path_buf()));
}
let entries = HARNESSES
.iter()
.flat_map(|harness| {
scopes
.iter()
.filter_map(|scope| harness_status(*harness, scope.clone()))
})
.collect();
Report {
entries,
listening: listener.is_some(),
peers: listener.map(Listener::peer_count).unwrap_or(0),
}
}
impl Report {
pub fn lines(&self) -> Vec<(String, bool)> {
let mut out = Vec::new();
for harness in HARNESSES {
let mine: Vec<&ScopeStatus> = self
.entries
.iter()
.filter(|entry| entry.harness == harness)
.collect();
if mine.iter().all(|entry| entry.health == Health::Absent) {
if let Some(entry) = mine.first() {
out.push((
format!(
"{}: not installed ({})",
harness.label(),
entry.path.display()
),
false,
));
}
continue;
}
for entry in mine.iter().filter(|entry| entry.health != Health::Absent) {
let (text, bad) = describe(&entry.health);
out.push((
format!(
"{} ({}) {}: {text}",
harness.label(),
entry.scope.label(),
entry.path.display()
),
bad,
));
}
}
out.push(match (self.listening, self.peers) {
(false, _) => ("Listener: not running".into(), true),
(true, 0) => ("Listener: receiving".into(), false),
(true, n) => (
format!("Listener: receiving, alongside {n} other cctop(s)"),
false,
),
});
out
}
}
fn describe(health: &Health) -> (String, bool) {
match health {
Health::Absent => ("not installed".into(), false),
Health::Installed => ("installed".into(), false),
Health::Partial(missing) => (
format!("installed, but missing {}", missing.join(", ")),
true,
),
Health::Other(exe) => (format!("installed, pointing at {exe}"), false),
Health::Broken(exe) => (format!("points at {exe}, which is gone"), true),
Health::Unreadable(why) => (why.clone(), true),
}
}
pub fn repair(cwd: Option<&Path>) -> Vec<String> {
let Ok(exe) = own_exe() else {
return Vec::new();
};
let mut scopes = vec![Scope::User];
if let Some(dir) = cwd {
scopes.push(Scope::Project(dir.to_path_buf()));
}
let mut fixed = Vec::new();
for harness in HARNESSES {
for scope in &scopes {
if matches!(harness.health(scope), Some(Health::Broken(_)))
&& harness.install(scope, &exe).is_ok()
{
fixed.push(format!(
"repointed {} ({}) at this cctop",
harness.label(),
scope.label()
));
}
}
}
fixed
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cctop-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_subagent_stop_names_the_subagent_that_finished() {
let raw = br#"{"session_id":"parent-1","cwd":"/x","hook_event_name":"SubagentStop","agent_id":"ab3e95cbb4558bd90","agent_type":"general-purpose","last_assistant_message":"pineapple"}"#;
let line = envelope("", raw).expect("envelope");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert_eq!(
event.finished_agent.as_deref(),
Some("ab3e95cbb4558bd90"),
"the id must reach the UI, not stop at the envelope"
);
assert_eq!(event.session_id, "parent-1");
}
#[test]
fn only_a_subagent_stop_reports_a_finished_subagent() {
for name in ["Stop", "PreToolUse", "Notification"] {
let raw = format!(r#"{{"session_id":"s","hook_event_name":"{name}"}}"#);
let line = envelope("", raw.as_bytes()).expect("envelope");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert!(event.finished_agent.is_none(), "{name} named a subagent");
}
}
#[test]
fn an_event_is_reduced_to_the_session_and_what_happened() {
let raw = br#"{"session_id":"abc","cwd":"/x","hook_event_name":"Stop","extra":{"a":1}}"#;
let line = envelope("", raw).expect("envelope");
assert!(line.ends_with(b"\n"), "the wire is newline delimited");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert_eq!(event.session_id, "abc");
assert_eq!(event.reported.cwd, "/x");
assert_eq!(event.reported.signal, Signal::Idle);
let line = envelope("Notification", raw).expect("envelope");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert_eq!(event.reported.signal, Signal::NeedsInput);
assert!(envelope("Stop", b"not json").is_none());
assert!(parse("not json").is_none());
assert!(
parse(r#"{"event":"Stop"}"#).is_none(),
"no session to apply to"
);
assert!(
parse(r#"{"event":"SomethingNew","session_id":"a"}"#).is_none(),
"an unknown event must be dropped, not guessed at"
);
}
#[test]
fn a_codex_turn_lands_in_the_same_bin_as_a_claude_stop() {
let raw = br#"{"type":"agent-turn-complete","thread-id":"019fda22-5315-7580-84de-033e4f6835b5","turn-id":"019fda22-6995-7c40-bf6b-aaf54b274444","cwd":"/home/flo/cctop","client":"codex_exec","input-messages":["hi"],"last-assistant-message":"ok"}"#;
let line = envelope("", raw).expect("envelope");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert_eq!(event.session_id, "019fda22-5315-7580-84de-033e4f6835b5");
assert_eq!(event.reported.cwd, "/home/flo/cctop");
assert_eq!(
event.reported.signal,
Signal::Idle,
"a finished Codex turn is the same fact as a Claude Stop"
);
}
#[test]
fn every_harness_reports_a_finished_turn_the_same_way() {
let raw = br#"{"session_id":"a1b2c3d4-0000-4000-8000-00000000ffff","transcript_path":"/t.jsonl","cwd":"/home/flo/cctop","hook_event_name":"AfterAgent","timestamp":"2026-08-07T00:00:00Z","prompt":"hi","prompt_response":"ok","stop_hook_active":false}"#;
let event = parse(
std::str::from_utf8(&envelope("", raw).expect("envelope"))
.unwrap()
.trim(),
)
.expect("parse");
assert_eq!(event.session_id, "a1b2c3d4-0000-4000-8000-00000000ffff");
assert_eq!(event.reported.cwd, "/home/flo/cctop");
assert_eq!(event.reported.signal, Signal::Idle);
let raw = br#"{"conversation_id":"c8f2e1a0-1111-4111-8111-111111111111","session_id":"c8f2e1a0-1111-4111-8111-111111111111","hook_event_name":"stop","cursor_version":"2026.06.04","workspace_roots":["/home/flo/cctop"],"status":"completed"}"#;
let event = parse(
std::str::from_utf8(&envelope("", raw).expect("envelope"))
.unwrap()
.trim(),
)
.expect("parse");
assert_eq!(event.session_id, "c8f2e1a0-1111-4111-8111-111111111111");
assert_eq!(
event.reported.cwd, "/home/flo/cctop",
"a Cursor event carries its directory as a workspace root"
);
assert_eq!(event.reported.signal, Signal::Idle);
let raw = br#"{"conversation_id":"c8f2e1a0-1111-4111-8111-111111111111","hook_event_name":"subagentStop","subagent_id":"sub-77","workspace_roots":["/w"]}"#;
let event = parse(
std::str::from_utf8(&envelope("", raw).expect("envelope"))
.unwrap()
.trim(),
)
.expect("parse");
assert_eq!(event.session_id, "c8f2e1a0-1111-4111-8111-111111111111");
assert_eq!(event.finished_agent.as_deref(), Some("sub-77"));
assert_eq!(event.reported.signal, Signal::Busy);
let raw =
br#"{"type":"session.idle","sessionID":"ses_8a7c","directory":"/home/flo/cctop"}"#;
let event = parse(
std::str::from_utf8(&envelope("", raw).expect("envelope"))
.unwrap()
.trim(),
)
.expect("parse");
assert_eq!(event.session_id, "ses_8a7c");
assert_eq!(event.reported.cwd, "/home/flo/cctop");
assert_eq!(event.reported.signal, Signal::Idle);
}
#[test]
fn no_two_harnesses_disagree_about_a_shared_event_name() {
let mut seen: Vec<(&str, Signal)> = Vec::new();
for event in CLAUDE_EVENTS
.iter()
.chain(GEMINI_EVENTS)
.chain(CURSOR_EVENTS)
{
let Some(signal) = signal_of(event, "") else {
panic!("{event} is installed but means nothing to cctop");
};
if let Some((_, other)) = seen.iter().find(|(name, _)| name == event) {
assert_eq!(*other, signal, "{event} means two things");
}
seen.push((event, signal));
}
}
#[test]
fn an_answered_permission_prompt_stops_asking() {
assert_eq!(signal_of("PreToolUse", ""), Some(Signal::Busy));
assert_eq!(signal_of("Notification", ""), Some(Signal::NeedsInput));
assert_eq!(
signal_of("PostToolUse", ""),
Some(Signal::Busy),
"without this the prompt stays 'asking' until the next tool or the \
end of the turn"
);
assert!(
CLAUDE_EVENTS.contains(&"PostToolUse"),
"the event has to be installed to arrive at all"
);
}
#[test]
fn only_a_notification_that_blocks_the_agent_asks_for_you() {
let notification = |kind: &str| signal_of("Notification", kind);
assert_eq!(notification("idle_prompt"), Some(Signal::Idle));
assert_eq!(notification("agent_needs_input"), Some(Signal::NeedsInput));
assert_eq!(
notification("worker_permission_prompt"),
Some(Signal::NeedsInput)
);
assert_eq!(notification("elicitation_response"), Some(Signal::Busy));
for quiet in [
"auth_success",
"computer_use_enter",
"computer_use_exit",
"elicitation_complete",
"agent_completed",
"push_notification",
] {
assert_eq!(notification(quiet), None, "{quiet} claimed a state");
}
assert_eq!(notification("something_new"), Some(Signal::NeedsInput));
assert_eq!(notification(""), Some(Signal::NeedsInput));
}
#[test]
fn an_idle_nudge_arrives_as_a_finished_turn() {
let raw = br#"{"session_id":"s-1","transcript_path":"/t.jsonl","cwd":"/w","hook_event_name":"Notification","message":"Claude is waiting for your input","title":"Claude Code","notification_type":"idle_prompt"}"#;
let line = envelope("Notification", raw).expect("envelope");
let event = parse(std::str::from_utf8(&line).unwrap().trim()).expect("parse");
assert_eq!(event.reported.signal, Signal::Idle);
}
#[test]
fn only_the_lifecycle_events_ask_for_a_rescan() {
assert!(signal_of("SessionStart", "").unwrap().is_lifecycle());
assert!(signal_of("SessionEnd", "").unwrap().is_lifecycle());
assert!(!signal_of("PreToolUse", "").unwrap().is_lifecycle());
assert!(!signal_of("Stop", "").unwrap().is_lifecycle());
assert!(signal_of("PreCompact", "").unwrap().is_working());
assert!(signal_of("SubagentStop", "").unwrap().is_working());
assert!(!signal_of("Stop", "").unwrap().is_working());
}
#[test]
fn installing_leaves_another_tools_hooks_exactly_as_they_were() {
let dir = scratch("hooks");
let scope = Scope::Project(dir.clone());
let path = Harness::Claude.config_file(&scope).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let theirs = serde_json::json!({
"model": "opus",
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "/theirs.mjs"}]}]
}
});
std::fs::write(&path, serde_json::to_string_pretty(&theirs).unwrap()).unwrap();
install(&scope);
let after = read_settings(&path).unwrap();
assert_eq!(after["model"], "opus", "an unrelated setting was disturbed");
assert_eq!(
after["hooks"]["SessionStart"][0], theirs["hooks"]["SessionStart"][0],
"another tool's hook was disturbed"
);
assert_eq!(
after["hooks"]["SessionStart"].as_array().unwrap().len(),
2,
"cctop's own SessionStart hook was not added alongside it"
);
assert_eq!(after["hooks"]["Stop"].as_array().unwrap().len(), 1);
install(&scope);
assert_eq!(
read_settings(&path).unwrap()["hooks"]["Stop"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(Harness::Claude.health(&scope).unwrap(), Health::Installed);
remove(&scope);
assert_eq!(read_settings(&path).unwrap(), *theirs.as_object().unwrap());
assert_eq!(Harness::Claude.health(&scope).unwrap(), Health::Absent);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_harness_is_asked_in_its_own_dialect() {
let dir = scratch("dialects");
let scope = Scope::Project(dir.clone());
let done = install(&scope);
assert_eq!(
done.len(),
4,
"every harness with a project scope should have been written: {done:?}"
);
for (harness, event) in [(Harness::Claude, "Stop"), (Harness::Gemini, "AfterAgent")] {
let path = harness.config_file(&scope).unwrap();
let root = read_settings(&path).unwrap();
let command = root["hooks"][event][0]["hooks"][0]["command"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
is_our_command(&command),
"{} wrote {command:?} for {event}",
harness.label()
);
assert!(command.ends_with(event), "the event has to be named");
assert_eq!(harness.health(&scope), Some(Health::Installed));
}
let path = Harness::Cursor.config_file(&scope).unwrap();
assert!(path.ends_with(".cursor/hooks.json"));
let root = read_settings(&path).unwrap();
assert_eq!(root["version"], 1, "Cursor ignores an unversioned file");
let command = root["hooks"]["stop"][0]["command"].as_str().unwrap();
assert!(is_our_command(command), "Cursor got {command:?}");
let path = Harness::OpenCode.config_file(&scope).unwrap();
assert!(path.ends_with(".opencode/plugins/cctop.ts"));
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(plugin_exe(&text).as_deref(), own_exe().ok().as_deref());
assert!(
text.contains(&format!("\"hook\", \"{OPENCODE_SELECTOR}\"")),
"the plugin has to call the hook the way `hook` reads it"
);
remove(&scope);
for harness in HARNESSES {
assert!(
matches!(harness.health(&scope), None | Some(Health::Absent)),
"{} was left behind",
harness.label()
);
}
assert!(
!Harness::OpenCode.config_file(&scope).unwrap().exists(),
"the plugin file was left on disk"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_install_missing_newer_events_reads_as_partial() {
let dir = scratch("partial");
let scope = Scope::Project(dir.clone());
let path = Harness::Claude.config_file(&scope).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let exe = own_exe().unwrap();
std::fs::write(
&path,
serde_json::json!({
"hooks": {"Stop": [{"hooks": [{"type": "command", "command": format!("{exe} hook Stop")}]}]}
})
.to_string(),
)
.unwrap();
match Harness::Claude.health(&scope).unwrap() {
Health::Partial(missing) => {
assert!(missing.contains(&"SessionEnd"));
assert!(!missing.contains(&"Stop"));
}
other => panic!("expected Partial, got {other:?}"),
}
install(&scope);
assert_eq!(Harness::Claude.health(&scope).unwrap(), Health::Installed);
assert_eq!(
read_settings(&path).unwrap()["hooks"]["Stop"]
.as_array()
.unwrap()
.len(),
1
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hook_pointing_at_a_deleted_binary_is_repaired_but_a_live_one_is_not() {
let dir = scratch("repair");
let scope = Scope::Project(dir.clone());
let path = Harness::Claude.config_file(&scope).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let gone = dir.join("moved-away-cctop");
let write_pointing_at = |exe: &Path| {
let hooks: serde_json::Map<String, serde_json::Value> = CLAUDE_EVENTS
.iter()
.map(|e| {
(
(*e).to_string(),
serde_json::json!([{"hooks": [{"type": "command",
"command": format!("{} hook {e}", exe.display())}]}]),
)
})
.collect();
std::fs::write(&path, serde_json::json!({"hooks": hooks}).to_string()).unwrap();
};
write_pointing_at(&gone);
assert_eq!(
Harness::Claude.health(&scope).unwrap(),
Health::Broken(gone.display().to_string())
);
assert!(
!repair(Some(&dir)).is_empty(),
"a dead path was not repaired"
);
assert_eq!(Harness::Claude.health(&scope).unwrap(), Health::Installed);
let other = dir.join("other-cctop");
std::fs::write(&other, "").unwrap();
write_pointing_at(&other);
let before = std::fs::read_to_string(&path).unwrap();
assert!(
repair(Some(&dir)).is_empty(),
"somebody else's live install was taken over"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_entry_is_recognised_however_the_binary_is_named() {
for command in [
"/usr/local/bin/cctop hook Stop",
"/home/me/.cargo/bin/cctop-0.1.12 hook PreToolUse",
"/target/debug/deps/cctop-9f2c1a hook SessionEnd",
"/Applications/My Tools/cctop hook Stop",
] {
assert!(is_our_command(command), "{command} was not recognised");
}
assert_eq!(
recorded_exe("/Applications/My Tools/cctop hook Stop"),
Some("/Applications/My Tools/cctop")
);
for command in [
"/usr/bin/something-else Stop",
"/opt/theirs/notify hook Stop",
"/usr/local/bin/cctop hook ",
"/usr/local/bin/cctop hook Stop && rm -rf /",
"/home/me/cctop/scripts/theirs.sh hook Stop",
] {
assert!(!is_our_command(command), "{command} was claimed as ours");
assert_eq!(recorded_exe(command), None);
}
}
#[test]
fn an_unparsable_settings_file_is_refused_rather_than_replaced() {
let dir = scratch("badjson");
let path = dir.join("settings.json");
std::fs::write(&path, "{ this is not json").unwrap();
assert!(read_settings(&path).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{ this is not json"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn editing_codexs_config_keeps_the_comments_around_it() {
let dir = scratch("codex");
let path = dir.join("config.toml");
std::fs::write(
&path,
"# my settings\nmodel = \"gpt-5.6-terra\"\n\n[tui]\n# keep this\nnotifications = true\n",
)
.unwrap();
let mut doc = read_codex(&path).unwrap();
let mut array = toml_edit::Array::new();
for arg in codex_notify("/usr/local/bin/cctop") {
array.push(arg);
}
doc["notify"] = toml_edit::value(array);
write_codex(&path, &doc).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# my settings"), "a comment was lost");
assert!(text.contains("# keep this"), "a comment was lost");
assert!(text.contains(r#"notify = ["/usr/local/bin/cctop", "hook", "codex"]"#));
let argv = codex_notify_argv(&read_codex(&path).unwrap()).unwrap();
assert_eq!(argv[1], "hook");
assert_eq!(argv[2], CODEX_SELECTOR);
doc = read_codex(&path).unwrap();
doc.remove("notify");
write_codex(&path, &doc).unwrap();
assert!(!std::fs::read_to_string(&path).unwrap().contains("notify ="));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_wedged_cctop_does_not_hold_the_agent_up() {
use std::io::Write;
use std::os::unix::net::{UnixListener, UnixStream};
let dir = scratch("wedge");
let path = dir.join("hooks.sock");
let listener = UnixListener::bind(&path).expect("bind");
let _wedge: Vec<UnixStream> = (0..8)
.filter_map(|_| UnixStream::connect(&path).ok())
.collect();
let started = std::time::Instant::now();
let (tx, rx) = std::sync::mpsc::channel();
let target = path.clone();
std::thread::spawn(move || {
if let Ok(mut stream) = UnixStream::connect(&target) {
let _ = stream.set_write_timeout(Some(DEADLINE));
let _ = stream.write_all(b"{}\n");
}
let _ = tx.send(());
});
let finished = rx.recv_timeout(DEADLINE).is_ok();
let waited = started.elapsed();
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
assert!(
waited < DEADLINE * 2,
"the agent was held up for {waited:?} (finished={finished})"
);
}
#[cfg(unix)]
#[test]
fn the_hook_succeeds_when_nothing_is_listening() {
let started = std::time::Instant::now();
deliver(b"{\"event\":\"Stop\",\"session_id\":\"a\"}\n");
assert!(
started.elapsed() < DEADLINE * 2,
"the agent was held up for {:?}",
started.elapsed()
);
}
#[cfg(unix)]
#[test]
fn every_running_cctop_hears_the_same_event() {
let a = Listener::start().expect("first listener");
let b = Listener::start().expect("second listener");
assert!(a.peer_count() >= 1, "the second cctop was not advertised");
deliver(b"{\"event\":\"Stop\",\"session_id\":\"shared\"}\n");
let mine = |events: &[Event]| events.iter().any(|e| e.session_id == "shared");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let (mut got_a, mut got_b) = (Vec::new(), Vec::new());
while std::time::Instant::now() < deadline && !(mine(&got_a) && mine(&got_b)) {
got_a.extend(a.drain());
got_b.extend(b.drain());
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(mine(&got_a), "the first cctop missed the event");
assert!(mine(&got_b), "the second cctop missed the event");
}
#[cfg(unix)]
#[test]
fn a_dead_address_is_cleaned_up_by_the_next_event() {
let dir = socket_dir().expect("socket dir");
std::fs::create_dir_all(&dir).unwrap();
let stale = dir.join(format!("0-{}-stale.sock", std::process::id()));
std::fs::write(&stale, "").unwrap();
assert!(peers(&dir).contains(&stale));
deliver(b"{\"event\":\"Stop\",\"session_id\":\"a\"}\n");
assert!(!stale.exists(), "a dead address was left on disk");
}
}