use clap::{Parser, Subcommand, ValueEnum};
use heartbeat_rs::hook;
use heartbeat_rs::recover::{self, OrphanPolicy};
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(
name = "heartbeat-stop",
about = "Claude Code stop hook for autonomous agent loops",
long_about = "Reads from a JSONL inbox at a byte offset and outputs a block/approve \
decision. Used as a Stop hook in .claude/settings.json to keep a \
Claude Code session alive while the inbox has undelivered messages.\n\n\
Also provides `recover` subcommand for launcher-side orphan recovery."
)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, global = true)]
inbox: Option<PathBuf>,
#[arg(long, default_value = "drain")]
mode: CliMode,
#[arg(long, default_value = "2", value_parser = clap::value_parser!(u64))]
idle_interval: u64,
#[arg(long)]
signal_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum Command {
Recover {
#[arg(long, default_value = "deadletter")]
on_orphan: CliOrphanPolicy,
},
}
#[derive(Debug, Clone, ValueEnum)]
enum CliMode {
Drain,
Persist,
}
#[derive(Debug, Clone, ValueEnum)]
enum CliOrphanPolicy {
Retry,
Deadletter,
Drop,
}
impl From<CliMode> for hook::Mode {
fn from(m: CliMode) -> Self {
match m {
CliMode::Drain => hook::Mode::Drain,
CliMode::Persist => hook::Mode::Persist,
}
}
}
impl From<CliOrphanPolicy> for OrphanPolicy {
fn from(p: CliOrphanPolicy) -> Self {
match p {
CliOrphanPolicy::Retry => OrphanPolicy::Retry,
CliOrphanPolicy::Deadletter => OrphanPolicy::DeadLetter,
CliOrphanPolicy::Drop => OrphanPolicy::Drop,
}
}
}
fn main() {
let args = Args::parse();
match args.command {
Some(Command::Recover { on_orphan }) => {
let inbox = match args.inbox {
Some(p) => p,
None => {
eprintln!("heartbeat-stop recover: --inbox is required");
std::process::exit(1);
}
};
let policy = OrphanPolicy::from(on_orphan);
match recover::recover(&inbox, policy) {
Ok(outcome) => {
eprintln!("heartbeat-stop recover: {outcome:?}");
}
Err(e) => {
eprintln!("heartbeat-stop recover: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
None => {
let inbox = match args.inbox {
Some(p) => p,
None => {
eprintln!("heartbeat-stop: --inbox is required");
std::process::exit(1);
}
};
let mode = hook::Mode::from(args.mode.clone());
if matches!(args.mode, CliMode::Persist) && args.idle_interval > 0 {
eprintln!(
"heartbeat-stop: idle sleep {}s — ensure hook timeout > {}s",
args.idle_interval, args.idle_interval
);
}
let decision = match hook::run(&inbox, &mode, args.idle_interval) {
Ok(d) => d,
Err(e) => {
eprintln!("heartbeat-stop: error: {e}");
hook::Decision::Approve
}
};
if matches!(decision, hook::Decision::Approve) {
if let Some(ref sig) = args.signal_file {
if let Err(e) = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(sig)
{
eprintln!(
"heartbeat-stop: warning: could not touch signal file {}: {e}",
sig.display()
);
}
}
}
let output = hook::serialize(&decision);
if !output.is_empty() {
if let Err(e) = std::io::stdout().write_all(output.as_bytes()) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("heartbeat-stop: fatal: stdout write failed: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
}
}