use std::io::{IsTerminal, Read};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use code_kb_core::{
SessionState, Workspace, edit_check_session_diff, ensure_fts_index_path, format_stop_reason,
head_commit, index_written_by_other_extractor, installed_extractor_version, is_session_id,
lock_session, open_read_only, prune_sessions, read_session, record_session_base, write_session,
};
use serde_json::{Value, json};
const STDIN_WAIT: Duration = Duration::from_secs(2);
const LOCK_WAIT: Duration = Duration::from_secs(2);
pub fn is_stop_event(event: &str) -> bool {
event.eq_ignore_ascii_case("Stop") || event.eq_ignore_ascii_case("SubagentStop")
}
pub fn record_session_start() {
let _ = catch_unwind(AssertUnwindSafe(|| {
read_payload().and_then(|payload| record(&payload))
}));
}
pub fn run_stop() {
let decision = catch_unwind(AssertUnwindSafe(|| {
read_payload().and_then(|payload| stop_decision(&payload))
}));
if let Ok(Some(decision)) = decision {
println!("{decision}");
}
}
fn read_payload() -> Option<Value> {
if std::io::stdin().is_terminal() {
return None;
}
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
let _ = std::io::stdin().read_to_string(&mut text);
let _ = sender.send(text);
});
let text = receiver.recv_timeout(STDIN_WAIT).ok()?;
serde_json::from_str(&text).ok()
}
fn session_id(payload: &Value) -> Option<&str> {
payload
.get("session_id")?
.as_str()
.filter(|id| is_session_id(id))
}
fn payload_workspace(payload: &Value) -> Option<Workspace> {
let cwd = match payload.get("cwd") {
Some(Value::String(cwd)) => cwd.clone(),
Some(_) => return None,
None => std::env::current_dir().ok()?.to_str()?.to_string(),
};
Workspace::from_project_root(&cwd).ok()
}
fn existing_index(workspace: &Workspace) -> Option<PathBuf> {
workspace.locate_db(None).ok().filter(|db| db.is_file())
}
fn record(payload: &Value) -> Option<()> {
let id = session_id(payload)?;
let workspace = payload_workspace(payload)?;
let root = &workspace.canonical_root;
let head = head_commit(root)?;
prune_sessions(root);
let _lock = lock_session(root, id, LOCK_WAIT)?;
record_session_base(root, id, &head).ok()
}
fn stop_decision(payload: &Value) -> Option<String> {
if payload.get("stop_hook_active").and_then(Value::as_bool) == Some(true) {
return None;
}
let id = session_id(payload)?;
let workspace = payload_workspace(payload)?;
let db_path = existing_index(&workspace)?;
if index_written_by_other_extractor(&db_path, &installed_extractor_version()) {
return None;
}
let root = &workspace.canonical_root;
ensure_fts_index_path(&db_path).ok()?;
let conn = open_read_only(&db_path).ok()?;
let base = read_session(root, id).and_then(|state| state.base);
let pending = edit_check_session_diff(&workspace, &conn, base.as_deref()).ok()?;
let hash = pending.diff_hash().to_string();
let _lock = lock_session(root, id, LOCK_WAIT)?;
let state = read_session(root, id).unwrap_or_default();
if state.has_seen(&hash) {
return None;
}
let reason = format_stop_reason(&pending.run(&workspace, &db_path, &conn).ok()?);
let blocked_diff = match reason {
Some(_) => Some(hash.clone()),
None => state.blocked_diff.clone(),
};
let reported = SessionState {
blocked_diff,
checked_diff: Some(hash),
..state
};
write_session(root, id, &reported).ok()?;
reason.map(|reason| json!({ "decision": "block", "reason": reason }).to_string())
}