use crate::client;
use crate::config::Paths;
use crate::receive::Payload;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::Read;
use std::path::{Path, PathBuf};
const STATUS_EVENTS: [&str; 4] = ["UserPromptSubmit", "Notification", "Stop", "SessionEnd"];
#[derive(Deserialize, Default)]
#[serde(default)]
struct Event {
hook_event_name: Str,
cwd: Str,
session_id: Str,
tool_name: Str,
tool_input: ToolInput,
tool_response: ToolInput,
notification_type: Str,
message: Str,
source: Str,
session_title: Str,
}
#[derive(Default)]
struct ToolInput {
file_path: Str,
plan: Str,
plan_file: Str,
}
#[derive(Default)]
struct Str(Option<String>);
impl Str {
fn get(&self) -> Option<&str> {
self.0.as_deref()
}
}
macro_rules! ignore_the_rest {
($out:expr) => {
fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
Ok($out)
}
fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
Ok($out)
}
fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
Ok($out)
}
fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
Ok($out)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok($out)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut a: A,
) -> Result<Self::Value, A::Error> {
while a.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok($out)
}
};
}
impl<'de> Deserialize<'de> for Str {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Str, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Str;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("anything")
}
fn visit_str<E>(self, s: &str) -> Result<Str, E> {
Ok(Str(Some(s.to_string())))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Str, A::Error> {
while a
.next_entry::<serde::de::IgnoredAny, serde::de::IgnoredAny>()?
.is_some()
{}
Ok(Str(None))
}
ignore_the_rest!(Str(None));
}
d.deserialize_any(V)
}
}
impl<'de> Deserialize<'de> for ToolInput {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ToolInput, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = ToolInput;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("anything")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut a: A,
) -> Result<ToolInput, A::Error> {
let mut out = ToolInput::default();
while let Some(k) = a.next_key::<Str>()? {
match k.get() {
Some("file_path") => out.file_path = a.next_value()?,
Some("plan") => out.plan = a.next_value()?,
Some("planFilePath" | "filePath") => out.plan_file = a.next_value()?,
_ => {
a.next_value::<serde::de::IgnoredAny>()?;
}
}
}
Ok(out)
}
fn visit_str<E>(self, _: &str) -> Result<ToolInput, E> {
Ok(ToolInput::default())
}
ignore_the_rest!(ToolInput::default());
}
d.deserialize_any(V)
}
}
fn read_all(mut r: impl Read) -> std::io::Result<Vec<u8>> {
const FIRST: usize = 64 << 10;
const ROOM: usize = 32 << 20;
let mut head = Vec::with_capacity(FIRST);
(&mut r).take(FIRST as u64).read_to_end(&mut head)?;
if head.len() < FIRST {
return Ok(head);
}
let mut all = Vec::with_capacity(ROOM);
all.extend_from_slice(&head);
drop(head);
r.read_to_end(&mut all)?;
Ok(all)
}
pub fn run(paths: &Paths) -> Result<()> {
let input = read_all(std::io::stdin().lock())?;
let Ok(event) = serde_json::from_slice::<Event>(&input) else {
return Ok(());
};
drop(input);
let name = event.hook_event_name.get().unwrap_or("");
if matches!(name, "SessionStart" | "UserPromptSubmit") {
if let (Some(cwd), Some(sid)) = (event.cwd.get(), event.session_id.get()) {
crate::session::record(paths, cwd, sid);
}
}
let pane = std::env::var("SNYVI_SESSION")
.ok()
.filter(|v| crate::pane::valid_id(v));
if let Some(pane) = &pane {
let state = agent_state(&event);
let session = event
.session_id
.get()
.filter(|s| crate::desk::valid_session(s));
let starting = name == "SessionStart";
if state.is_some() || (starting && session.is_some()) {
client::agent_state(paths, pane, state, session);
}
if starting {
if let Some((context, title)) = client::brief(paths, pane) {
if let Some(out) = session_start_output(&event, &context, &title) {
println!("{out}");
}
}
return Ok(());
}
}
if event.tool_name.get() == Some("ExitPlanMode") && matches!(name, "PreToolUse" | "PostToolUse")
{
send_plan(paths, &event, pane.is_some());
return Ok(());
}
if name != "PostToolUse" {
return Ok(());
}
if !matches!(
event.tool_name.get().unwrap_or(""),
"Write" | "Edit" | "MultiEdit" | "NotebookEdit"
) {
return Ok(());
}
let Some(file) = event.tool_input.file_path.get() else {
return Ok(());
};
if !wanted(Path::new(file)) || !auto_send() {
return Ok(());
}
let payload = Payload {
path: Some(file.to_string()),
cwd: event.cwd.get().map(str::to_string),
session: event.session_id.get().map(crate::session::workflow_key),
origin: Some("hook".into()),
sender: Some("claude-code".into()),
..Default::default()
};
let _ = client::send(paths, &payload);
Ok(())
}
fn session_start_output(event: &Event, context: &str, title: &str) -> Option<String> {
let titled = matches!(event.source.get(), Some("startup" | "resume" | "fork"))
&& !title.is_empty()
&& event
.session_title
.get()
.is_none_or(|t| t.trim().is_empty() || our_title(t));
if context.is_empty() && !titled {
return None;
}
let mut out = json!({ "hookEventName": "SessionStart" });
if !context.is_empty() {
out["additionalContext"] = json!(context);
}
if titled {
out["sessionTitle"] = json!(title);
}
Some(json!({ "hookSpecificOutput": out }).to_string())
}
fn our_title(t: &str) -> bool {
t.rsplit_once(" · panel ").is_some_and(|(desk, n)| {
!desk.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
})
}
fn send_plan(paths: &Paths, event: &Event, in_pane: bool) {
let pre = event.hook_event_name.get() == Some("PreToolUse");
if !in_pane && !auto_send() {
return;
}
if !pre && plan_hook_installed() {
return;
}
let from = if pre {
&event.tool_input
} else {
&event.tool_response
};
let file = from
.plan_file
.get()
.filter(|f| Path::new(f).is_file())
.map(str::to_string);
let content = from.plan.get().filter(|p| !p.trim().is_empty());
if file.is_none() && content.is_none() {
return;
}
let payload = Payload {
content: if file.is_none() {
content.map(str::to_string)
} else {
None
},
path: file,
lang: Some("md".into()),
cwd: event.cwd.get().map(str::to_string),
session: event.session_id.get().map(crate::session::workflow_key),
origin: Some("plan".into()),
sender: Some("claude-code".into()),
..Default::default()
};
let within = std::time::Duration::from_millis(if pre { 5000 } else { 1500 });
let _ = client::send_quick(paths, &payload, within);
}
fn plan_hook_installed() -> bool {
settings_path()
.and_then(|p| read_settings(&p))
.map(|s| {
s.pointer("/hooks/PreToolUse")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|e| {
e.get("matcher").and_then(Value::as_str) == Some(PLAN_MATCHER)
&& e.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hs| hs.iter().any(ours))
})
})
.unwrap_or(false)
}
const PLAN_MATCHER: &str = "ExitPlanMode";
fn agent_state(event: &Event) -> Option<&'static str> {
match event.hook_event_name.get()? {
"UserPromptSubmit" | "PostToolUse" => Some("working"),
"Stop" => Some("done"),
"SessionEnd" => Some(""),
"Notification" => {
let idle = event.notification_type.get() == Some("idle_prompt")
|| event
.message
.get()
.is_some_and(|m| m.contains("waiting for your input"));
(!idle).then_some("needs_you")
}
_ => None,
}
}
fn auto_send() -> bool {
settings_path()
.and_then(|p| read_settings(&p))
.map(|s| installed(&s).iter().any(|(e, _)| *e == "PostToolUse"))
.unwrap_or(false)
}
fn wanted(path: &Path) -> bool {
let exts = std::env::var("SNYVI_HOOK_EXT").unwrap_or_else(|_| "md,markdown".into());
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let skip = path.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git" | "node_modules" | "target" | ".snyvi")
)
});
!skip && exts.split(',').any(|e| e.trim().eq_ignore_ascii_case(&ext))
}
pub fn command_line(program: &str) -> String {
if program.contains(' ') {
format!("\"{program}\" hook")
} else {
format!("{program} hook")
}
}
pub fn settings_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("no home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
pub fn read_settings(path: &Path) -> Result<Value> {
Ok(match std::fs::read_to_string(path) {
Ok(s) if !s.trim().is_empty() => {
serde_json::from_str(&s).context("parsing ~/.claude/settings.json")?
}
_ => json!({}),
})
}
fn write_settings(path: &Path, settings: &Value) -> Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_string_pretty(settings)?)?;
Ok(())
}
fn ours(h: &Value) -> bool {
h.get("command")
.and_then(Value::as_str)
.map(|c| c.ends_with(" hook") && c.contains("snyvi"))
.unwrap_or(false)
}
fn every_tool(entry: &Value) -> bool {
matches!(
entry.get("matcher").and_then(Value::as_str),
None | Some("" | "*")
)
}
pub fn install(command: &str, auto: bool) -> Result<(PathBuf, Vec<&'static str>, bool)> {
let path = settings_path()?;
let mut settings = read_settings(&path)?;
let (mut changed, rewritten) = install_into(&mut settings, command, auto)?;
changed |= statusline_into(&mut settings, command)?;
if changed {
write_settings(&path, &settings)?;
}
let events = installed(&settings).into_iter().map(|(e, _)| e).collect();
Ok((path, events, rewritten))
}
pub fn install_into(settings: &mut Value, command: &str, auto: bool) -> Result<(bool, bool)> {
let hooks = settings
.as_object_mut()
.context("settings.json is not an object")?
.entry("hooks")
.or_insert(json!({}));
let hooks = hooks.as_object_mut().context("hooks is not an object")?;
let mut changed = false;
let mut rewritten = false;
for (_, list) in hooks.iter_mut() {
let Some(list) = list.as_array_mut() else {
continue;
};
for entry in list.iter_mut() {
let Some(hs) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
continue;
};
for h in hs.iter_mut().filter(|h| ours(h)) {
if h.get("command").and_then(Value::as_str) != Some(command) {
h["command"] = json!(command);
changed = true;
rewritten = true;
}
}
}
}
let hook = |c: &str| json!({ "hooks": [{ "type": "command", "command": c, "timeout": 10 }] });
let mut wanted: Vec<(&str, Value)> = vec![("SessionStart", hook(command))];
for event in STATUS_EVENTS {
wanted.push((event, hook(command)));
}
wanted.push((
"PostToolUse",
json!({ "matcher": "*", "hooks": [{ "type": "command", "command": command, "timeout": 10 }] }),
));
wanted.push((
"PreToolUse",
json!({ "matcher": PLAN_MATCHER, "hooks": [{ "type": "command", "command": command, "async": true, "timeout": 10 }] }),
));
if auto {
wanted.push((
"PostToolUse",
json!({ "matcher": "Write|Edit|MultiEdit", "hooks": [{ "type": "command", "command": command, "timeout": 10 }] }),
));
}
for (event, entry) in wanted {
let is_status = every_tool(&entry);
let list = hooks.entry(event).or_insert(json!([]));
let list = list
.as_array_mut()
.with_context(|| format!("{event} is not an array"))?;
let already = list.iter().any(|e| {
every_tool(e) == is_status
&& e.pointer("/hooks")
.and_then(Value::as_array)
.map(|hs| hs.iter().any(ours))
.unwrap_or(false)
});
if !already {
list.push(entry);
changed = true;
}
}
Ok((changed, rewritten))
}
pub fn installed(settings: &Value) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
let Some(hooks) = settings.get("hooks").and_then(Value::as_object) else {
return out;
};
for event in [
"SessionStart",
"UserPromptSubmit",
"Notification",
"Stop",
"SessionEnd",
"PostToolUse",
] {
let found = hooks
.get(event)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|e| event != "PostToolUse" || !every_tool(e))
.filter_map(|e| e.get("hooks").and_then(Value::as_array))
.flatten()
.find(|h| ours(h))
.and_then(|h| h.get("command").and_then(Value::as_str))
.map(str::to_string);
if let Some(c) = found {
out.push((event, c));
}
}
out
}
pub fn top_up() -> Result<bool> {
let path = settings_path()?;
let mut settings = read_settings(&path)?;
let have = installed(&settings);
let Some((_, command)) = have.iter().find(|(e, _)| *e == "SessionStart") else {
return Ok(false);
};
let me = std::env::current_exe().ok();
if !me.is_some_and(|me| runs(command, &me)) {
return Ok(false);
}
let auto = have.iter().any(|(e, _)| *e == "PostToolUse");
let command = command.clone();
let (mut changed, _) = install_into(&mut settings, &command, auto)?;
changed |= statusline_into(&mut settings, &command)?;
if changed {
write_settings(&path, &settings)?;
}
Ok(changed)
}
fn statusline_into(settings: &mut Value, hook_command: &str) -> Result<bool> {
let command = format!("{} statusline", hook_command.trim_end_matches(" hook"));
crate::statusline::install_into(
settings,
&command,
&crate::statusline::before_path(&crate::config::paths()),
)
}
fn runs(command: &str, exe: &Path) -> bool {
let program = command.trim_end_matches(" hook").trim_matches('"');
let program = if Path::new(program).components().count() == 1 {
crate::platform::find_on_path(program)
} else {
Some(PathBuf::from(program))
};
let real = |p: &Path| std::fs::canonicalize(p).ok();
match (program.as_deref().and_then(real), real(exe)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
pub fn uninstall() -> Result<(PathBuf, usize)> {
let path = settings_path()?;
let mut settings = read_settings(&path)?;
let n = remove_from(&mut settings);
let line = crate::statusline::remove_from(
&mut settings,
&crate::statusline::before_path(&crate::config::paths()),
);
if n > 0 || line {
write_settings(&path, &settings)?;
}
Ok((path, n + usize::from(line)))
}
pub fn remove_from(settings: &mut Value) -> usize {
let mut removed = 0;
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let events: Vec<String> = hooks.keys().cloned().collect();
for event in events {
let Some(list) = hooks.get_mut(&event).and_then(Value::as_array_mut) else {
continue;
};
for entry in list.iter_mut() {
if let Some(hs) = entry.get_mut("hooks").and_then(Value::as_array_mut) {
let before = hs.len();
hs.retain(|h| !ours(h));
removed += before - hs.len();
}
}
list.retain(|e| {
e.get("hooks")
.and_then(Value::as_array)
.map(|hs| !hs.is_empty())
.unwrap_or(true)
});
if list.is_empty() {
hooks.remove(&event);
}
}
if hooks.is_empty() {
settings.as_object_mut().map(|o| o.remove("hooks"));
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stdin_is_read_whole_whatever_its_size() {
struct Dribble<'a>(&'a [u8]);
impl Read for Dribble<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = buf.len().min(self.0.len()).min(7);
buf[..n].copy_from_slice(&self.0[..n]);
self.0 = &self.0[n..];
Ok(n)
}
}
for len in [0, 1, (64 << 10) - 1, 64 << 10, (64 << 10) + 1, 700_001] {
let sent: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
assert_eq!(read_all(&sent[..]).unwrap(), sent, "{len}");
assert_eq!(read_all(Dribble(&sent)).unwrap(), sent, "{len} dribbled");
}
}
fn state_of(v: &Value) -> Option<&'static str> {
agent_state(&serde_json::from_slice(v.to_string().as_bytes()).unwrap())
}
#[test]
fn an_odd_field_does_not_cost_the_event() {
let odd = json!({ "hook_event_name": "PostToolUse", "cwd": null, "session_id": 7,
"tool_name": "Write", "message": ["a", { "b": 1 }],
"tool_input": { "content": "x".repeat(1000), "file_path": "/p/PLAN.md", "extra": [1, 2] } });
let e: Event = serde_json::from_slice(odd.to_string().as_bytes()).unwrap();
assert_eq!(agent_state(&e), Some("working"));
assert_eq!(e.tool_input.file_path.get(), Some("/p/PLAN.md"));
assert_eq!(e.cwd.get(), None);
assert_eq!(e.session_id.get(), None);
for input in [
json!(null),
json!("x"),
json!([1]),
json!({ "file_path": 3 }),
] {
let ev = json!({ "hook_event_name": "PostToolUse", "tool_input": input });
let e: Event = serde_json::from_slice(ev.to_string().as_bytes()).unwrap();
assert_eq!(agent_state(&e), Some("working"));
assert_eq!(e.tool_input.file_path.get(), None);
}
}
#[test]
fn extension_filter() {
assert!(wanted(Path::new("/p/PLAN.md")));
assert!(wanted(Path::new("/p/notes.MD")));
assert!(!wanted(Path::new("/p/main.rs")));
assert!(!wanted(Path::new("/p/node_modules/x/README.md")));
assert!(!wanted(Path::new("/p/.git/x.md")));
}
#[test]
fn each_event_says_what_the_agent_is_doing() {
let ev = |e: &str| json!({ "hook_event_name": e });
assert_eq!(state_of(&ev("UserPromptSubmit")), Some("working"));
assert_eq!(state_of(&ev("PostToolUse")), Some("working"));
assert_eq!(state_of(&ev("Stop")), Some("done"));
assert_eq!(state_of(&ev("SessionEnd")), Some(""));
assert_eq!(state_of(&ev("SessionStart")), None);
assert_eq!(
state_of(
&json!({ "hook_event_name": "Notification", "notification_type": "permission_prompt",
"message": "Claude needs your permission to use Bash" })
),
Some("needs_you")
);
assert_eq!(
state_of(
&json!({ "hook_event_name": "Notification", "notification_type": "idle_prompt" })
),
None
);
assert_eq!(
state_of(&json!({ "hook_event_name": "Notification",
"message": "Claude is waiting for your input" })),
None
);
}
const ALL: [&str; 5] = [
"SessionStart",
"UserPromptSubmit",
"Notification",
"Stop",
"SessionEnd",
];
#[test]
fn install_is_idempotent_and_follows_the_binary() {
let mut s = json!({ "theme": "dark", "hooks": { "PostToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "other --x" }] } ] } });
let (changed, rewritten) = install_into(&mut s, "/opt/snyvi hook", false).unwrap();
assert!(changed && !rewritten);
assert_eq!(
installed(&s),
ALL.map(|e| (e, "/opt/snyvi hook".to_string())).to_vec()
);
let post = s["hooks"]["PostToolUse"].as_array().unwrap();
assert_eq!(post.len(), 2);
assert_eq!(post[1]["matcher"], "*");
assert_eq!(post[1]["hooks"][0]["command"], "/opt/snyvi hook");
assert!(!installed(&s).iter().any(|(e, _)| *e == "PostToolUse"));
let pre = s["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 1);
assert_eq!(pre[0]["matcher"], "ExitPlanMode");
assert_eq!(pre[0]["hooks"][0]["async"], true);
assert_eq!(pre[0]["hooks"][0]["command"], "/opt/snyvi hook");
assert_eq!(
install_into(&mut s, "/opt/snyvi hook", false).unwrap(),
(false, false)
);
assert_eq!(
install_into(&mut s, "/opt/snyvi hook", true).unwrap(),
(true, false)
);
assert_eq!(installed(&s).len(), 6);
assert_eq!(s["hooks"]["PostToolUse"].as_array().unwrap().len(), 3);
assert_eq!(
install_into(&mut s, "/opt/snyvi hook", true).unwrap(),
(false, false)
);
assert_eq!(
install_into(&mut s, "snyvi hook", false).unwrap(),
(true, true)
);
assert!(installed(&s).iter().all(|(_, c)| c == "snyvi hook"));
assert_eq!(installed(&s).last().unwrap().0, "PostToolUse");
assert_eq!(
s["hooks"]["PostToolUse"][1]["hooks"][0]["command"],
"snyvi hook"
);
assert_eq!(
s["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
"other --x"
);
}
#[test]
fn an_older_install_is_topped_up_with_its_own_choices() {
let mut old = json!({ "hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "/usr/bin/snyvi hook" }] }],
"PostToolUse": [{ "matcher": "Write|Edit|MultiEdit",
"hooks": [{ "type": "command", "command": "/usr/bin/snyvi hook" }] }] } });
let have = installed(&old);
assert_eq!(have.len(), 2);
assert!(
install_into(&mut old, "/usr/bin/snyvi hook", true)
.unwrap()
.0
);
assert_eq!(installed(&old).len(), 6);
assert_eq!(old["hooks"]["PostToolUse"].as_array().unwrap().len(), 2);
assert_eq!(old["hooks"]["PostToolUse"][1]["matcher"], "*");
}
#[test]
fn only_the_binary_a_hook_names_tops_it_up() {
let me = std::env::current_exe().unwrap();
let line = command_line(&me.to_string_lossy());
assert!(runs(&line, &me));
let dir = crate::store::tempdir::Dir::new("snyvi-hook-bin");
let other = dir.path.join("snyvi");
std::fs::write(&other, b"").unwrap();
assert!(!runs(&command_line(&other.to_string_lossy()), &me));
assert!(!runs("/nowhere/snyvi hook", &me));
}
#[test]
fn uninstall_leaves_what_it_found() {
let before = json!({ "theme": "dark", "hooks": { "PostToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "other --x" }] } ] } });
let mut s = before.clone();
install_into(&mut s, "/opt/snyvi hook", true).unwrap();
assert_eq!(remove_from(&mut s), 8);
assert_eq!(s, before);
let mut s = json!({ "theme": "dark" });
install_into(&mut s, "snyvi hook", true).unwrap();
assert_eq!(remove_from(&mut s), 8);
assert_eq!(s, json!({ "theme": "dark" }));
assert_eq!(remove_from(&mut s), 0);
}
fn event(v: Value) -> Event {
serde_json::from_slice(v.to_string().as_bytes()).unwrap()
}
#[test]
fn a_session_start_is_handed_the_brief_and_named_after_its_panel() {
let out = |source: &str, title: Option<&str>| {
let mut v = json!({ "hook_event_name": "SessionStart", "source": source });
if let Some(t) = title {
v["session_title"] = json!(t);
}
session_start_output(&event(v), "the brief", "ledger · panel 2")
.map(|o| serde_json::from_str::<Value>(&o).unwrap()["hookSpecificOutput"].clone())
};
for source in ["startup", "resume", "fork"] {
let o = out(source, None).unwrap();
assert_eq!(o["hookEventName"], "SessionStart");
assert_eq!(o["additionalContext"], "the brief", "{source}");
assert_eq!(o["sessionTitle"], "ledger · panel 2", "{source}");
}
for source in ["clear", "compact"] {
let o = out(source, None).unwrap();
assert_eq!(o["additionalContext"], "the brief", "{source}");
assert!(o.get("sessionTitle").is_none(), "{source}");
}
assert!(out("resume", Some("auth refactor"))
.unwrap()
.get("sessionTitle")
.is_none());
assert_eq!(
out("resume", Some("chores · panel 1")).unwrap()["sessionTitle"],
"ledger · panel 2"
);
assert_eq!(
out("startup", Some(" ")).unwrap()["sessionTitle"],
"ledger · panel 2"
);
let off = event(json!({ "hook_event_name": "SessionStart", "source": "startup" }));
assert_eq!(session_start_output(&off, "", ""), None);
assert!(
our_title("a · b · panel 12") && !our_title("panel 2") && !our_title("x · panel two")
);
}
#[test]
fn a_plan_is_read_from_the_input_and_from_the_response() {
let pre = event(
json!({ "hook_event_name": "PreToolUse", "tool_name": "ExitPlanMode",
"tool_input": { "plan": "# Ship it", "planFilePath": "/h/.claude/plans/ship.md", "allowedPrompts": [] } }),
);
assert_eq!(pre.tool_input.plan.get(), Some("# Ship it"));
assert_eq!(
pre.tool_input.plan_file.get(),
Some("/h/.claude/plans/ship.md")
);
let post = event(
json!({ "hook_event_name": "PostToolUse", "tool_name": "ExitPlanMode",
"tool_input": {}, "tool_response": { "plan": "# Ship it", "filePath": "/h/p.md", "isAgent": false } }),
);
assert_eq!(post.tool_response.plan.get(), Some("# Ship it"));
assert_eq!(post.tool_response.plan_file.get(), Some("/h/p.md"));
let read = event(
json!({ "hook_event_name": "PostToolUse", "tool_name": "Read",
"tool_response": { "file": { "content": "x".repeat(10_000) } } }),
);
assert_eq!(read.tool_response.plan.get(), None);
assert_eq!(
agent_state(&pre),
None,
"a PreToolUse says nothing new about the agent"
);
}
}