use std::io::Read as _;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::cli::{DeliverArgs, DeliverShell};
use super::{
append_ledger, block_cap, channel_dir, choose_vehicle, cleanup_if_last, fold_baseline,
is_lane_id, is_steer_event, ledger_len, mark_done, now_utc, open_slot, plan, read_armed,
read_ledger_prefix, refresh_armed, run_recipe, wait_prev, ArmedMarker, Bookkeeping, Chunk,
HookInput, LedgerLine, Plan, RecipeShell, SlotChain, VehicleChoice, WaitOutcome, BLOCK_CAP_ENV,
HELD_BLOCK_CAP, HELD_SOURCE_MISSING,
};
const VERSION_TAIL_BYTES: u64 = 16 * 1024;
const VERSION_ENV: &str = "CLAUDE_CODE_VERSION";
const REFUSED_NO_TRANSCRIPT: &str = "no transcript path in the hook payload";
const REFUSED_LANE_ID: &str = "the payload's agent id is not a lane id";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Ready { root: PathBuf, lane: String },
Refused {
root: PathBuf,
lane: String,
reason: &'static str,
},
Silent,
}
pub(crate) fn run_deliver(args: &DeliverArgs) -> Result<()> {
if args.recipe {
return run_recipe(args.slots, recipe_shell(args.shell));
}
let Some(raw) = read_stdin() else {
return Ok(());
};
let Some(hook) = HookInput::parse(&raw) else {
return Ok(());
};
match target(&hook) {
Target::Silent => Ok(()),
Target::Refused { root, lane, reason } => append_ledger(
&root,
&lane,
&LedgerLine::Refused {
id: None,
reason: reason.to_string(),
ts_utc: now_utc(),
},
),
Target::Ready { root, lane } => handle(&hook, &root, &lane, args.slot.unwrap_or(1)),
}
}
fn recipe_shell(shell: DeliverShell) -> RecipeShell {
match shell {
DeliverShell::Bash => RecipeShell::Bash,
DeliverShell::Powershell => RecipeShell::Powershell,
}
}
fn read_stdin() -> Option<String> {
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw).ok()?;
(!raw.trim().is_empty()).then_some(raw)
}
fn target(hook: &HookInput) -> Target {
if !crate::path::is_uuid(&hook.session_id) {
return Target::Silent;
}
let Some(root) = channel_root(hook) else {
return Target::Silent;
};
if hook.transcript_path.trim().is_empty() {
return Target::Refused {
root,
lane: hook.session_id.clone(),
reason: REFUSED_NO_TRANSCRIPT,
};
}
let lane = hook.lane().to_string();
if is_lane_id(&lane) {
Target::Ready { root, lane }
} else {
Target::Refused {
root,
lane: hook.session_id.clone(),
reason: REFUSED_LANE_ID,
}
}
}
fn channel_root(hook: &HookInput) -> Option<PathBuf> {
let project_dir = if hook.transcript_path.trim().is_empty() {
let cwd = hook.cwd.as_deref()?;
crate::path::projects_root()
.ok()?
.join(crate::path::encode_cwd(Path::new(cwd)))
} else {
Path::new(&hook.transcript_path).parent()?.to_path_buf()
};
Some(channel_dir(&project_dir.join(&hook.session_id)))
}
fn handle(hook: &HookInput, root: &Path, lane: &str, slot: u32) -> Result<()> {
let now = now_utc();
let prior = read_armed(root, lane)?;
let version = claude_code_version(hook);
refresh_armed(
root,
lane,
slot,
&hook.hook_event_name,
&now,
Some(hook.session_id.as_str()),
version.as_deref(),
)?;
if !is_steer_event(&hook.hook_event_name) {
return Ok(());
}
if inbox_is_empty(root, lane)? {
return Ok(());
}
emit_for_slot(hook, root, lane, slot, &now, prior.as_ref())
}
fn inbox_is_empty(root: &Path, lane: &str) -> Result<bool> {
let path = super::inbox_path(root, lane)?;
Ok(std::fs::metadata(&path).map_or(true, |m| m.len() == 0))
}
fn emit_for_slot(
hook: &HookInput,
root: &Path,
lane: &str,
slot: u32,
now: &str,
prior: Option<&ArmedMarker>,
) -> Result<()> {
let chain = open_slot(chain_key(hook), &hook.hook_event_name, lane, slot)?;
let outcome = wait_prev(&chain);
let baseline = fold_baseline(&chain, ledger_len(root, lane)?);
let (ledger, _) = read_ledger_prefix(root, lane, baseline)?;
let firing = plan(root, lane, hook, &ledger, now)?;
let cap = block_cap(std::env::var(BLOCK_CAP_ENV).ok().as_deref());
let index = usize::try_from(slot)
.unwrap_or(usize::MAX)
.saturating_sub(1);
let chosen = firing
.chunks
.get(index)
.map(|c| (c.clone(), choose_vehicle(hook, c, &ledger, cap)));
if slot <= 1 {
record_bookkeeping(root, lane, &firing, now)?;
}
if let Some((chunk, choice)) = &chosen {
record_emit(root, lane, hook, slot, chunk, choice, now)?;
}
mark_done(&chain)?;
cleanup_chain(&chain, prior);
match chosen {
Some((chunk, choice)) => super::emit(
&hook.hook_event_name,
&with_order_warning(&chunk.text, outcome, slot),
choice.vehicle,
),
None => Ok(()),
}
}
pub(crate) fn with_order_warning(text: &str, outcome: WaitOutcome, slot: u32) -> String {
match outcome {
WaitOutcome::TimedOut => format!("{}\n{text}", super::disorder_warning(slot)),
WaitOutcome::First | WaitOutcome::Ready => text.to_string(),
}
}
fn record_bookkeeping(root: &Path, lane: &str, firing: &Plan, now: &str) -> Result<()> {
for item in &firing.bookkeeping {
let line = match item {
Bookkeeping::Expired(id) => LedgerLine::Expired {
id: id.clone(),
ts_utc: now.to_string(),
},
Bookkeeping::Redelivered(id, source) => LedgerLine::Redelivered {
id: id.clone(),
source: *source,
ts_utc: now.to_string(),
},
Bookkeeping::SourceMissing(id) => LedgerLine::Held {
id: id.clone(),
reason: HELD_SOURCE_MISSING.to_string(),
ts_utc: now.to_string(),
},
};
append_ledger(root, lane, &line)?;
}
Ok(())
}
fn record_emit(
root: &Path,
lane: &str,
hook: &HookInput,
slot: u32,
chunk: &Chunk,
choice: &VehicleChoice,
now: &str,
) -> Result<()> {
if choice.held_block_cap {
append_ledger(
root,
lane,
&LedgerLine::Held {
id: chunk.id.clone(),
reason: HELD_BLOCK_CAP.to_string(),
ts_utc: now.to_string(),
},
)?;
}
append_ledger(
root,
lane,
&LedgerLine::Emit {
id: chunk.id.clone(),
event: hook.hook_event_name.clone(),
slot,
part: chunk.part,
parts: chunk.parts,
vehicle: choice.vehicle,
ts_utc: now.to_string(),
hook_session: Some(hook.session_id.clone()),
hook_agent_id: hook.agent_id.clone(),
block_count: choice.block_count,
},
)
}
fn cleanup_chain(chain: &SlotChain, prior: Option<&ArmedMarker>) {
if let Some(max) = prior.and_then(|m| m.slots_seen.iter().max().copied()) {
cleanup_if_last(chain, max);
}
}
fn chain_key(hook: &HookInput) -> u32 {
#[cfg(unix)]
{
let _ = hook;
std::os::unix::process::parent_id()
}
#[cfg(not(unix))]
{
let mut h: u32 = 2_166_136_261;
for b in hook.session_id.as_bytes() {
h ^= u32::from(*b);
h = h.wrapping_mul(16_777_619);
}
h
}
}
fn claude_code_version(hook: &HookInput) -> Option<String> {
version_from_tail(Path::new(&hook.transcript_path)).or_else(|| {
std::env::var(VERSION_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
})
}
fn version_from_tail(path: &Path) -> Option<String> {
let (bytes, _) = crate::parse::read_tail(path, VERSION_TAIL_BYTES).ok()?;
let text = String::from_utf8_lossy(&bytes);
text.lines().rev().find_map(|line| {
crate::parse::parse_line(line.as_bytes())
.ok()
.flatten()
.and_then(|rec| rec.version)
})
}