use crate::args::Args;
use crate::event::VivacKind;
use crate::failure::{Failure, R};
use crate::output::outln;
fn envelope(event_name: &str, text: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": text,
}
})
.to_string()
}
struct HookInput {
source: String,
session: Option<String>,
}
impl HookInput {
fn read() -> HookInput {
use std::io::IsTerminal;
let mut raw = String::new();
if !std::io::stdin().is_terminal() {
use std::io::Read;
std::io::stdin().read_to_string(&mut raw).ok();
}
let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
HookInput {
source: v
.get("source")
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string(),
session: v
.get("session_id")
.and_then(|s| s.as_str())
.map(str::to_string),
}
}
}
pub fn start(ctx: &mut crate::ops::Ctx, a: &Args, project: &str) -> R {
if !a.has("hook") {
return crate::brief::brief(&ctx.tree, ctx.anchor.as_ref(), a, project);
}
let text = crate::brief::to_text(&ctx.tree, ctx.anchor.as_ref(), a, project)?;
outln!("{}", envelope("SessionStart", &text));
let hook = HookInput::read();
crate::ops::session_started(ctx, &hook.source, hook.session).ok();
Ok(())
}
pub fn end(ctx: &mut crate::ops::Ctx, a: &Args) -> R {
if ctx.tree.stack.is_empty() {
if !a.has("hook") {
outln!(" Empty stack: no stop worth saving.");
}
return Ok(());
}
if ctx.tree.seq_change <= ctx.tree.seq_vivac {
if !a.has("hook") {
outln!(" Nothing changed since the last stop.");
}
return Ok(());
}
let next = a.opt_or("next");
let label = segment_label(&ctx.tree);
let num = ctx.tree.next_vivac_num.max(1);
crate::ops::auto_vivac(ctx, VivacKind::Auto, &next, &label)?;
if !a.has("hook") {
outln!(" v{num} automatic stop at session close");
}
Ok(())
}
fn segment_label(t: &crate::model::Tree) -> String {
let mut parts = Vec::new();
if t.seg_new > 0 {
parts.push(format!("{} new", t.seg_new));
}
if t.seg_closed > 0 {
parts.push(format!("{} closed", t.seg_closed));
}
if t.seg_notes == 1 {
parts.push("1 note".to_string());
} else if t.seg_notes > 1 {
parts.push(format!("{} notes", t.seg_notes));
}
if parts.is_empty() && t.seg_events > 0 {
parts.push(if t.seg_events == 1 {
"1 change".to_string()
} else {
format!("{} changes", t.seg_events)
});
}
parts.join(", ")
}
pub fn dispatch(ctx: &mut crate::ops::Ctx, a: &Args, project: &str) -> R {
match a.positional(0) {
Some("start") => start(ctx, a, project),
Some("end") => end(ctx, a),
_ => Err(Failure::usage("usage: vivac session start|end [--hook]")),
}
}
pub fn hooks() -> R {
outln!(
r#"
Paste this into the project's .claude/settings.json:
{{
"hooks": {{
"SessionStart": [
{{ "matcher": "*", "hooks": [{{ "type": "command", "command": "vivac session start --hook" }}] }}
],
"Stop": [
{{ "hooks": [{{ "type": "command", "command": "vivac session end --hook" }}] }}
]
}}
}}
SessionStart injects the brief into the agent's context, and fires again
after a compaction -- exactly when the brief has the most work to do
picking the thread back up.
Stop leaves an automatic stop with the stack as it stood.
Both stay quiet and exit 0 where there is no .vivac/, so they can be left
in the global configuration without getting in the way of other projects.
"#
);
Ok(())
}