Compare commits

...

1 Commits

Author SHA1 Message Date
gt-oai
376c7d517d Add session start hook 2026-02-12 11:07:23 +00:00
5 changed files with 101 additions and 0 deletions

View File

@@ -44,6 +44,7 @@ use async_channel::Receiver;
use async_channel::Sender;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookEventSessionStart;
use codex_hooks::HookPayload;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
@@ -1327,6 +1328,16 @@ impl Session {
Arc::clone(&config),
&session_configuration.session_source,
);
sess.hooks()
.dispatch(HookPayload {
session_id: sess.conversation_id,
cwd: session_configuration.cwd.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::SessionStart {
event: HookEventSessionStart {},
},
})
.await;
Ok(sess)
}

22
codex-rs/hooks/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Hooks
Hooks are arbitrary programs which run at various deterministic, pre-defined points in the Codex lifecycle.
Hooks implementation is in progress (as of 2025-02-10).
## TODO
- Allow hooks to return errors which halt execution of subsequent hooks.
- Add a /hooks slash command to list and debug hooks.
- Implement the following hooks:
- SessionStart
- SessionEnd
- BeforeAgent
- BeforeTool
- Add Hooks to config.toml
## Done
- Hooks for:
- AfterAgent
- AfterTool

View File

@@ -9,6 +9,7 @@ pub use types::Hook;
pub use types::HookEvent;
pub use types::HookEventAfterAgent;
pub use types::HookEventAfterToolUse;
pub use types::HookEventSessionStart;
pub use types::HookOutcome;
pub use types::HookPayload;
pub use types::HookToolInput;

View File

@@ -12,6 +12,7 @@ pub struct HooksConfig {
#[derive(Clone)]
pub struct Hooks {
session_start: Vec<Hook>,
after_agent: Vec<Hook>,
after_tool_use: Vec<Hook>,
}
@@ -33,6 +34,7 @@ impl Hooks {
.into_iter()
.collect();
Self {
session_start: Vec::new(),
after_agent,
after_tool_use: Vec::new(),
}
@@ -40,6 +42,7 @@ impl Hooks {
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
match hook_event {
HookEvent::SessionStart { .. } => &self.session_start,
HookEvent::AfterAgent { .. } => &self.after_agent,
HookEvent::AfterToolUse { .. } => &self.after_tool_use,
}
@@ -88,6 +91,7 @@ mod tests {
use super::*;
use crate::types::HookEventAfterAgent;
use crate::types::HookEventAfterToolUse;
use crate::types::HookEventSessionStart;
use crate::types::HookToolInput;
use crate::types::HookToolKind;
@@ -155,6 +159,20 @@ mod tests {
}
}
fn session_start_payload() -> HookPayload {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::SessionStart {
event: HookEventSessionStart {},
},
}
}
#[test]
fn command_from_argv_returns_none_for_empty_args() {
assert!(command_from_argv(&[]).is_none());
@@ -269,6 +287,18 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_executes_session_start_hooks() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
session_start: vec![counting_hook(&calls, HookOutcome::Continue)],
..Hooks::default()
};
hooks.dispatch(session_start_payload()).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[cfg(not(windows))]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_unix() -> Result<()> {

View File

@@ -50,6 +50,10 @@ pub struct HookEventAfterAgent {
pub last_assistant_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct HookEventSessionStart {}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookToolKind {
@@ -116,6 +120,10 @@ where
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event_type", rename_all = "snake_case")]
pub enum HookEvent {
SessionStart {
#[serde(flatten)]
event: HookEventSessionStart,
},
AfterAgent {
#[serde(flatten)]
event: HookEventAfterAgent,
@@ -147,6 +155,7 @@ mod tests {
use super::HookEvent;
use super::HookEventAfterAgent;
use super::HookEventAfterToolUse;
use super::HookEventSessionStart;
use super::HookPayload;
use super::HookToolInput;
use super::HookToolInputLocalShell;
@@ -190,6 +199,34 @@ mod tests {
assert_eq!(actual, expected);
}
#[test]
fn session_start_payload_serializes_stable_wire_shape() {
let session_id = ThreadId::new();
let payload = HookPayload {
session_id,
cwd: PathBuf::from("tmp"),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::SessionStart {
event: HookEventSessionStart {},
},
};
let actual = serde_json::to_value(payload).expect("serialize hook payload");
let expected = json!({
"session_id": session_id.to_string(),
"cwd": "tmp",
"triggered_at": "2025-01-01T00:00:00Z",
"hook_event": {
"event_type": "session_start",
},
});
assert_eq!(actual, expected);
}
#[test]
fn after_tool_use_payload_serializes_stable_wire_shape() {
let session_id = ThreadId::new();