Add hooks for interrupted turns (#40511)

## What changed

- Add an `Interrupt` hook event that runs for an active top-level turn before its
  interrupted abort event is emitted.
- Flush the turn transcript before invoking the hook and provide the session,
  turn, transcript, working directory, model, and permission mode in its input.
- Support command and MCP handlers, including asynchronous commands, with a
  one-second default timeout and a three-second maximum.
- Expose the event through hook configuration, managed requirements, app-server
  notifications, generated schemas, analytics, and the TUI hook views.

## Testing

- Cover handler discovery, timeout normalization, output parsing, protocol
  compatibility, TUI rendering, and interrupt execution ordering.

GitOrigin-RevId: 163fa7c098d94ac2775f6d137f8e916f8ea9b6eb
This commit is contained in:
Andrei Eternal
2026-08-25 01:27:18 +00:00
committed by copyberry
parent 9b2ef38f54
commit cbfd999db7
49 changed files with 1393 additions and 48 deletions
+31
View File
@@ -6,6 +6,7 @@ use codex_analytics::CompactionTrigger;
use codex_analytics::HookRunFact;
use codex_analytics::build_track_events_context;
use codex_core_plugins::executor_plugin_hook_sources;
use codex_hooks::InterruptRequest;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PermissionRequestOutcome;
use codex_hooks::PermissionRequestRequest;
@@ -427,6 +428,35 @@ pub(crate) async fn run_session_end_hooks(sess: &Arc<Session>) {
emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await;
}
pub(crate) async fn run_turn_interrupt_hooks(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) {
return;
}
let hooks = sess.hooks();
let preview_runs = hooks.preview_interrupt();
if preview_runs.is_empty() {
return;
}
let request = InterruptRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
};
if let Err(err) = sess.flush_rollout().await {
tracing::warn!("failed to flush transcript before Interrupt hook: {err}");
}
emit_hook_started_events(sess, turn_context, preview_runs).await;
let outcome = hooks.run_interrupt(request).await;
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -841,6 +871,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
HookEventName::Interrupt => "Interrupt",
};
let hook_source = match run.source {
HookSource::System => "system",
+8
View File
@@ -26,6 +26,7 @@ use tracing::warn;
use crate::codex_thread::BackgroundTerminalInfo;
use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::hook_runtime::run_turn_interrupt_hooks;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn::run_hooks_and_record_inputs;
@@ -791,6 +792,9 @@ impl Session {
ThreadIdleCause::Completed
};
let event = if let Some(reason) = abort_reason {
if reason == TurnAbortReason::Interrupted {
run_turn_interrupt_hooks(self, &turn_context).await;
}
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
EventMsg::TurnAborted(TurnAbortedEvent {
@@ -941,6 +945,10 @@ impl Session {
}
}
if reason == TurnAbortReason::Interrupted {
run_turn_interrupt_hooks(self, &task.turn_context).await;
}
let started_at = task
.turn_context
.turn_timing_state