start of hooks engine (#13276)

(Experimental)

This PR adds a first MVP for hooks, with SessionStart and Stop

The core design is:

- hooks live in a dedicated engine under codex-rs/hooks
- each hook type has its own event-specific file
- hook execution is synchronous and blocks normal turn progression while
running
- matching hooks run in parallel, then their results are aggregated into
a normalized HookRunSummary

On the AppServer side, hooks are exposed as operational metadata rather
than transcript-native items:

- new live notifications: hook/started, hook/completed
- persisted/replayed hook results live on Turn.hookRuns
- we intentionally did not add hook-specific ThreadItem variants

Hooks messages are not persisted, they remain ephemeral. The context
changes they add are (they get appended to the user's prompt)
This commit is contained in:
Andrei Eternal
2026-03-10 04:11:31 +00:00
committed by GitHub
parent da616136cc
commit 244b2d53f4
73 changed files with 4791 additions and 483 deletions
@@ -0,0 +1,71 @@
#[derive(Debug, Clone)]
pub(crate) struct UniversalOutput {
pub continue_processing: bool,
pub stop_reason: Option<String>,
pub suppress_output: bool,
pub system_message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionStartOutput {
pub universal: UniversalOutput,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct StopOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
}
use crate::schema::HookUniversalOutputWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::StopDecisionWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(SessionStartOutput {
universal: UniversalOutput::from(wire.universal),
additional_context,
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
should_block: matches!(wire.decision, Some(StopDecisionWire::Block)),
reason: wire.reason,
})
}
impl From<HookUniversalOutputWire> for UniversalOutput {
fn from(value: HookUniversalOutputWire) -> Self {
Self {
continue_processing: value.r#continue,
stop_reason: value.stop_reason,
suppress_output: value.suppress_output,
system_message: value.system_message,
}
}
}
fn parse_json<T>(stdout: &str) -> Option<T>
where
T: for<'de> serde::Deserialize<'de>,
{
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
if !value.is_object() {
return None;
}
serde_json::from_value(value).ok()
}