use std::{fmt::Write as _, fs};
use serde::{Deserialize, Serialize};
use crate::{
Paths,
config::{Config, HandoffConfig},
context_signal::{self, Signal},
};
use brgr_store::Store;
const MAX_AGE: u64 = 30 * 60;
const REPEAT_AFTER: u64 = 30 * 60;
const REARM_GAP: f64 = 15.0;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct State {
armed: bool,
last_note_at: u64,
}
impl Default for State {
fn default() -> Self {
Self {
armed: true,
last_note_at: 0,
}
}
}
struct Over {
reasons: Vec<String>,
rearmed: bool,
}
fn measure(config: &HandoffConfig, signal: &Signal) -> Over {
let mut reasons = Vec::new();
let mut rearmed = true;
let mut check = |label: String, value: Option<f64>, limit: Option<u8>| {
let Some(limit) = limit.map(f64::from) else {
return;
};
match value {
Some(value) if value >= limit => {
rearmed = false;
reasons.push(format!("{label} {value:.0}% (limit {limit:.0}%)"));
}
Some(value) if value >= limit - REARM_GAP => rearmed = false,
Some(_) => {}
None => rearmed = false,
}
};
check(
"context window".to_owned(),
signal.context_percent,
config.context_percent,
);
let quota = signal.quota();
check(
format!("{} plan window", quota.map_or("plan", |(name, _)| name)),
quota.map(|(_, value)| value),
config.quota_percent,
);
Over { reasons, rearmed }
}
pub(crate) fn decide(
config: &HandoffConfig,
signal: &Signal,
state: State,
now: u64,
owner_kind: &str,
) -> (Option<String>, State) {
let Some(kind) = config.kind.as_deref().filter(|_| config.is_armed()) else {
return (None, state);
};
if kind == owner_kind || now.saturating_sub(signal.at) > MAX_AGE {
return (None, state);
}
let over = measure(config, signal);
let mut state = state;
if over.rearmed {
state.armed = true;
}
if over.reasons.is_empty()
|| !(state.armed || now.saturating_sub(state.last_note_at) >= REPEAT_AFTER)
{
return (None, state);
}
state.armed = false;
state.last_note_at = now;
(Some(note(config, kind, &over.reasons)), state)
}
fn note(config: &HandoffConfig, kind: &str, reasons: &[String]) -> String {
let mut command = format!("brgr head handoff TASK --kind {kind}");
if config.exit_old {
command.push_str(" --exit-old");
}
for argument in &config.agent_args {
let _ = write!(command, " --agent-arg={argument}");
}
command.push_str(" --goal \"WHAT YOU ARE FINISHING, IN YOUR OWN WORDS\"");
format!(
"brgr: this head's {} is over the limit you set for handing off to {kind}.\n \
Finish your current step, then run: {command}\n \
TASK is any task you own. This note repeats after {} minutes if still over.",
reasons.join(" and "),
REPEAT_AFTER / 60,
)
}
fn state_path(paths: &Paths, session: &str) -> std::path::PathBuf {
paths
.home
.join("context")
.join(format!("{session}.note.json"))
}
pub(crate) fn print(paths: &Paths) {
let Ok(config) = Config::load(&paths.config) else {
return;
};
if !config.handoff.is_armed() {
return;
}
let Ok(Some(session)) = crate::current_session() else {
return;
};
let Ok(owner) = crate::owner_from_environment() else {
return;
};
let Ok(store) = Store::open(&paths.store) else {
return;
};
if store
.owner_binding(&owner)
.ok()
.flatten()
.is_none_or(|(bound, _)| bound != session)
|| store.owner_takeovers(&owner).ok().is_none_or(|takeovers| {
takeovers
.iter()
.any(|takeover| !takeover.state.is_terminal())
})
{
return;
}
let Some(signal) = context_signal::read(paths, &session, MAX_AGE) else {
return;
};
let path = state_path(paths, &session);
let state = fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
let owner_kind = owner.as_str().split(':').next().unwrap_or_default();
let (text, state) = decide(
&config.handoff,
&signal,
state,
context_signal::now(),
owner_kind,
);
if let Some(text) = text {
eprintln!("{text}");
}
let _ = crate::write_json_atomic(&path, &state);
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> HandoffConfig {
HandoffConfig {
kind: Some("codex".to_owned()),
exit_old: true,
context_percent: Some(70),
quota_percent: Some(85),
agent_args: vec!["--yolo".to_owned()],
}
}
fn signal(context: Option<f64>, quota: Option<f64>, at: u64) -> Signal {
Signal {
session: "s".to_owned(),
source: "status-line".to_owned(),
at,
context_percent: context,
context_window: Some(1_000_000),
five_hour_percent: quota,
five_hour_resets_at: None,
seven_day_percent: None,
seven_day_resets_at: None,
}
}
fn step(
state: State,
context: Option<f64>,
quota: Option<f64>,
now: u64,
) -> (Option<String>, State) {
decide(
&config(),
&signal(context, quota, now),
state,
now,
"claude",
)
}
#[test]
fn crossing_a_limit_gives_the_exact_command_once() {
let (text, state) = step(State::default(), Some(72.0), Some(10.0), 1_000);
let text = text.unwrap();
assert!(text.contains("context window 72% (limit 70%)"), "{text}");
assert!(
text.contains("brgr head handoff TASK --kind codex --exit-old --agent-arg=--yolo"),
"{text}"
);
assert_eq!(step(state, Some(73.0), Some(10.0), 1_060).0, None);
}
#[test]
fn staying_over_is_told_again_after_the_repeat_interval() {
let (_, state) = step(State::default(), Some(72.0), None, 1_000);
assert_eq!(
step(state, Some(75.0), None, 1_000 + REPEAT_AFTER - 1).0,
None
);
assert!(
step(state, Some(75.0), None, 1_000 + REPEAT_AFTER)
.0
.is_some()
);
}
#[test]
fn dropping_well_below_the_limit_rearms_the_next_crossing() {
let (_, state) = step(State::default(), Some(72.0), None, 1_000);
let (none, state) = step(state, Some(60.0), None, 1_100);
assert_eq!(none, None);
assert_eq!(step(state, Some(72.0), None, 1_200).0, None);
let (none, state) = step(state, Some(40.0), Some(10.0), 1_300);
assert_eq!(none, None);
assert!(step(state, Some(71.0), Some(10.0), 1_400).0.is_some());
}
#[test]
fn an_unmeasured_figure_is_never_over_and_never_rearms() {
assert_eq!(step(State::default(), None, None, 1_000).0, None);
let (_, state) = step(State::default(), Some(72.0), None, 1_000);
let (none, state) = step(state, None, None, 1_100);
assert_eq!(none, None);
assert_eq!(step(state, Some(72.0), None, 1_200).0, None);
}
#[test]
fn a_stale_signal_says_nothing() {
let stale = signal(Some(99.0), Some(99.0), 1_000);
let (text, _) = decide(
&config(),
&stale,
State::default(),
1_000 + MAX_AGE + 1,
"claude",
);
assert_eq!(text, None);
}
#[test]
fn a_head_already_running_in_the_target_harness_is_not_told() {
let (text, _) = decide(
&config(),
&signal(Some(99.0), None, 1_000),
State::default(),
1_000,
"codex",
);
assert_eq!(text, None);
}
#[test]
fn nothing_is_said_until_a_target_and_a_threshold_are_set() {
let mut off = config();
off.kind = None;
let signal = signal(Some(99.0), Some(99.0), 1_000);
assert_eq!(
decide(&off, &signal, State::default(), 1_000, "claude").0,
None
);
let mut no_limit = config();
no_limit.context_percent = None;
no_limit.quota_percent = None;
assert_eq!(
decide(&no_limit, &signal, State::default(), 1_000, "claude").0,
None
);
}
#[test]
fn both_limits_are_named_when_both_are_over() {
let (text, _) = step(State::default(), Some(80.0), Some(90.0), 1_000);
let text = text.unwrap();
assert!(text.contains("context window 80%"), "{text}");
assert!(text.contains("five-hour plan window 90%"), "{text}");
}
}