use std::io::Read;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use lifeloop::protocol::{
ProtocolAdapter, ProtocolPayload, RenderError, RenderRequest, render_hook_payload,
};
use lifeloop::router::{
BuiltinAdapterRegistry, CallbackInvoker, SubprocessCallbackInvoker, SubprocessInvokerConfig,
route,
};
use lifeloop::{
CallbackRequest, CallbackResponse, FailureClass, FrameContext, IntegrationMode,
LifecycleEventKind, PlacementClass, ReceiptStatus, SCHEMA_VERSION, lookup_manifest,
};
use serde_json::{Value, json};
use super::host_context_status;
use super::renewal;
use super::{CliError, MAX_STDIN_BYTES};
const CLIENT_CALLBACK_TIMEOUT_MS: u64 = 5_000;
const PROMPT_METADATA_MAX_BYTES: usize = 64 * 1024;
static RECEIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
struct HostHookArgs {
path: PathBuf,
state_dir: Option<PathBuf>,
host: String,
hook: String,
client_cmd: Option<String>,
client_profile: Option<String>,
}
pub fn run<I: Iterator<Item = String>>(args: I, output: Option<&str>) -> Result<(), CliError> {
let mut raw_args: Vec<String> = args.collect();
let local_output = take_output_flag(&mut raw_args)?;
let selected_output = match (output, local_output.as_deref()) {
(Some(_), Some(_)) => {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
(Some(value), None) | (None, Some(value)) => value,
(None, None) => "hook-protocol",
};
match selected_output {
"hook-protocol" => {}
other => {
return Err(CliError::Usage(format!(
"host-hook: unsupported --output `{other}` (expected: hook-protocol)"
)));
}
}
let args = HostHookArgs::parse(raw_args.into_iter())?;
let host_input = read_optional_json_stdin()?;
let rendered = run_host_hook(&args, &host_input)?;
println!(
"{}",
serde_json::to_string(&rendered).unwrap_or_else(|_| "{}".to_string())
);
Ok(())
}
impl HostHookArgs {
fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
let mut parsed = Self {
path: PathBuf::from("."),
state_dir: None,
host: String::new(),
hook: String::new(),
client_cmd: None,
client_profile: None,
};
while let Some(arg) = args.next() {
match arg.as_str() {
"--path" => parsed.path = PathBuf::from(require_value(&arg, args.next())?),
"--state-dir" => {
parsed.state_dir = Some(PathBuf::from(require_value(&arg, args.next())?));
}
"--host" => parsed.host = require_value(&arg, args.next())?,
"--hook" => parsed.hook = require_value(&arg, args.next())?,
"--client-cmd" => parsed.client_cmd = Some(require_value(&arg, args.next())?),
"--profile" => parsed.client_profile = Some(require_value(&arg, args.next())?),
other => {
return Err(CliError::Usage(format!(
"host-hook: unknown flag `{other}`"
)));
}
}
}
if parsed.host.is_empty() {
return Err(CliError::Usage("host-hook: --host <id> is required".into()));
}
if parsed.hook.is_empty() {
return Err(CliError::Usage(
"host-hook: --hook <hook> is required".into(),
));
}
Ok(parsed)
}
fn state_dir(&self) -> PathBuf {
self.state_dir
.clone()
.unwrap_or_else(|| renewal::default_state_dir(&self.path))
}
}
fn run_host_hook(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
match (args.host.as_str(), args.hook.as_str()) {
("codex", "on-session-start") => codex_session_start(args, host_input),
("claude" | "codex", "before-prompt-build") => host_before_prompt_build(args, host_input),
(_, "on-compaction-notice") => host_on_compaction_notice(args, host_input),
(_, "on-session-end") => host_on_session_end(args, host_input),
_ => Ok(json!({})),
}
}
fn host_before_prompt_build(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
let Some(client_cmd) = args.client_cmd.as_deref() else {
return Ok(json!({}));
};
let Some(prompt) = host_input
.get("prompt")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
else {
return Ok(json!({}));
};
let request = frame_opening_request(args, host_input, prompt)?;
let response = run_ccd_lifeloop_client(args, client_cmd, &request)?;
reject_unaccepted_ccd_response(&response)?;
Ok(json!({}))
}
fn frame_opening_request(
args: &HostHookArgs,
host_input: &Value,
prompt: &str,
) -> Result<CallbackRequest, CliError> {
let manifest = lookup_manifest(&args.host).ok_or_else(|| {
CliError::Validation(format!(
"host-hook: unknown adapter `{}` for prompt callback",
args.host
))
})?;
let prompt = bounded_prompt_metadata(prompt)?;
let event_id = format!("evt-host-hook-{}", unique_suffix());
let invocation_id = format!("inv-host-hook-{}", unique_suffix());
let frame_id = host_input
.get("turn_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| event_id.clone());
let mut metadata = serde_json::Map::new();
metadata.insert("prompt".to_owned(), Value::String(prompt.to_owned()));
insert_host_metadata(
&mut metadata,
"hook_event_name",
host_input
.get("hook_event_name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or(Some("UserPromptSubmit")),
);
insert_host_metadata(
&mut metadata,
"transcript_path",
host_input.get("transcript_path").and_then(Value::as_str),
);
insert_host_metadata(
&mut metadata,
"turn_id",
host_input.get("turn_id").and_then(Value::as_str),
);
Ok(CallbackRequest {
schema_version: SCHEMA_VERSION.to_string(),
event: LifecycleEventKind::FrameOpening,
event_id,
adapter_id: args.host.clone(),
adapter_version: manifest.manifest.adapter_version,
integration_mode: IntegrationMode::NativeHook,
invocation_id,
harness_session_id: host_input
.get("session_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned),
harness_run_id: host_input
.get("run_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned),
harness_task_id: None,
frame_context: Some(FrameContext::top_level(frame_id)),
capability_snapshot_ref: None,
payload_refs: Vec::new(),
sequence: None,
idempotency_key: None,
metadata,
})
}
fn insert_host_metadata(
metadata: &mut serde_json::Map<String, Value>,
key: &str,
value: Option<&str>,
) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
metadata.insert(key.to_owned(), Value::String(value.to_owned()));
}
}
fn bounded_prompt_metadata(prompt: &str) -> Result<&str, CliError> {
if prompt.len() > PROMPT_METADATA_MAX_BYTES {
return Err(CliError::Validation(format!(
"host-hook: prompt metadata exceeds {PROMPT_METADATA_MAX_BYTES} bytes"
)));
}
Ok(prompt)
}
fn run_ccd_lifeloop_client(
args: &HostHookArgs,
client_cmd: &str,
request: &CallbackRequest,
) -> Result<CallbackResponse, CliError> {
let registry = BuiltinAdapterRegistry;
let plan = route(request, ®istry).map_err(|err| {
CliError::Validation(format!("host-hook: callback request rejected: {err}"))
})?;
let mut cmd_args = vec![
"lifeloop".to_owned(),
"client".to_owned(),
"--path".to_owned(),
args.path.display().to_string(),
];
append_profile(args, &mut cmd_args);
let config = SubprocessInvokerConfig::new(
client_cmd,
Duration::from_millis(CLIENT_CALLBACK_TIMEOUT_MS),
)
.args(cmd_args);
let invoker = SubprocessCallbackInvoker::new(config);
invoker
.invoke(&plan, &[])
.map_err(|err| CliError::Validation(format!("host-hook: CCD callback failed: {err}")))
}
fn reject_unaccepted_ccd_response(response: &CallbackResponse) -> Result<(), CliError> {
if !matches!(
response.status,
ReceiptStatus::Failed | ReceiptStatus::Skipped
) {
return Ok(());
}
let failure_class = response
.failure_class
.unwrap_or(FailureClass::InternalError);
let reason = response
.metadata
.get("reason")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or(match response.status {
ReceiptStatus::Failed => "status=failed",
ReceiptStatus::Skipped => "status=skipped",
_ => "status not accepted",
});
Err(CliError::Validation(format!(
"host-hook: CCD callback did not accept prompt event: status={:?}, failure_class={failure_class:?}, reason={reason}",
response.status
)))
}
fn host_on_compaction_notice(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
let session_id = host_input
.get("session_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CliError::Input("on-compaction-notice: host input missing required `session_id`".into())
})?;
let state_dir = args.state_dir();
host_context_status::record_compaction(&state_dir, &args.host, session_id, epoch_s())?;
let Some(client_cmd) = args.client_cmd.as_deref() else {
return Ok(json!({}));
};
let Some(compaction) = compaction_lifecycle_event(host_input) else {
return Ok(json!({}));
};
let request = compaction_request(args, host_input, compaction)?;
let response = run_ccd_lifeloop_client(args, client_cmd, &request)?;
reject_failed_ccd_response(&response, "compaction event")?;
render_compaction_response(args, compaction.event, &response)
}
#[derive(Clone, Copy)]
struct CompactionEvent {
event: LifecycleEventKind,
source_hook: &'static str,
}
fn compaction_lifecycle_event(host_input: &Value) -> Option<CompactionEvent> {
match host_input
.get("hook_event_name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("PreCompact")
{
"PreCompact" => Some(CompactionEvent {
event: LifecycleEventKind::ContextPressureObserved,
source_hook: "PreCompact",
}),
"PostCompact" => Some(CompactionEvent {
event: LifecycleEventKind::ContextCompacted,
source_hook: "PostCompact",
}),
_ => None,
}
}
fn compaction_request(
args: &HostHookArgs,
host_input: &Value,
compaction: CompactionEvent,
) -> Result<CallbackRequest, CliError> {
let manifest = lookup_manifest(&args.host).ok_or_else(|| {
CliError::Validation(format!(
"host-hook: unknown adapter `{}` for compaction callback",
args.host
))
})?;
let event_id = format!("evt-host-hook-{}", unique_suffix());
let invocation_id = format!("inv-host-hook-{}", unique_suffix());
let mut metadata = serde_json::Map::new();
insert_host_metadata(
&mut metadata,
"hook_event_name",
Some(compaction.source_hook),
);
insert_host_metadata(&mut metadata, "source_hook", Some(compaction.source_hook));
insert_host_metadata(
&mut metadata,
"trigger",
host_input.get("trigger").and_then(Value::as_str),
);
if compaction.event == LifecycleEventKind::ContextPressureObserved {
metadata.insert("compaction_signal".to_owned(), Value::Bool(true));
}
Ok(CallbackRequest {
schema_version: SCHEMA_VERSION.to_string(),
event: compaction.event,
event_id,
adapter_id: args.host.clone(),
adapter_version: manifest.manifest.adapter_version,
integration_mode: IntegrationMode::NativeHook,
invocation_id,
harness_session_id: host_input
.get("session_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned),
harness_run_id: host_input
.get("run_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned),
harness_task_id: host_input
.get("turn_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned),
frame_context: None,
capability_snapshot_ref: None,
payload_refs: Vec::new(),
sequence: None,
idempotency_key: None,
metadata,
})
}
fn reject_failed_ccd_response(response: &CallbackResponse, label: &str) -> Result<(), CliError> {
if !matches!(response.status, ReceiptStatus::Failed) {
return Ok(());
}
let failure_class = response
.failure_class
.unwrap_or(FailureClass::InternalError);
let reason = response
.metadata
.get("reason")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("status=failed");
Err(CliError::Validation(format!(
"host-hook: CCD callback failed for {label}: failure_class={failure_class:?}, reason={reason}"
)))
}
fn render_compaction_response(
args: &HostHookArgs,
event: LifecycleEventKind,
response: &CallbackResponse,
) -> Result<Value, CliError> {
if event != LifecycleEventKind::ContextCompacted || response.client_payloads.is_empty() {
return Ok(json!({}));
}
let Some(adapter) = ProtocolAdapter::from_id(&args.host) else {
return Ok(json!({}));
};
let manifest = lookup_manifest(&args.host).ok_or_else(|| {
CliError::Validation(format!(
"host-hook: unknown adapter `{}` for compaction response render",
args.host
))
})?;
let payloads = response
.client_payloads
.iter()
.filter_map(|payload| {
payload
.acceptable_placements
.iter()
.find(|placement| placement.placement == PlacementClass::SideChannelContext)
.map(|placement| ProtocolPayload::new(payload, placement.placement))
})
.collect::<Vec<_>>();
if payloads.is_empty() {
return Ok(json!({}));
}
let req = RenderRequest {
adapter,
adapter_id: &args.host,
adapter_version: &manifest.manifest.adapter_version,
integration_mode: IntegrationMode::NativeHook,
event,
frame: None,
payloads: &payloads,
directive: None,
};
match render_hook_payload(&req) {
Ok(rendered) => Ok(rendered.body),
Err(RenderError::UnsupportedEvent { .. }) => Ok(json!({})),
Err(err) => Err(CliError::Validation(format!(
"host-hook: failed to render compaction response: {err}"
))),
}
}
fn host_on_session_end(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
let session_id = host_input
.get("session_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CliError::Input("on-session-end: host input missing required `session_id`".into())
})?;
let state_dir = args.state_dir();
host_context_status::clear_for_session(&state_dir, session_id)?;
Ok(json!({}))
}
fn codex_session_start(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
let session_id = host_input
.get("session_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CliError::Input(
"codex on-session-start: host input missing required `session_id`".into(),
)
})?;
let source = host_input.get("source").and_then(Value::as_str);
let state_dir = args.state_dir();
if source == Some("resume") {
host_context_status::clear_if_session_changed(&state_dir, session_id)?;
} else {
host_context_status::clear_unconditional(&state_dir)?;
}
Ok(json!({}))
}
fn append_profile(args: &HostHookArgs, cmd: &mut Vec<String>) {
if let Some(profile) = &args.client_profile {
cmd.push("--profile".into());
cmd.push(profile.clone());
}
}
fn read_optional_json_stdin() -> Result<Value, CliError> {
let mut buf = Vec::new();
std::io::stdin()
.lock()
.take(MAX_STDIN_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|err| CliError::Input(format!("host-hook: failed to read stdin: {err}")))?;
if buf.len() as u64 > MAX_STDIN_BYTES {
return Err(CliError::Input(format!(
"host-hook: stdin exceeds {MAX_STDIN_BYTES} bytes"
)));
}
if buf.iter().all(|b| b.is_ascii_whitespace()) {
return Ok(json!({}));
}
serde_json::from_slice(&buf)
.map_err(|err| CliError::Input(format!("host-hook: invalid hook JSON on stdin: {err}")))
}
fn epoch_s() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let seq = RECEIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{nanos}-{seq}", std::process::id())
}
fn take_output_flag(args: &mut Vec<String>) -> Result<Option<String>, CliError> {
let mut found = None;
let mut index = 0;
while index < args.len() {
if args[index] == "--output" {
if index + 1 >= args.len() {
return Err(CliError::Usage("flag `--output` requires a value".into()));
}
let value = args.remove(index + 1);
args.remove(index);
if found.replace(value).is_some() {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
continue;
}
if let Some(value) = args[index].strip_prefix("--output=") {
let value = value.to_string();
args.remove(index);
if found.replace(value).is_some() {
return Err(CliError::Usage(
"host-hook: --output was provided more than once".into(),
));
}
continue;
}
index += 1;
}
Ok(found)
}
fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}