use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const WINDOW_MS: i64 = 30 * 60 * 1000;
const MAX_ENTRIES: usize = 256;
const LOCK_PATIENCE: Duration = Duration::from_millis(25);
const MAX_NAMED: usize = 3;
const CLAUDE_WRITES: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
const GEMINI_WRITES: [&str; 2] = ["write_file", "replace"];
const CODEX_WRITES: [&str; 2] = ["apply_patch", "ApplyPatch"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Agent {
pid: u32,
start: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Entry {
path: String,
session: String,
harness: String,
cwd: String,
at: i64,
agent: Agent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reply {
Context(&'static str),
}
#[derive(Debug, PartialEq)]
struct Plan {
check: Option<Reply>,
record: bool,
files: Vec<String>,
harness: &'static str,
}
fn plan(event: &str, body: &Value) -> Option<Plan> {
let tool = body.get("tool_name").and_then(Value::as_str)?;
let cursor = body.get("cursor_version").is_some();
let (check, record, harness) = match event {
"PreToolUse" if CLAUDE_WRITES.contains(&tool) && !cursor => {
(Some(Reply::Context("PreToolUse")), false, "Claude Code")
}
"PostToolUse" if CLAUDE_WRITES.contains(&tool) => (
None,
true,
match cursor {
true => "Cursor",
false => "Claude Code",
},
),
"PostToolUse" if CODEX_WRITES.contains(&tool) => (None, true, "Codex"),
"AfterTool" if GEMINI_WRITES.contains(&tool) => {
(Some(Reply::Context("AfterTool")), true, "Gemini CLI")
}
_ => return None,
};
let cwd = body.get("cwd").and_then(Value::as_str).unwrap_or_default();
let input = body.get("tool_input");
let mut named: Vec<String> = ["file_path", "notebook_path", "path"]
.iter()
.filter_map(|k| input?.get(*k)?.as_str())
.map(str::to_string)
.collect();
if CODEX_WRITES.contains(&tool) {
let patch = input.and_then(|i| {
i.as_str().or_else(|| {
["command", "input", "patch"]
.iter()
.find_map(|k| i.get(*k)?.as_str())
})
});
named.extend(
patch
.map(crate::session::extract::patch_files)
.unwrap_or_default(),
);
}
let mut files: Vec<String> = named
.iter()
.filter(|p| !p.trim().is_empty())
.map(|p| crate::collide::normalise(p, cwd))
.filter(|p| crate::collide::contested(p))
.collect();
files.sort();
files.dedup();
(!files.is_empty()).then_some(Plan {
check,
record,
files,
harness,
})
}
struct Me<'a> {
session: &'a str,
cwd: &'a str,
agent: Option<Agent>,
}
pub fn consider(name: &str, payload: &[u8], chain: &[u32]) -> Option<String> {
let body: Value = serde_json::from_slice(payload).ok()?;
let event = match name.is_empty() {
true => body.get("hook_event_name").and_then(Value::as_str)?,
false => name,
};
let plan = plan(event, &body)?;
if crate::settings::Settings::load().warn_agents != Some(true) {
return None;
}
let me = Me {
session: body.get("session_id").and_then(Value::as_str)?,
cwd: body.get("cwd").and_then(Value::as_str).unwrap_or_default(),
agent: agent_of(chain),
};
let ledger = Ledger::at(crate::hook::socket_dir()?);
let advice = run(&ledger, &plan, &me, crate::util::now_ms(), alive);
if advice.is_some() {
crate::elog::event(
"hook",
"advise",
serde_json::json!({ "event": event, "files": plan.files.len() }),
);
}
advice
}
fn run(
ledger: &Ledger,
plan: &Plan,
me: &Me,
now: i64,
alive: impl Fn(&Agent) -> bool,
) -> Option<String> {
if me.session.is_empty() {
return None;
}
let advice = plan.check.and_then(|reply| {
let held = holders(&ledger.load(), &plan.files, me, now, &alive);
(!held.is_empty()).then(|| render(reply, &held, now))
});
if plan.record
&& let Some(agent) = me.agent
{
ledger.record(&plan.files, me.session, plan.harness, me.cwd, agent, now);
}
advice
}
fn holders(
entries: &[Entry],
files: &[String],
me: &Me,
now: i64,
alive: &impl Fn(&Agent) -> bool,
) -> Vec<Entry> {
let mut found: Vec<Entry> = entries
.iter()
.filter(|e| files.contains(&e.path))
.filter(|e| e.session != me.session)
.filter(|e| Some(e.agent) != me.agent)
.filter(|e| (0..=WINDOW_MS).contains(&(now - e.at)))
.filter(|e| alive(&e.agent))
.cloned()
.collect();
found.sort_by_key(|e| std::cmp::Reverse(e.at));
let mut seen = std::collections::HashSet::new();
found.retain(|e| seen.insert((e.session.clone(), e.path.clone())));
found.truncate(MAX_NAMED);
found
}
fn render(reply: Reply, held: &[Entry], now: i64) -> String {
let mut text = String::from(
"cctop: another agent that is still running on this machine wrote this file recently.\n",
);
for e in held {
let short: String = e.session.chars().take(8).collect();
text.push_str(&format!(
"- {} — {} ago, by {} session {short} working in {}\n",
e.path,
crate::util::compact_duration(now - e.at),
e.harness,
e.cwd,
));
}
text.push_str(
"Two agents editing one file do not merge: whichever writes last silently replaces \
the other's work. Re-read the file before changing it again, and settle who finishes \
first — or move one of you into a separate git worktree.",
);
let Reply::Context(event) = reply;
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": text,
}
})
.to_string()
}
struct Ledger {
dir: PathBuf,
patience: Duration,
}
impl Ledger {
fn at(dir: PathBuf) -> Ledger {
Ledger {
dir,
patience: LOCK_PATIENCE,
}
}
fn file(&self) -> PathBuf {
self.dir.join("writes.json")
}
fn load(&self) -> Vec<Entry> {
std::fs::read(self.file())
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn record(
&self,
files: &[String],
session: &str,
harness: &str,
cwd: &str,
agent: Agent,
now: i64,
) {
if std::fs::create_dir_all(&self.dir).is_err() {
return;
}
let _ = std::fs::set_permissions(
&self.dir,
std::os::unix::fs::PermissionsExt::from_mode(0o700),
);
let Some(_lock) = Lock::take(&self.dir.join("writes.lock"), self.patience) else {
return;
};
let mut entries = self.load();
entries.retain(|e| {
(0..=WINDOW_MS).contains(&(now - e.at))
&& !(e.session == session && files.contains(&e.path))
});
entries.extend(files.iter().map(|path| Entry {
path: path.clone(),
session: session.to_string(),
harness: harness.to_string(),
cwd: cwd.to_string(),
at: now,
agent,
}));
entries.sort_by_key(|e| std::cmp::Reverse(e.at));
entries.truncate(MAX_ENTRIES);
let Ok(json) = serde_json::to_vec(&entries) else {
return;
};
let tmp = self.dir.join("writes.json.tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, self.file());
}
}
}
struct Lock {
_held: std::fs::File,
}
impl Lock {
fn take(path: &Path, patience: Duration) -> Option<Lock> {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
.ok()?;
let started = Instant::now();
loop {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Some(Lock { _held: file });
}
if started.elapsed() >= patience {
return None;
}
std::thread::sleep(Duration::from_millis(1));
}
}
}
const WRAPPERS: [&str; 10] = [
"sh", "bash", "dash", "zsh", "fish", "ksh", "mksh", "env", "timeout", "nohup",
];
fn agent_of(chain: &[u32]) -> Option<Agent> {
chain.iter().find_map(|&pid| {
let (comm, start) = stat(pid)?;
(!WRAPPERS.contains(&comm.as_str())).then_some(Agent { pid, start })
})
}
fn alive(agent: &Agent) -> bool {
stat(agent.pid).is_some_and(|(_, start)| start == agent.start)
}
fn stat(pid: u32) -> Option<(String, u64)> {
let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
parse_stat(&text)
}
fn parse_stat(text: &str) -> Option<(String, u64)> {
let open = text.find('(')?;
let (head, rest) = text.rsplit_once(')')?;
let comm = head.get(open + 1..)?.to_string();
let start = rest.split_whitespace().nth(19)?.parse().ok()?;
Some((comm, start))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const NOW: i64 = 1_800_000_000_000;
fn scratch(name: &str) -> Ledger {
let dir = std::env::temp_dir().join(format!(
"cctop-advise-{}-{name}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
Ledger {
dir,
patience: Duration::from_secs(2),
}
}
fn agent(pid: u32) -> Option<Agent> {
Some(Agent { pid, start: 7 })
}
fn me<'a>(session: &'a str, pid: u32) -> Me<'a> {
Me {
session,
cwd: "/repo",
agent: agent(pid),
}
}
fn claude(event: &str, tool: &str, file: &str) -> Value {
json!({
"session_id": "ignored-here",
"cwd": "/repo",
"hook_event_name": event,
"tool_name": tool,
"tool_input": { "file_path": file, "old_string": "a", "new_string": "b" },
})
}
fn everyone_alive(_: &Agent) -> bool {
true
}
fn a_then_b(ledger: &Ledger, gap_ms: i64, alive: impl Fn(&Agent) -> bool) -> Option<String> {
let wrote = plan("PostToolUse", &claude("PostToolUse", "Edit", "src/ui.rs")).unwrap();
assert_eq!(
run(ledger, &wrote, &me("aaaaaaaa-1111", 100), NOW, &alive),
None
);
let about = plan(
"PreToolUse",
&claude("PreToolUse", "Edit", "/repo/src/ui.rs"),
)
.unwrap();
run(
ledger,
&about,
&me("bbbbbbbb-2222", 200),
NOW + gap_ms,
&alive,
)
}
#[test]
fn the_second_agent_is_told_who_wrote_its_file() {
let ledger = scratch("told");
let answer = a_then_b(&ledger, 3 * 60 * 1000, everyone_alive).expect("advice");
let value: Value = serde_json::from_str(&answer).expect("one JSON object");
let out = value["hookSpecificOutput"]
.as_object()
.expect("hook output");
assert_eq!(out["hookEventName"], "PreToolUse");
let text = out["additionalContext"].as_str().unwrap();
assert!(text.contains("/repo/src/ui.rs"), "{text}");
assert!(text.contains("aaaaaaaa"), "names the peer: {text}");
assert!(text.contains("3m00s ago"), "says how long ago: {text}");
assert!(text.contains("Claude Code"), "{text}");
assert_eq!(value.as_object().unwrap().len(), 1);
assert_eq!(out.len(), 2, "a decision snuck into the advice: {out:?}");
let _ = std::fs::remove_dir_all(&ledger.dir);
}
#[test]
fn nobody_is_warned_about_themselves() {
let ledger = scratch("self");
let wrote = plan("PostToolUse", &claude("PostToolUse", "Write", "src/x.rs")).unwrap();
let about = plan("PreToolUse", &claude("PreToolUse", "Edit", "src/x.rs")).unwrap();
run(&ledger, &wrote, &me("one", 100), NOW, everyone_alive);
assert_eq!(
run(&ledger, &about, &me("one", 100), NOW, everyone_alive),
None
);
assert_eq!(
run(
&ledger,
&about,
&me("after-clear", 100),
NOW,
everyone_alive
),
None,
"the same process is the same agent"
);
let _ = std::fs::remove_dir_all(&ledger.dir);
}
#[test]
fn a_finished_or_stale_peer_is_not_mentioned() {
let dead = scratch("dead");
assert_eq!(a_then_b(&dead, 1000, |_| false), None);
let stale = scratch("stale");
assert_eq!(a_then_b(&stale, WINDOW_MS + 1, everyone_alive), None);
for l in [dead, stale] {
let _ = std::fs::remove_dir_all(&l.dir);
}
}
#[test]
fn a_reused_pid_is_not_the_agent_that_wrote() {
let me = std::process::id();
let (_, start) = stat(me).expect("own stat");
assert!(alive(&Agent { pid: me, start }));
assert!(!alive(&Agent {
pid: me,
start: start + 1
}));
assert!(!alive(&Agent {
pid: u32::MAX,
start
}));
}
#[test]
fn a_process_name_with_parens_still_parses() {
let line = "42 (we(i)rd name) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 99 20";
assert_eq!(parse_stat(line), Some(("we(i)rd name".into(), 99)));
assert_eq!(parse_stat("garbage"), None);
}
#[test]
fn only_a_file_write_is_considered() {
for (event, tool) in [
("PreToolUse", "Bash"),
("PreToolUse", "Read"),
("PostToolUse", "Grep"),
("Stop", "Edit"),
("BeforeTool", "write_file"),
] {
assert_eq!(
plan(event, &claude(event, tool, "src/x.rs")),
None,
"{event} {tool}"
);
}
assert_eq!(plan("PreToolUse", &json!({ "session_id": "s" })), None);
}
#[test]
fn prose_and_lock_files_are_not_worth_a_warning() {
for file in ["MEMORY.md", "Cargo.lock", "package-lock.json"] {
assert_eq!(
plan("PreToolUse", &claude("PreToolUse", "Edit", file)),
None
);
}
}
#[test]
fn only_harnesses_with_a_documented_context_channel_are_answered() {
let patch =
"*** Begin Patch\n*** Update File: src/a.rs\n*** Add File: src/b.rs\n*** End Patch";
let codex = json!({
"session_id": "c", "cwd": "/repo", "tool_name": "apply_patch",
"tool_input": { "command": patch },
});
assert_eq!(plan("PreToolUse", &codex), None);
let wrote = plan("PostToolUse", &codex).expect("recorded");
assert_eq!(wrote.check, None);
assert_eq!(wrote.files, ["/repo/src/a.rs", "/repo/src/b.rs"]);
let mut cursor = claude("PreToolUse", "Write", "src/a.rs");
cursor["cursor_version"] = json!("1.7.2");
assert_eq!(plan("PreToolUse", &cursor), None);
assert!(plan("PostToolUse", &cursor).is_some_and(|p| p.record));
}
#[test]
fn gemini_is_told_after_the_tool_and_records_its_own_write() {
let ledger = scratch("gemini");
let wrote = plan("PostToolUse", &claude("PostToolUse", "Edit", "src/ui.rs")).unwrap();
run(&ledger, &wrote, &me("claude-a", 100), NOW, everyone_alive);
let gemini = json!({
"session_id": "g", "cwd": "/repo", "tool_name": "replace",
"tool_input": { "file_path": "/repo/src/ui.rs" },
});
let after = plan("AfterTool", &gemini).unwrap();
let answer =
run(&ledger, &after, &me("gemini-b", 200), NOW, everyone_alive).expect("advice");
let value: Value = serde_json::from_str(&answer).unwrap();
assert_eq!(value["hookSpecificOutput"]["hookEventName"], "AfterTool");
let about = plan("PreToolUse", &claude("PreToolUse", "Edit", "src/ui.rs")).unwrap();
let back = run(&ledger, &about, &me("claude-a", 100), NOW, everyone_alive)
.expect("the record went both ways");
assert!(back.contains("Gemini CLI"), "{back}");
let _ = std::fs::remove_dir_all(&ledger.dir);
}
#[test]
fn a_broken_ledger_is_silence() {
let about = plan("PreToolUse", &claude("PreToolUse", "Edit", "src/x.rs")).unwrap();
let mangled = scratch("mangled");
std::fs::create_dir_all(&mangled.dir).unwrap();
std::fs::write(mangled.file(), b"{ not json").unwrap();
assert_eq!(
run(&mangled, &about, &me("b", 2), NOW, everyone_alive),
None
);
let blocked = scratch("blocked");
std::fs::write(&blocked.dir, b"").unwrap();
let wrote = plan("PostToolUse", &claude("PostToolUse", "Edit", "src/x.rs")).unwrap();
assert_eq!(
run(&blocked, &wrote, &me("a", 1), NOW, everyone_alive),
None
);
assert_eq!(
run(&blocked, &about, &me("b", 2), NOW, everyone_alive),
None
);
let _ = std::fs::remove_dir_all(&mangled.dir);
let _ = std::fs::remove_file(&blocked.dir);
}
#[test]
fn a_held_lock_gives_up_well_inside_the_deadline() {
let ledger = Ledger::at(scratch("held").dir);
std::fs::create_dir_all(&ledger.dir).unwrap();
let held = Lock::take(&ledger.dir.join("writes.lock"), LOCK_PATIENCE).expect("first");
let wrote = plan("PostToolUse", &claude("PostToolUse", "Edit", "src/x.rs")).unwrap();
let started = Instant::now();
assert_eq!(run(&ledger, &wrote, &me("a", 1), NOW, everyone_alive), None);
let waited = started.elapsed();
assert!(waited < crate::hook::DEADLINE, "waited {waited:?}");
assert!(ledger.load().is_empty(), "wrote without the lock");
drop(held);
run(&ledger, &wrote, &me("a", 1), NOW, everyone_alive);
assert_eq!(ledger.load().len(), 1, "the lock came back");
let _ = std::fs::remove_dir_all(&ledger.dir);
}
#[test]
fn the_ledger_stays_small() {
let ledger = scratch("small");
let wrote = plan("PostToolUse", &claude("PostToolUse", "Edit", "src/x.rs")).unwrap();
run(
&ledger,
&wrote,
&me("a", 1),
NOW - WINDOW_MS - 1,
everyone_alive,
);
run(&ledger, &wrote, &me("b", 2), NOW - 10, everyone_alive);
run(&ledger, &wrote, &me("b", 2), NOW, everyone_alive);
let kept = ledger.load();
assert_eq!(kept.len(), 1, "{kept:?}");
assert_eq!((kept[0].session.as_str(), kept[0].at), ("b", NOW));
for i in 0..(MAX_ENTRIES + 10) {
let file = format!("src/f{i}.rs");
let p = plan("PostToolUse", &claude("PostToolUse", "Edit", &file)).unwrap();
run(&ledger, &p, &me("c", 3), NOW, everyone_alive);
}
assert_eq!(ledger.load().len(), MAX_ENTRIES);
let _ = std::fs::remove_dir_all(&ledger.dir);
}
#[test]
fn off_by_default_means_silent_and_unrecorded() {
assert_eq!(
crate::settings::Settings::parse("").warn_agents,
None,
"the default must be off"
);
if crate::settings::Settings::load().warn_agents == Some(true) {
return;
}
let payload = serde_json::to_vec(&claude("PreToolUse", "Edit", "src/x.rs")).unwrap();
assert_eq!(consider("PreToolUse", &payload, &[]), None);
assert_eq!(consider("", b"not json", &[]), None);
}
#[test]
fn the_agent_is_the_first_ancestor_that_is_not_a_shell() {
let chain: Vec<u32> = std::iter::successors(stat_parent(std::process::id()), |p| {
stat_parent(*p).filter(|pp| *pp > 1)
})
.take(8)
.collect();
if let Some(found) = agent_of(&chain) {
assert!(alive(&found));
let (comm, _) = stat(found.pid).unwrap();
assert!(!WRAPPERS.contains(&comm.as_str()), "{comm}");
}
assert_eq!(agent_of(&[]), None);
assert_eq!(agent_of(&[u32::MAX]), None);
}
fn stat_parent(pid: u32) -> Option<u32> {
let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
text.rsplit_once(')')?
.1
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
}