From a2ec21869829d48891dad456fa4e28e11099a6cd Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 15:16:21 -0700 Subject: [PATCH 1/9] chore(snapshot): capture current hooks work --- code-rs/app-server/src/message_processor.rs | 3 +- code-rs/cli/src/llm.rs | 3 +- code-rs/cli/src/proto.rs | 2 +- .../src/auto_coordinator.rs | 7 +- code-rs/core/src/agent_tool.rs | 7 + code-rs/core/src/codex/exec.rs | 613 +++++++++++++++--- code-rs/core/src/codex/session.rs | 10 +- code-rs/core/src/codex/streaming.rs | 134 +++- code-rs/core/src/config.rs | 15 + code-rs/core/src/lib.rs | 1 + code-rs/core/tests/hook_events.rs | 522 +++++++++++++++ code-rs/exec/src/lib.rs | 2 +- code-rs/mcp-server/src/message_processor.rs | 3 +- code-rs/tui/src/app/events.rs | 4 +- code-rs/tui/src/app/init.rs | 4 +- .../src/bottom_pane/theme_selection_view.rs | 11 +- code-rs/tui/src/chatwidget.rs | 3 +- code-rs/tui/src/chatwidget/agent_install.rs | 7 +- .../tui/src/chatwidget/rate_limit_refresh.rs | 23 +- hooks/ask_user_prompt.py | 38 ++ hooks/check_shell.py | 57 ++ scripts/run-hooks-test.sh | 40 ++ 22 files changed, 1362 insertions(+), 147 deletions(-) create mode 100644 code-rs/core/tests/hook_events.rs create mode 100755 hooks/ask_user_prompt.py create mode 100755 hooks/check_shell.py create mode 100755 scripts/run-hooks-test.sh diff --git a/code-rs/app-server/src/message_processor.rs b/code-rs/app-server/src/message_processor.rs index 25db805eb84..143d98b8618 100644 --- a/code-rs/app-server/src/message_processor.rs +++ b/code-rs/app-server/src/message_processor.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use crate::code_message_processor::CodexMessageProcessor; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; -use code_protocol::mcp_protocol::AuthMode; use code_protocol::mcp_protocol::ClientInfo; use code_protocol::mcp_protocol::ClientRequest; use code_protocol::mcp_protocol::InitializeResponse; @@ -39,7 +38,7 @@ impl MessageProcessor { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let conversation_manager = Arc::new(ConversationManager::new( diff --git a/code-rs/cli/src/llm.rs b/code-rs/cli/src/llm.rs index 96be6ecdb66..4d17ed4346a 100644 --- a/code-rs/cli/src/llm.rs +++ b/code-rs/cli/src/llm.rs @@ -10,7 +10,6 @@ use code_core::agent_defaults::model_guide_markdown_with_custom; use code_core::AuthManager; use code_core::Prompt; use code_core::TextFormat; -use code_app_server_protocol::AuthMode; use code_protocol::models::{ContentItem, ResponseItem}; use futures::StreamExt; @@ -134,7 +133,7 @@ async fn run_llm_request( // Auth + provider let auth_mgr = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let provider: ModelProviderInfo = config.model_provider.clone(); diff --git a/code-rs/cli/src/proto.rs b/code-rs/cli/src/proto.rs index 5c346e812b9..b01a49f9fc4 100644 --- a/code-rs/cli/src/proto.rs +++ b/code-rs/cli/src/proto.rs @@ -40,7 +40,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { // Use conversation_manager API to start a conversation let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - code_login::AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Cli); diff --git a/code-rs/code-auto-drive-core/src/auto_coordinator.rs b/code-rs/code-auto-drive-core/src/auto_coordinator.rs index c7922b02cb9..5ff72a0f639 100644 --- a/code-rs/code-auto-drive-core/src/auto_coordinator.rs +++ b/code-rs/code-auto-drive-core/src/auto_coordinator.rs @@ -1105,16 +1105,11 @@ fn run_auto_loop( let compact_prompt_text = resolve_compact_prompt_text(config.compact_prompt_override.as_deref()); - let preferred_auth = if config.using_chatgpt_auth { - code_protocol::mcp_protocol::AuthMode::ChatGPT - } else { - code_protocol::mcp_protocol::AuthMode::ApiKey - }; let code_home = config.code_home.clone(); let responses_originator_header = config.responses_originator_header.clone(); let auth_mgr = AuthManager::shared_with_mode_and_originator( code_home, - preferred_auth, + config.preferred_auth_mode(), responses_originator_header, ); let model_provider = config.model_provider.clone(); diff --git a/code-rs/core/src/agent_tool.rs b/code-rs/core/src/agent_tool.rs index 90d707f53d0..894a3cf6143 100644 --- a/code-rs/core/src/agent_tool.rs +++ b/code-rs/core/src/agent_tool.rs @@ -306,6 +306,13 @@ impl AgentManager { self.start_watchdog(); } + /// Emit a status update payload to any active listener (used by tests and integrations). + pub fn emit_status_update(&self, payload: AgentStatusUpdatePayload) { + if let Some(ref sender) = self.event_sender { + let _ = sender.send(payload); + } + } + fn start_watchdog(&mut self) { if self.watchdog_handle.is_some() { return; diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index ddec7a0b5ba..736a29d1664 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -1,5 +1,7 @@ use super::*; use super::session::{HookGuard, RunningExecMeta}; +use code_protocol::models::ContentItem; +use serde_json::{json, Map, Value}; fn synthetic_exec_end_payload(cancelled: bool) -> (i32, String) { if cancelled { @@ -114,6 +116,207 @@ pub(crate) struct ApplyPatchCommandContext { pub(crate) changes: HashMap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HookPermissionDecision { + Allow, + Deny, + Ask, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum HookStopDecision { + Approve, + Block { reason: Option }, +} + +#[derive(Debug, Default, Clone)] +pub(super) struct HookOutput { + continue_processing: Option, + suppress_output: Option, + system_message: Option, + permission_decision: Option, + updated_input: Option, + stop_decision: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct HookRunResult { + pub(super) continue_processing: bool, + pub(super) suppress_output: bool, + pub(super) system_messages: Vec, + pub(super) permission_decision: Option, + pub(super) updated_input: Option, + pub(super) stop_decision: Option, +} + +impl Default for HookRunResult { + fn default() -> Self { + Self { + continue_processing: true, + suppress_output: false, + system_messages: Vec::new(), + permission_decision: None, + updated_input: None, + stop_decision: None, + } + } +} + +impl HookRunResult { + fn apply(&mut self, output: HookOutput) { + if let Some(flag) = output.continue_processing { + if !flag { + self.continue_processing = false; + } + } + if matches!(output.suppress_output, Some(true)) { + self.suppress_output = true; + } + if let Some(message) = output.system_message { + if !message.trim().is_empty() { + self.system_messages.push(message); + } + } + if let Some(decision) = output.permission_decision { + self.permission_decision = Some(merge_permission_decision(self.permission_decision, decision)); + } + if let Some(updated) = output.updated_input { + self.updated_input = Some(updated); + } + if let Some(decision) = output.stop_decision { + self.stop_decision = Some(merge_stop_decision(self.stop_decision.take(), decision)); + } + } +} + +fn merge_permission_decision( + current: Option, + next: HookPermissionDecision, +) -> HookPermissionDecision { + fn rank(decision: HookPermissionDecision) -> u8 { + match decision { + HookPermissionDecision::Deny => 3, + HookPermissionDecision::Ask => 2, + HookPermissionDecision::Allow => 1, + } + } + match current { + None => next, + Some(existing) => { + if rank(next) >= rank(existing) { + next + } else { + existing + } + } + } +} + +fn merge_stop_decision(current: Option, next: HookStopDecision) -> HookStopDecision { + match (current, next) { + (Some(HookStopDecision::Block { reason }), HookStopDecision::Block { reason: next_reason }) => { + HookStopDecision::Block { + reason: reason.or(next_reason), + } + } + (Some(HookStopDecision::Block { reason }), HookStopDecision::Approve) => { + HookStopDecision::Block { reason } + } + (Some(HookStopDecision::Approve), HookStopDecision::Block { reason }) => { + HookStopDecision::Block { reason } + } + (None, HookStopDecision::Block { reason }) => HookStopDecision::Block { reason }, + _ => HookStopDecision::Approve, + } +} + +fn parse_hook_permission_decision(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "allow" | "approve" => Some(HookPermissionDecision::Allow), + "deny" | "block" => Some(HookPermissionDecision::Deny), + "ask" | "confirm" => Some(HookPermissionDecision::Ask), + _ => None, + } +} + +fn parse_stop_decision(value: &str, reason: Option) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "approve" | "allow" => Some(HookStopDecision::Approve), + "block" | "deny" => Some(HookStopDecision::Block { reason }), + _ => None, + } +} + +fn extract_json_from_text(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let stripped = if trimmed.starts_with("```") { + let mut lines = trimmed.lines(); + let _ = lines.next(); + let mut body = lines.collect::>().join("\n"); + if let Some(idx) = body.rfind("```") { + body.truncate(idx); + } + body.trim().to_string() + } else { + trimmed.to_string() + }; + + if serde_json::from_str::(&stripped).is_ok() { + return Some(stripped); + } + + let start = stripped.find('{')?; + let end = stripped.rfind('}')?; + if end <= start { + return None; + } + let candidate = stripped[start..=end].trim(); + serde_json::from_str::(candidate).ok()?; + Some(candidate.to_string()) +} + +fn parse_hook_output_from_text(raw: &str) -> HookOutput { + let mut output = HookOutput::default(); + let Some(json_text) = extract_json_from_text(raw) else { + return output; + }; + let Ok(value) = serde_json::from_str::(&json_text) else { + return output; + }; + + if let Some(flag) = value.get("continue").and_then(|v| v.as_bool()) { + output.continue_processing = Some(flag); + } + if let Some(flag) = value.get("suppressOutput").and_then(|v| v.as_bool()) { + output.suppress_output = Some(flag); + } + if let Some(message) = value.get("systemMessage").and_then(|v| v.as_str()) { + output.system_message = Some(message.to_string()); + } + + let permission = value + .pointer("/hookSpecificOutput/permissionDecision") + .and_then(|v| v.as_str()) + .or_else(|| value.get("permissionDecision").and_then(|v| v.as_str())); + output.permission_decision = permission.and_then(parse_hook_permission_decision); + + output.updated_input = value + .pointer("/hookSpecificOutput/updatedInput") + .cloned() + .or_else(|| value.get("updatedInput").cloned()); + + if let Some(decision) = value.get("decision").and_then(|v| v.as_str()) { + let reason = value.get("reason").and_then(|v| v.as_str()).map(|s| s.to_string()); + output.stop_decision = parse_stop_decision(decision, reason); + } + + output +} + fn sanitize_identifier(value: &str) -> String { let mut slug = String::with_capacity(value.len()); for ch in value.chars() { @@ -136,45 +339,99 @@ fn truncate_payload(text: &str, limit: usize) -> String { let mut iter = text.chars(); let truncated: String = iter.by_ref().take(limit).collect(); if iter.next().is_some() { - format!("{truncated}…") + format!("{truncated}...") } else { truncated } } +fn permission_mode_label(policy: AskForApproval) -> &'static str { + match policy { + AskForApproval::Never => "allow", + _ => "ask", + } +} + +pub(super) fn build_base_hook_payload( + sess: &Session, + event: ProjectHookEvent, +) -> Map { + let mut base = Map::new(); + base.insert("session_id".to_string(), Value::String(sess.id.to_string())); + let transcript_path = sess + .rollout + .lock() + .unwrap() + .as_ref() + .map(|r| r.rollout_path.to_string_lossy().to_string()); + base.insert( + "transcript_path".to_string(), + transcript_path.map(Value::String).unwrap_or(Value::Null), + ); + base.insert( + "cwd".to_string(), + Value::String(sess.cwd.to_string_lossy().to_string()), + ); + base.insert( + "permission_mode".to_string(), + Value::String(permission_mode_label(sess.approval_policy).to_string()), + ); + base.insert( + "hook_event_name".to_string(), + Value::String(event.hook_event_name().to_string()), + ); + base +} + fn build_exec_hook_payload( + sess: &Session, event: ProjectHookEvent, ctx: &ExecCommandContext, params: &ExecParams, output: Option<&ExecToolCallOutput>, + tool_name: Option<&str>, ) -> Value { - let base = json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - }); + let mut base = build_base_hook_payload(sess, event); + base.insert("event".to_string(), Value::String(event.as_str().to_string())); + base.insert("call_id".to_string(), Value::String(ctx.call_id.clone())); + base.insert( + "cwd".to_string(), + Value::String(ctx.cwd.to_string_lossy().to_string()), + ); + base.insert("command".to_string(), json!(params.command)); + base.insert("timeout_ms".to_string(), json!(params.timeout_ms)); + if let Some(tool_name) = tool_name { + base.insert("tool_name".to_string(), Value::String(tool_name.to_string())); + base.insert("tool_use_id".to_string(), Value::String(ctx.call_id.clone())); + } match event { - ProjectHookEvent::ToolBefore => base, ProjectHookEvent::ToolAfter => { if let Some(out) = output { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "exit_code": out.exit_code, - "duration_ms": out.duration.as_millis(), - "timed_out": out.timed_out, - "stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT), - "stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT), - }) - } else { - base + base.insert( + "tool_result".to_string(), + json!({ + "exit_code": out.exit_code, + "duration_ms": out.duration.as_millis(), + "timed_out": out.timed_out, + "stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT), + "stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT), + "success": out.exit_code == 0, + }), + ); + base.insert("exit_code".to_string(), json!(out.exit_code)); + base.insert("duration_ms".to_string(), json!(out.duration.as_millis())); + base.insert("timed_out".to_string(), json!(out.timed_out)); + base.insert( + "stdout".to_string(), + json!(truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT)), + ); + base.insert( + "stderr".to_string(), + json!(truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT)), + ); } + Value::Object(base) } ProjectHookEvent::FileBeforeWrite => { let changes = ctx @@ -182,14 +439,8 @@ fn build_exec_hook_payload( .as_ref() .and_then(|p| serde_json::to_value(&p.changes).ok()) .unwrap_or(Value::Null); - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - }) + base.insert("changes".to_string(), changes); + Value::Object(base) } ProjectHookEvent::FileAfterWrite => { let changes = ctx @@ -197,36 +448,97 @@ fn build_exec_hook_payload( .as_ref() .and_then(|p| serde_json::to_value(&p.changes).ok()) .unwrap_or(Value::Null); + base.insert("changes".to_string(), changes); if let Some(out) = output { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - "exit_code": out.exit_code, - "duration_ms": out.duration.as_millis(), - "timed_out": out.timed_out, - "stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT), - "stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT), - "success": out.exit_code == 0, - }) - } else { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - }) + base.insert("exit_code".to_string(), json!(out.exit_code)); + base.insert("duration_ms".to_string(), json!(out.duration.as_millis())); + base.insert("timed_out".to_string(), json!(out.timed_out)); + base.insert( + "stdout".to_string(), + json!(truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT)), + ); + base.insert( + "stderr".to_string(), + json!(truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT)), + ); + base.insert("success".to_string(), json!(out.exit_code == 0)); } + Value::Object(base) } - _ => base, + _ => Value::Object(base), } } +pub(super) fn build_tool_hook_payload( + sess: &Session, + event: ProjectHookEvent, + tool_name: &str, + tool_input: Option, + tool_result: Option, + tool_use_id: &str, +) -> Value { + let mut base = build_base_hook_payload(sess, event); + base.insert("event".to_string(), Value::String(event.as_str().to_string())); + base.insert("tool_name".to_string(), Value::String(tool_name.to_string())); + base.insert("tool_use_id".to_string(), Value::String(tool_use_id.to_string())); + base.insert("tool_input".to_string(), tool_input.unwrap_or(Value::Null)); + if let Some(result) = tool_result { + base.insert("tool_result".to_string(), result); + } + Value::Object(base) +} + +pub(super) fn build_user_prompt_hook_payload(sess: &Session, prompt: &str) -> Value { + let mut base = build_base_hook_payload(sess, ProjectHookEvent::UserPromptSubmit); + base.insert( + "event".to_string(), + Value::String(ProjectHookEvent::UserPromptSubmit.as_str().to_string()), + ); + base.insert("user_prompt".to_string(), Value::String(prompt.to_string())); + Value::Object(base) +} + +pub(super) fn build_stop_hook_payload( + sess: &Session, + event: ProjectHookEvent, + reason: Option, + details: Option, +) -> Value { + let mut base = build_base_hook_payload(sess, event); + base.insert("event".to_string(), Value::String(event.as_str().to_string())); + if let Some(reason) = reason { + base.insert("reason".to_string(), Value::String(reason)); + } + if let Some(details) = details { + base.insert("details".to_string(), details); + } + Value::Object(base) +} + +pub(super) fn build_precompact_hook_payload(sess: &Session, reason: &str) -> Value { + let mut base = build_base_hook_payload(sess, ProjectHookEvent::PreCompact); + base.insert( + "event".to_string(), + Value::String(ProjectHookEvent::PreCompact.as_str().to_string()), + ); + base.insert("reason".to_string(), Value::String(reason.to_string())); + Value::Object(base) +} + +pub(super) fn build_notification_hook_payload( + sess: &Session, + notification: &UserNotification, +) -> Value { + let mut base = build_base_hook_payload(sess, ProjectHookEvent::Notification); + base.insert( + "event".to_string(), + Value::String(ProjectHookEvent::Notification.as_str().to_string()), + ); + let payload = serde_json::to_value(notification).unwrap_or(Value::Null); + base.insert("notification".to_string(), payload); + Value::Object(base) +} + pub struct ExecInvokeArgs<'a> { pub params: ExecParams, pub sandbox_type: SandboxType, @@ -663,43 +975,105 @@ impl Session { params: &ExecParams, output: Option<&ExecToolCallOutput>, attempt_req: u64, - ) { + ) -> HookRunResult { if self.project_hooks.is_empty() { - return; + return HookRunResult::default(); } let hooks: Vec = self.project_hooks.hooks_for(event).cloned().collect(); if hooks.is_empty() { - return; + return HookRunResult::default(); } let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else { - return; + return HookRunResult::default(); }; - let payload = build_exec_hook_payload(event, exec_ctx, params, output); + let payload = build_exec_hook_payload(self, event, exec_ctx, params, output, None); + let mut result = HookRunResult::default(); for (idx, hook) in hooks.into_iter().enumerate() { - self - .run_hook_command(turn_diff_tracker, &hook, event, &payload, Some(exec_ctx), attempt_req, idx) + let output = self + .run_hook_command( + turn_diff_tracker, + &hook, + event, + &payload, + Some(exec_ctx), + attempt_req, + idx, + ) .await; + result.apply(output); + if !result.continue_processing { + break; + } } + result } - pub(super) async fn run_session_hooks(&self, event: ProjectHookEvent) { + pub(super) async fn run_hooks_for_event( + &self, + turn_diff_tracker: &mut TurnDiffTracker, + event: ProjectHookEvent, + payload: &Value, + base_ctx: Option<&ExecCommandContext>, + attempt_req: u64, + ) -> HookRunResult { if self.project_hooks.is_empty() { - return; + return HookRunResult::default(); } let hooks: Vec = self.project_hooks.hooks_for(event).cloned().collect(); if hooks.is_empty() { - return; + return HookRunResult::default(); } let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else { - return; + return HookRunResult::default(); }; + + let mut result = HookRunResult::default(); + for (idx, hook) in hooks.into_iter().enumerate() { + let output = self + .run_hook_command( + turn_diff_tracker, + &hook, + event, + payload, + base_ctx, + attempt_req, + idx, + ) + .await; + result.apply(output); + if !result.continue_processing { + break; + } + } + + result + } + + pub(super) async fn run_session_hooks(&self, event: ProjectHookEvent) { let payload = self.build_session_payload(event); let mut tracker = TurnDiffTracker::new(); let attempt_req = self.current_request_ordinal(); - for (idx, hook) in hooks.into_iter().enumerate() { - self - .run_hook_command(&mut tracker, &hook, event, &payload, None, attempt_req, idx) - .await; + let result = self + .run_hooks_for_event(&mut tracker, event, &payload, None, attempt_req) + .await; + self.enqueue_hook_system_messages(result.system_messages); + } + + pub(super) fn enqueue_hook_system_messages(&self, messages: Vec) { + if self.client.get_provider().wire_api == crate::model_provider_info::WireApi::Responses { + return; + } + for message in messages { + let trimmed = message.trim(); + if trimmed.is_empty() { + continue; + } + self.add_pending_input(ResponseInputItem::Message { + role: "system".to_string(), + content: vec![ContentItem::InputText { + text: trimmed.to_string(), + }], + }); } } @@ -730,7 +1104,7 @@ impl Session { base_ctx: Option<&ExecCommandContext>, attempt_req: u64, index: usize, - ) { + ) -> HookOutput { let sub_id = base_ctx .map(|ctx| ctx.sub_id.clone()) .unwrap_or_else(|| INITIAL_SUBMIT_ID.to_string()); @@ -786,29 +1160,80 @@ impl Session { stdout_stream: None, }; - if let Err(err) = Box::pin(self.run_exec_with_events_inner( - turn_diff_tracker, - exec_ctx, - exec_args, - None, - None, - attempt_req, - false, - )) - .await - { - let hook_label = hook - .name - .as_deref() - .unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook")); - let order = self.next_background_order(&sub_id, attempt_req, None); - self - .notify_background_event_with_order( - &sub_id, - order, - format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)), - ) - .await; + let ExecInvokeArgs { + params, + sandbox_type, + sandbox_policy, + sandbox_cwd, + code_linux_sandbox_exe, + stdout_stream, + } = exec_args; + + let result = if hook.run_in_background { + crate::exec::process_exec_tool_call( + params, + sandbox_type, + sandbox_policy, + sandbox_cwd, + code_linux_sandbox_exe, + None, + ) + .await + } else { + let exec_args = ExecInvokeArgs { + params, + sandbox_type, + sandbox_policy, + sandbox_cwd, + code_linux_sandbox_exe, + stdout_stream, + }; + Box::pin(self.run_exec_with_events_inner( + turn_diff_tracker, + exec_ctx, + exec_args, + None, + None, + attempt_req, + false, + )) + .await + }; + + match result { + Ok(output) => { + let stdout_text = output.stdout.text.trim(); + let stderr_text = output.stderr.text.trim(); + let mut hook_output = if !stdout_text.is_empty() { + parse_hook_output_from_text(&output.stdout.text) + } else if !stderr_text.is_empty() { + parse_hook_output_from_text(&output.stderr.text) + } else { + HookOutput::default() + }; + if output.exit_code == 2 { + if hook_output.system_message.is_none() && !stderr_text.is_empty() { + hook_output.system_message = Some(output.stderr.text.clone()); + } + hook_output.continue_processing = Some(false); + } + hook_output + } + Err(err) => { + let hook_label = hook + .name + .as_deref() + .unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook")); + let order = self.next_background_order(&sub_id, attempt_req, None); + self + .notify_background_event_with_order( + &sub_id, + order, + format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)), + ) + .await; + HookOutput::default() + } } } diff --git a/code-rs/core/src/codex/session.rs b/code-rs/core/src/codex/session.rs index 94d64088446..690c165c449 100644 --- a/code-rs/core/src/codex/session.rs +++ b/code-rs/core/src/codex/session.rs @@ -1844,7 +1844,15 @@ impl Session { /// Spawn the configured notifier (if any) with the given JSON payload as /// the last argument. Failures are logged but otherwise ignored so that /// notification issues do not interfere with the main workflow. - pub(super) fn maybe_notify(&self, notification: UserNotification) { + pub(super) async fn maybe_notify(&self, notification: UserNotification) { + let payload = build_notification_hook_payload(self, ¬ification); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = self.current_request_ordinal(); + let result = self + .run_hooks_for_event(&mut tracker, ProjectHookEvent::Notification, &payload, None, attempt_req) + .await; + self.enqueue_hook_system_messages(result.system_messages); + let Some(notify_command) = &self.notify else { return; }; diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index 7b6d789e06a..b29e5f1c4f7 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -3,6 +3,9 @@ use super::exec::{ ApplyPatchCommandContext, ExecCommandContext, ExecInvokeArgs, + build_precompact_hook_payload, + build_stop_hook_payload, + build_user_prompt_hook_payload, maybe_run_with_user_profile, }; use super::session::{ @@ -130,6 +133,23 @@ impl AgentTask { } } +fn user_prompt_text_from_items(items: &[InputItem]) -> Option { + let mut parts = Vec::new(); + for item in items { + if let InputItem::Text { text } = item { + let trimmed = text.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + pub(super) async fn submission_loop( mut session_id: Uuid, config: Arc, @@ -777,6 +797,34 @@ pub(super) async fn submission_loop( }), ); let _ = tx_event_clone.send(status_event).await; + + for agent in &payload.agents { + if agent.status != "completed" { + continue; + } + let payload = build_stop_hook_payload( + &sess_for_agents, + ProjectHookEvent::SubagentStop, + Some("subagent_complete".to_string()), + Some(serde_json::json!({ + "agent_id": agent.id, + "agent_name": agent.name, + "status": agent.status, + })), + ); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess_for_agents.current_request_ordinal(); + let result = sess_for_agents + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::SubagentStop, + &payload, + None, + attempt_req, + ) + .await; + sess_for_agents.enqueue_hook_system_messages(result.system_messages); + } } }); agent_manager_initialized = true; @@ -795,6 +843,22 @@ pub(super) async fn submission_loop( // This prevents token buildup from old screenshots/status messages sess.cleanup_old_status_items().await; + if let Some(prompt_text) = user_prompt_text_from_items(&items) { + let payload = build_user_prompt_hook_payload(sess, &prompt_text); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::UserPromptSubmit, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(result.system_messages); + } + // Abort synchronously here to avoid a race that can kill the // newly spawned agent if the async abort runs after set_task. sess.notify_wait_interrupted(WaitInterruptReason::UserMessage); @@ -827,6 +891,21 @@ pub(super) async fn submission_loop( } else { // No task running: treat this as immediate user input without aborting. sess.cleanup_old_status_items().await; + if let Some(prompt_text) = user_prompt_text_from_items(&items) { + let payload = build_user_prompt_hook_payload(sess, &prompt_text); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::UserPromptSubmit, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(result.system_messages); + } let turn_context = sess.make_turn_context(); let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items); sess.set_task(agent); @@ -1046,6 +1125,20 @@ pub(super) async fn submission_loop( } }; + let payload = build_precompact_hook_payload(sess, "manual"); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::PreCompact, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(result.system_messages); + let prompt_text = sess.compact_prompt_text(); // Attempt to inject input into current task if let Err(items) = sess.inject_input(vec![InputItem::Text { @@ -1443,6 +1536,8 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S } let mut last_task_message: Option = None; + let mut last_turn_input_messages: Vec = Vec::new(); + let mut did_notify = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Agent which contains // many turns, from the perspective of the user, it is a single turn. let mut turn_diff_tracker = TurnDiffTracker::new(); @@ -1528,6 +1623,7 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S }) }) .collect(); + last_turn_input_messages = turn_input_messages.clone(); match run_turn( &sess, &turn_context, @@ -1727,11 +1823,34 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S if let Some(m) = last_task_message.as_ref() { tracing::info!("core.turn completed: last_assistant_message.len={}", m.len()); } - sess.maybe_notify(UserNotification::AgentTurnComplete { + let stop_payload = build_stop_hook_payload( + sess, + ProjectHookEvent::Stop, + Some("turn_complete".to_string()), + Some(serde_json::json!({ + "last_assistant_message": last_task_message.clone(), + })), + ); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let stop_result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::Stop, + &stop_payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(stop_result.system_messages); + sess + .maybe_notify(UserNotification::AgentTurnComplete { turn_id: sub_id.clone(), input_messages: turn_input_messages, last_assistant_message: last_task_message.clone(), - }); + }) + .await; + did_notify = true; break; } } @@ -1751,6 +1870,17 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S } } } + + if !did_notify { + sess + .maybe_notify(UserNotification::AgentTurnComplete { + turn_id: sub_id.clone(), + input_messages: last_turn_input_messages, + last_assistant_message: last_task_message.clone(), + }) + .await; + } + if is_review_mode && !review_exit_emitted { let combined = if !review_messages.is_empty() { review_messages.join("\n\n") diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index fa898967218..ca2d7a80f86 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -377,6 +377,9 @@ pub struct Config { /// Whether we're using ChatGPT authentication (affects feature availability) pub using_chatgpt_auth: bool, + /// Preferred auth method (when both ChatGPT tokens and API key are present). + pub preferred_auth_method: Option, + /// When true, automatically switch to another connected account when the /// current account hits a rate/usage limit. pub auto_switch_accounts_on_rate_limit: bool, @@ -1421,6 +1424,7 @@ impl Config { debug: debug.unwrap_or(false), // Already computed before moving code_home using_chatgpt_auth, + preferred_auth_method: cfg.preferred_auth_method, auto_switch_accounts_on_rate_limit, api_key_fallback_on_all_accounts_limited, github: cfg.github.unwrap_or_default(), @@ -1468,6 +1472,17 @@ impl Config { _ => false, } } + + /// Preferred auth mode for this configuration. + pub fn preferred_auth_mode(&self) -> AuthMode { + self.preferred_auth_method.unwrap_or_else(|| { + if self.using_chatgpt_auth { + AuthMode::ChatGPT + } else { + AuthMode::ApiKey + } + }) + } fn load_instructions(code_dir: Option<&Path>) -> Option { sources::load_instructions(code_dir) diff --git a/code-rs/core/src/lib.rs b/code-rs/core/src/lib.rs index 151aec67263..3b314637da7 100644 --- a/code-rs/core/src/lib.rs +++ b/code-rs/core/src/lib.rs @@ -68,6 +68,7 @@ mod cgroup; pub mod agent_defaults; mod agent_tool; pub use agent_tool::AGENT_MANAGER; +pub use agent_tool::AgentStatusUpdatePayload; mod dry_run_guard; mod image_comparison; pub mod git_worktree; diff --git a/code-rs/core/tests/hook_events.rs b/code-rs/core/tests/hook_events.rs new file mode 100644 index 00000000000..3243553b569 --- /dev/null +++ b/code-rs/core/tests/hook_events.rs @@ -0,0 +1,522 @@ +#![allow(clippy::unwrap_used)] + +mod common; + +use common::{load_default_config_for_test, wait_for_event}; + +use code_core::built_in_model_providers; +use code_core::config_types::{ProjectHookConfig, ProjectHookEvent}; +use code_core::project_features::ProjectHooks; +use code_core::protocol::{AgentInfo, AgentSourceKind, AskForApproval, EventMsg, InputItem, Op, SandboxPolicy}; +use code_core::{AgentStatusUpdatePayload, CodexAuth, ConversationManager, ModelProviderInfo, AGENT_MANAGER}; +use serde_json::json; +use std::fs::{self, File}; +use std::path::Path; +use std::time::{Duration, Instant}; +use tempfile::TempDir; +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +static HOOK_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn hook_test_guard() -> std::sync::MutexGuard<'static, ()> { + HOOK_TEST_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) +} + +fn hook_cmd(log_path: &Path, label: &str) -> Vec { + vec![ + "bash".to_string(), + "-lc".to_string(), + format!( + "printf '%s\\n' \"{}:$CODE_HOOK_EVENT\" >> {}", + label, + log_path.display() + ), + ] +} + +fn hook_config_with_background( + event: ProjectHookEvent, + label: &str, + log_path: &Path, + run_in_background: bool, +) -> ProjectHookConfig { + ProjectHookConfig { + event, + name: Some(label.to_string()), + command: hook_cmd(log_path, label), + cwd: None, + env: None, + timeout_ms: Some(1500), + run_in_background: Some(run_in_background), + } +} + +fn hook_config(event: ProjectHookEvent, label: &str, log_path: &Path) -> ProjectHookConfig { + hook_config_with_background(event, label, log_path, false) +} +fn sse_response(body: String) -> ResponseTemplate { + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body) +} + +fn sse_message_body(message: &str, msg_id: &str, resp_id: &str) -> String { + let message_item = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "id": msg_id, + "role": "assistant", + "content": [{"type": "output_text", "text": message}], + } + }); + let completed = json!({ + "type": "response.completed", + "response": { + "id": resp_id, + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + } + } + }); + + format!( + "event: response.output_item.done\ndata: {}\n\n\ +event: response.completed\ndata: {}\n\n", + message_item, completed + ) +} + +fn sse_function_call_body(call_id: &str, name: &str, args: serde_json::Value, resp_id: &str) -> String { + let function_call_item = json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": call_id, + "call_id": call_id, + "name": name, + "arguments": args.to_string(), + } + }); + let completed = json!({ + "type": "response.completed", + "response": { + "id": resp_id, + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + } + } + }); + format!( + "event: response.output_item.done\ndata: {}\n\n\ +event: response.completed\ndata: {}\n\n", + function_call_item, completed + ) +} + +async fn wait_for_log_contains(path: &Path, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(contents) = fs::read_to_string(path) { + if contents.contains(needle) { + return; + } + } + if Instant::now() > deadline { + let contents = fs::read_to_string(path).unwrap_or_default(); + panic!( + "timed out waiting for log entry: {} (contents: {})", + needle, contents + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_for_event_with_timeout( + codex: &code_core::CodexConversation, + timeout: Duration, + mut predicate: F, +) -> Option +where + F: FnMut(&EventMsg) -> bool, +{ + let deadline = Instant::now() + timeout; + loop { + let now = Instant::now(); + if now >= deadline { + return None; + } + let remaining = deadline.saturating_duration_since(now); + let event = match tokio::time::timeout(remaining, codex.next_event()).await { + Ok(Ok(event)) => event, + Ok(Err(_)) => return None, + Err(_) => return None, + }; + if predicate(&event.msg) { + return Some(event.msg); + } + } +} + +fn base_config(code_home: &TempDir, project_dir: &TempDir) -> code_core::config::Config { + let mut config = load_default_config_for_test(code_home); + config.cwd = project_dir.path().to_path_buf(); + config.approval_policy = AskForApproval::Never; + config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config +} + +fn attach_mock_provider(config: &mut code_core::config::Config, server: &MockServer) { + config.model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + config.model = "gpt-5.1-codex".to_string(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_start_end_hooks_fire() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![ + hook_config(ProjectHookEvent::SessionStart, "start", &log_path), + hook_config(ProjectHookEvent::SessionEnd, "end", &log_path), + ]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + wait_for_log_contains(&log_path, "start:session.start").await; + + codex.submit(Op::Shutdown).await.unwrap(); + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::ShutdownComplete)).await; + wait_for_log_contains(&log_path, "end:session.end").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_prompt_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![ + hook_config(ProjectHookEvent::UserPromptSubmit, "prompt", &log_path), + ]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let body = sse_message_body("ok", "msg-1", "resp-1"); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { text: "hello".into() }], + }) + .await + .unwrap(); + + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "prompt:user.prompt_submit").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![hook_config(ProjectHookEvent::Stop, "stop", &log_path)]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let body = sse_message_body("ok", "msg-1", "resp-1"); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { text: "hello".into() }], + }) + .await + .unwrap(); + + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "stop:stop").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notification_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![ + hook_config(ProjectHookEvent::SessionStart, "start", &log_path), + hook_config_with_background(ProjectHookEvent::Notification, "notify", &log_path, true), + ]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let body = sse_message_body("ok", "msg-1", "resp-1"); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + wait_for_log_contains(&log_path, "start:session.start").await; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { text: "hello".into() }], + }) + .await + .unwrap(); + + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "notify:notification").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_before_after_write_hooks_fire() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![ + hook_config(ProjectHookEvent::FileBeforeWrite, "before", &log_path), + hook_config(ProjectHookEvent::FileAfterWrite, "after", &log_path), + ]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch"; + let script = format!("apply_patch <<'EOF'\n{patch}\nEOF"); + let call_args = json!({ + "command": ["bash", "-lc", script], + "workdir": config.cwd, + "timeout_ms": null, + "sandbox_permissions": null, + "justification": null, + }); + + let first_body = sse_function_call_body("call-1", "shell", call_args, "resp-1"); + let second_body = sse_message_body("done", "msg-1", "resp-2"); + + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(first_body)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(second_body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "apply patch".into(), + }], + }) + .await + .unwrap(); + + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "before:file.before_write").await; + wait_for_log_contains(&log_path, "after:file.after_write").await; + + let created = project_dir.path().join("hello.txt"); + assert!(created.exists(), "apply_patch did not create file"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn precompact_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![hook_config(ProjectHookEvent::PreCompact, "precompact", &log_path)]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let body = sse_message_body("summary", "msg-1", "resp-1"); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex.submit(Op::Compact).await.unwrap(); + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "precompact:pre.compact").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_stop_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![hook_config_with_background( + ProjectHookEvent::SubagentStop, + "subagent", + &log_path, + true, + )]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let payload = AgentStatusUpdatePayload { + agents: vec![AgentInfo { + id: "agent-1".to_string(), + name: "agent-1".to_string(), + status: "completed".to_string(), + batch_id: Some("batch-1".to_string()), + model: Some("gpt-5.1-codex".to_string()), + last_progress: None, + result: Some("ok".to_string()), + error: None, + elapsed_ms: Some(1), + token_count: None, + last_activity_at: None, + seconds_since_last_activity: None, + source_kind: Some(AgentSourceKind::Default), + }], + context: None, + task: None, + }; + + let manager = AGENT_MANAGER.read().await; + let mut received = false; + for _ in 0..5 { + manager.emit_status_update(payload.clone()); + if wait_for_event_with_timeout( + &codex, + Duration::from_millis(400), + |msg| matches!(msg, EventMsg::AgentStatusUpdate(_)), + ) + .await + .is_some() + { + received = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(received, "timed out waiting for AgentStatusUpdate event"); + wait_for_log_contains(&log_path, "subagent:subagent.stop").await; +} diff --git a/code-rs/exec/src/lib.rs b/code-rs/exec/src/lib.rs index 0c29fa74355..1e05406cb5a 100644 --- a/code-rs/exec/src/lib.rs +++ b/code-rs/exec/src/lib.rs @@ -480,7 +480,7 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option) -> anyh let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - code_protocol::mcp_protocol::AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); diff --git a/code-rs/mcp-server/src/message_processor.rs b/code-rs/mcp-server/src/message_processor.rs index 5515cb42d51..95d34b5ebd7 100644 --- a/code-rs/mcp-server/src/message_processor.rs +++ b/code-rs/mcp-server/src/message_processor.rs @@ -33,7 +33,6 @@ use code_core::default_client::get_code_user_agent_default; use code_core::model_family::{derive_default_model_family, find_family_for_model}; use code_core::protocol::Submission; use code_core::protocol::Op; -use code_app_server_protocol::AuthMode; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::ClientRequest as McpClientRequest; @@ -76,7 +75,7 @@ impl MessageProcessor { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let conversation_manager = Arc::new(ConversationManager::new( diff --git a/code-rs/tui/src/app/events.rs b/code-rs/tui/src/app/events.rs index d9dd7d23e28..8219eee39a3 100644 --- a/code-rs/tui/src/app/events.rs +++ b/code-rs/tui/src/app/events.rs @@ -13,7 +13,7 @@ use code_cloud_tasks_client::{CloudTaskError, TaskId}; use code_core::config::add_project_allowed_command; use code_core::config_types::Notifications; use code_core::protocol::{Event, Op, SandboxPolicy}; -use code_login::{AuthManager, AuthMode, ServerOptions}; +use code_login::{AuthManager, ServerOptions}; use portable_pty::PtySize; use crate::app_event::AppEvent; @@ -2198,7 +2198,7 @@ impl App<'_> { } else { let auth_manager = AuthManager::shared_with_mode_and_originator( cfg.code_home.clone(), - AuthMode::ApiKey, + cfg.preferred_auth_mode(), cfg.responses_originator_header.clone(), ); let mut new_widget = ChatWidget::new_from_existing( diff --git a/code-rs/tui/src/app/init.rs b/code-rs/tui/src/app/init.rs index 2d1fce1fb6d..d71195bdf78 100644 --- a/code-rs/tui/src/app/init.rs +++ b/code-rs/tui/src/app/init.rs @@ -14,7 +14,7 @@ use signal_hook::flag; use code_core::config::Config; use code_core::ConversationManager; -use code_login::{AuthManager, AuthMode}; +use code_login::AuthManager; use code_protocol::protocol::SessionSource; use crate::app_event::AppEvent; @@ -44,7 +44,7 @@ impl App<'_> { ) -> Self { let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); let conversation_manager = Arc::new(ConversationManager::new( diff --git a/code-rs/tui/src/bottom_pane/theme_selection_view.rs b/code-rs/tui/src/bottom_pane/theme_selection_view.rs index cb659060715..6b64bc97ea7 100644 --- a/code-rs/tui/src/bottom_pane/theme_selection_view.rs +++ b/code-rs/tui/src/bottom_pane/theme_selection_view.rs @@ -358,16 +358,9 @@ impl ThemeSelectionView { return; } }; - // Use the same auth preference as the active Codex session. - // When logged in with ChatGPT, prefer ChatGPT auth; otherwise fall back to API key. - let preferred_auth = if cfg.using_chatgpt_auth { - code_protocol::mcp_protocol::AuthMode::ChatGPT - } else { - code_protocol::mcp_protocol::AuthMode::ApiKey - }; let auth_mgr = code_core::AuthManager::shared_with_mode_and_originator( cfg.code_home.clone(), - preferred_auth, + cfg.preferred_auth_mode(), cfg.responses_originator_header.clone(), ); let client = code_core::ModelClient::new( @@ -693,7 +686,7 @@ impl ThemeSelectionView { }; let auth_mgr = code_core::AuthManager::shared_with_mode_and_originator( cfg.code_home.clone(), - code_protocol::mcp_protocol::AuthMode::ApiKey, + cfg.preferred_auth_mode(), cfg.responses_originator_header.clone(), ); let client = code_core::ModelClient::new( diff --git a/code-rs/tui/src/chatwidget.rs b/code-rs/tui/src/chatwidget.rs index d4775d4b519..23d498e0d88 100644 --- a/code-rs/tui/src/chatwidget.rs +++ b/code-rs/tui/src/chatwidget.rs @@ -53,7 +53,6 @@ use code_core::account_usage::{ }; use code_core::auth_accounts::{self, StoredAccount}; use code_login::AuthManager; -use code_login::AuthMode; use code_protocol::mcp_protocol::AuthMode as McpAuthMode; use code_protocol::protocol::SessionSource; use code_protocol::num_format::format_with_separators; @@ -6280,7 +6279,7 @@ impl ChatWidget<'_> { let auth_manager = AuthManager::shared_with_mode_and_originator( config.code_home.clone(), - AuthMode::ApiKey, + config.preferred_auth_mode(), config.responses_originator_header.clone(), ); diff --git a/code-rs/tui/src/chatwidget/agent_install.rs b/code-rs/tui/src/chatwidget/agent_install.rs index 1950cc086d2..019372457de 100644 --- a/code-rs/tui/src/chatwidget/agent_install.rs +++ b/code-rs/tui/src/chatwidget/agent_install.rs @@ -248,14 +248,9 @@ fn run_guided_loop( None => Config::load_with_cli_overrides(vec![], ConfigOverrides::default()) .context("loading config")?, }; - let preferred_auth = if cfg.using_chatgpt_auth { - code_protocol::mcp_protocol::AuthMode::ChatGPT - } else { - code_protocol::mcp_protocol::AuthMode::ApiKey - }; let auth_mgr = AuthManager::shared_with_mode_and_originator( cfg.code_home.clone(), - preferred_auth, + cfg.preferred_auth_mode(), cfg.responses_originator_header.clone(), ); let client = ModelClient::new( diff --git a/code-rs/tui/src/chatwidget/rate_limit_refresh.rs b/code-rs/tui/src/chatwidget/rate_limit_refresh.rs index a357d679d83..aafdf5c03ad 100644 --- a/code-rs/tui/src/chatwidget/rate_limit_refresh.rs +++ b/code-rs/tui/src/chatwidget/rate_limit_refresh.rs @@ -122,21 +122,14 @@ fn run_refresh( Some(account), ) } - None => { - let auth_mode = if config.using_chatgpt_auth { - code_protocol::mcp_protocol::AuthMode::ChatGPT - } else { - code_protocol::mcp_protocol::AuthMode::ApiKey - }; - ( - AuthManager::shared_with_mode_and_originator( - config.code_home.clone(), - auth_mode, - config.responses_originator_header.clone(), - ), - None, - ) - } + None => ( + AuthManager::shared_with_mode_and_originator( + config.code_home.clone(), + config.preferred_auth_mode(), + config.responses_originator_header.clone(), + ), + None, + ), }; let client = build_model_client(&config, auth_mgr, debug_enabled)?; diff --git a/hooks/ask_user_prompt.py b/hooks/ask_user_prompt.py new file mode 100755 index 00000000000..01a81b419ba --- /dev/null +++ b/hooks/ask_user_prompt.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import json +import sys + +def main(): + try: + payload = json.load(sys.stdin) + except Exception: + payload = {} + + prompt = (payload.get("user_prompt") or "").strip() + lowered = prompt.lower() + trigger = lowered.startswith("hook:") + + if not trigger: + print(json.dumps({"hookSpecificOutput": {"permissionDecision": "allow"}})) + return + + shown = prompt + max_len = 280 + if len(shown) > max_len: + shown = shown[:max_len].rstrip() + "..." + + message = "Hook gate: approve this prompt to continue." + if shown: + message = f"Hook gate: approve this prompt to continue.\n\nUser prompt: {shown}" + + print( + json.dumps( + { + "hookSpecificOutput": {"permissionDecision": "ask"}, + "systemMessage": message, + } + ) + ) + +if __name__ == "__main__": + main() diff --git a/hooks/check_shell.py b/hooks/check_shell.py new file mode 100755 index 00000000000..a304356a1a3 --- /dev/null +++ b/hooks/check_shell.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import json +import sys + +def load_payload(): + try: + return json.load(sys.stdin) + except Exception: + return {} + +def command_from_payload(payload): + tool_input = payload.get("tool_input") or {} + cmd = tool_input.get("command") + if isinstance(cmd, list): + return " ".join(cmd) + if isinstance(cmd, str): + return cmd + return "" + +def is_risky(command): + lowered = command.lower() + risky_terms = [ + "rm -rf", + "rm -r", + "mkfs", + "dd if=", + ":(){:|:&};:", + "shutdown -h", + "reboot", + ] + return any(term in lowered for term in risky_terms) + +def main(): + payload = load_payload() + command = command_from_payload(payload) + if not command: + print(json.dumps({"hookSpecificOutput": {"permissionDecision": "allow"}})) + return + + if is_risky(command): + print( + json.dumps( + { + "hookSpecificOutput": {"permissionDecision": "ask"}, + "systemMessage": ( + "Command guard: risky command requires explicit confirmation.\n\n" + f"Command: {command}" + ), + } + ) + ) + return + + print(json.dumps({"hookSpecificOutput": {"permissionDecision": "allow"}})) + +if __name__ == "__main__": + main() diff --git a/scripts/run-hooks-test.sh b/scripts/run-hooks-test.sh new file mode 100755 index 00000000000..fe14a76cce7 --- /dev/null +++ b/scripts/run-hooks-test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CODE_HOME="${ROOT}/.tmp-hooks-test" + +mkdir -p "$CODE_HOME" + +cat > "${CODE_HOME}/config.toml" <&2 + exit 1 +fi + +CODE_HOME="${CODE_HOME}" "${BIN}" -C "${ROOT}" From 9223b3d8b3c590e2ded994d80c2974680d8bab30 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 15:50:38 -0700 Subject: [PATCH 2/9] fix(hooks): wire extra hook events --- code-rs/core/src/codex/exec.rs | 19 ------- code-rs/core/src/codex/session.rs | 1 + code-rs/core/src/codex/streaming.rs | 4 +- code-rs/core/src/config_types.rs | 79 ++++++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 736a29d1664..5a49e349b25 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -469,25 +469,6 @@ fn build_exec_hook_payload( } } -pub(super) fn build_tool_hook_payload( - sess: &Session, - event: ProjectHookEvent, - tool_name: &str, - tool_input: Option, - tool_result: Option, - tool_use_id: &str, -) -> Value { - let mut base = build_base_hook_payload(sess, event); - base.insert("event".to_string(), Value::String(event.as_str().to_string())); - base.insert("tool_name".to_string(), Value::String(tool_name.to_string())); - base.insert("tool_use_id".to_string(), Value::String(tool_use_id.to_string())); - base.insert("tool_input".to_string(), tool_input.unwrap_or(Value::Null)); - if let Some(result) = tool_result { - base.insert("tool_result".to_string(), result); - } - Value::Object(base) -} - pub(super) fn build_user_prompt_hook_payload(sess: &Session, prompt: &str) -> Value { let mut base = build_base_hook_payload(sess, ProjectHookEvent::UserPromptSubmit); base.insert( diff --git a/code-rs/core/src/codex/session.rs b/code-rs/core/src/codex/session.rs index 690c165c449..38b0d846d0e 100644 --- a/code-rs/core/src/codex/session.rs +++ b/code-rs/core/src/codex/session.rs @@ -1,4 +1,5 @@ use super::*; +use super::exec::build_notification_hook_payload; use super::streaming::{ AgentTask, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL, diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index b29e5f1c4f7..ff45abe952c 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -803,7 +803,7 @@ pub(super) async fn submission_loop( continue; } let payload = build_stop_hook_payload( - &sess_for_agents, + sess_for_agents.as_ref(), ProjectHookEvent::SubagentStop, Some("subagent_complete".to_string()), Some(serde_json::json!({ @@ -1824,7 +1824,7 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S tracing::info!("core.turn completed: last_assistant_message.len={}", m.len()); } let stop_payload = build_stop_hook_payload( - sess, + sess.as_ref(), ProjectHookEvent::Stop, Some("turn_complete".to_string()), Some(serde_json::json!({ diff --git a/code-rs/core/src/config_types.rs b/code-rs/core/src/config_types.rs index 8fc2ad6bf1d..1e17529b966 100644 --- a/code-rs/core/src/config_types.rs +++ b/code-rs/core/src/config_types.rs @@ -1334,18 +1334,59 @@ where #[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] pub enum ProjectHookEvent { - #[serde(rename = "session.start")] + #[serde(rename = "session.start", alias = "session_start", alias = "session-start", alias = "SessionStart")] SessionStart, - #[serde(rename = "session.end")] + #[serde(rename = "session.end", alias = "session_end", alias = "session-end", alias = "SessionEnd")] SessionEnd, - #[serde(rename = "tool.before")] + #[serde( + rename = "tool.before", + alias = "tool_before", + alias = "tool-before", + alias = "PreToolUse", + alias = "pre_tool_use", + alias = "pre-tool-use", + alias = "pretooluse" + )] ToolBefore, - #[serde(rename = "tool.after")] + #[serde( + rename = "tool.after", + alias = "tool_after", + alias = "tool-after", + alias = "PostToolUse", + alias = "post_tool_use", + alias = "post-tool-use", + alias = "posttooluse" + )] ToolAfter, - #[serde(rename = "file.before_write")] + #[serde(rename = "file.before_write", alias = "file_before_write", alias = "file-before-write")] FileBeforeWrite, - #[serde(rename = "file.after_write")] + #[serde(rename = "file.after_write", alias = "file_after_write", alias = "file-after-write")] FileAfterWrite, + #[serde(rename = "stop", alias = "Stop")] + Stop, + #[serde( + rename = "subagent.stop", + alias = "subagent_stop", + alias = "subagent-stop", + alias = "SubagentStop" + )] + SubagentStop, + #[serde( + rename = "user.prompt_submit", + alias = "user_prompt_submit", + alias = "user-prompt-submit", + alias = "UserPromptSubmit" + )] + UserPromptSubmit, + #[serde( + rename = "pre.compact", + alias = "pre_compact", + alias = "pre-compact", + alias = "PreCompact" + )] + PreCompact, + #[serde(rename = "notification", alias = "Notification", alias = "notify")] + Notification, } impl ProjectHookEvent { @@ -1357,6 +1398,27 @@ impl ProjectHookEvent { ProjectHookEvent::ToolAfter => "tool.after", ProjectHookEvent::FileBeforeWrite => "file.before_write", ProjectHookEvent::FileAfterWrite => "file.after_write", + ProjectHookEvent::Stop => "stop", + ProjectHookEvent::SubagentStop => "subagent.stop", + ProjectHookEvent::UserPromptSubmit => "user.prompt_submit", + ProjectHookEvent::PreCompact => "pre.compact", + ProjectHookEvent::Notification => "notification", + } + } + + pub fn hook_event_name(&self) -> &'static str { + match self { + ProjectHookEvent::SessionStart => "SessionStart", + ProjectHookEvent::SessionEnd => "SessionEnd", + ProjectHookEvent::ToolBefore => "PreToolUse", + ProjectHookEvent::ToolAfter => "PostToolUse", + ProjectHookEvent::FileBeforeWrite => "FileBeforeWrite", + ProjectHookEvent::FileAfterWrite => "FileAfterWrite", + ProjectHookEvent::Stop => "Stop", + ProjectHookEvent::SubagentStop => "SubagentStop", + ProjectHookEvent::UserPromptSubmit => "UserPromptSubmit", + ProjectHookEvent::PreCompact => "PreCompact", + ProjectHookEvent::Notification => "Notification", } } @@ -1368,6 +1430,11 @@ impl ProjectHookEvent { ProjectHookEvent::ToolAfter => "tool_after", ProjectHookEvent::FileBeforeWrite => "file_before_write", ProjectHookEvent::FileAfterWrite => "file_after_write", + ProjectHookEvent::Stop => "stop", + ProjectHookEvent::SubagentStop => "subagent_stop", + ProjectHookEvent::UserPromptSubmit => "user_prompt_submit", + ProjectHookEvent::PreCompact => "pre_compact", + ProjectHookEvent::Notification => "notification", } } } From 4b83915eff248e042f691e9195e1e560555cf8c0 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 16:27:50 -0700 Subject: [PATCH 3/9] feat(hooks): add post-compact event --- code-rs/core/src/codex/compact.rs | 29 +++++++++++++++++++- code-rs/core/src/codex/compact_remote.rs | 9 ++++-- code-rs/core/src/codex/exec.rs | 10 +++++++ code-rs/core/src/config_types.rs | 10 +++++++ code-rs/core/tests/hook_events.rs | 35 ++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/code-rs/core/src/codex/compact.rs b/code-rs/core/src/codex/compact.rs index 2cbc72739d2..5d3f87c5b6f 100644 --- a/code-rs/core/src/codex/compact.rs +++ b/code-rs/core/src/codex/compact.rs @@ -6,8 +6,10 @@ use super::Session; use super::compact_remote; use super::TurnContext; use super::streaming::get_last_assistant_message_from_turn; +use super::exec::build_postcompact_hook_payload; use crate::Prompt; use crate::client_common::ResponseEvent; +use crate::config_types::ProjectHookEvent; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::RetryAfter; @@ -15,6 +17,7 @@ use crate::error::Result as CodexResult; use crate::protocol::AgentMessageEvent; use crate::protocol::ErrorEvent; use crate::protocol::EventMsg; +use crate::turn_diff_tracker::TurnDiffTracker; use code_protocol::protocol::CompactionCheckpointWarningEvent; use crate::protocol::InputItem; use crate::protocol::TaskCompleteEvent; @@ -175,7 +178,7 @@ pub(super) async fn run_inline_auto_compact_task( let sub_id = sess.next_internal_sub_id(); let prompt_text = resolve_compact_prompt_text(turn_context.compact_prompt_override.as_deref()); let input = vec![InputItem::Text { text: prompt_text.clone() }]; - run_compact_task_inner_inline(sess, turn_context, sub_id, input).await + run_compact_task_inner_inline(sess, turn_context, sub_id, input, "auto").await } pub(super) async fn run_compact_task( @@ -192,6 +195,7 @@ pub(super) async fn run_compact_task( Arc::clone(&turn_context), sub_id.clone(), input, + "manual", ) .await } else { @@ -201,6 +205,7 @@ pub(super) async fn run_compact_task( sub_id.clone(), input, true, + "manual", ) .await }; @@ -222,6 +227,7 @@ pub(super) async fn perform_compaction( sub_id: String, input: Vec, remove_task_on_completion: bool, + reason: &str, ) -> CodexResult<()> { // Convert core InputItem -> ResponseInputItem using the same logic as the main turn flow let initial_input_for_turn: ResponseInputItem = response_input_from_core_items(input); @@ -361,6 +367,8 @@ pub(super) async fn perform_compaction( // state bookkeeping stays centralized. sess.replace_history(new_history); + run_postcompact_hook(&sess, reason).await; + send_compaction_checkpoint_warning(&sess, &sub_id).await; let rollout_item = RolloutItem::Compacted(CompactedItem { @@ -389,6 +397,7 @@ async fn run_compact_task_inner_inline( turn_context: Arc, sub_id: String, input: Vec, + reason: &str, ) -> Vec { // Convert core InputItem -> ResponseInputItem and build prompt let initial_input_for_turn: ResponseInputItem = response_input_from_core_items(input); @@ -526,6 +535,8 @@ async fn run_compact_task_inner_inline( state.token_usage_info = None; } + run_postcompact_hook(&sess, reason).await; + send_compaction_checkpoint_warning(&sess, &sub_id).await; let rollout_item = RolloutItem::Compacted(CompactedItem { @@ -549,6 +560,22 @@ async fn run_compact_task_inner_inline( new_history } +pub(super) async fn run_postcompact_hook(sess: &Session, reason: &str) { + let payload = build_postcompact_hook_payload(sess, reason); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::PostCompact, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(result.system_messages); +} + pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { diff --git a/code-rs/core/src/codex/compact_remote.rs b/code-rs/core/src/codex/compact_remote.rs index 4a5dd88fc87..8f457fe776b 100644 --- a/code-rs/core/src/codex/compact_remote.rs +++ b/code-rs/core/src/codex/compact_remote.rs @@ -4,6 +4,7 @@ use super::compact::{ is_context_overflow_error, prune_orphan_tool_outputs, response_input_from_core_items, + run_postcompact_hook, sanitize_items_for_compact, send_compaction_checkpoint_warning, }; @@ -30,7 +31,7 @@ pub(super) async fn run_inline_remote_auto_compact_task( extra_input: Vec, ) -> Vec { let sub_id = sess.next_internal_sub_id(); - match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await { + match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input, "auto").await { Ok(history) => history, Err(err) => { let event = sess.make_event( @@ -50,8 +51,9 @@ pub(super) async fn run_remote_compact_task( turn_context: Arc, sub_id: String, extra_input: Vec, + reason: &str, ) -> CodexResult<()> { - match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await { + match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input, reason).await { Ok(_history) => { // Mirror local compaction behaviour: clear the running task when the // compaction finished successfully so the UI can unblock. @@ -76,6 +78,7 @@ async fn run_remote_compact_task_inner( turn_context: &Arc, sub_id: &str, extra_input: Vec, + reason: &str, ) -> CodexResult> { let mut turn_items = sess.turn_input_with_history({ if extra_input.is_empty() { @@ -167,6 +170,8 @@ async fn run_remote_compact_task_inner( state.token_usage_info = None; } + run_postcompact_hook(sess, reason).await; + send_compaction_checkpoint_warning(sess, sub_id).await; let rollout_item = RolloutItem::Compacted(CompactedItem { diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 5a49e349b25..81be8128c22 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -506,6 +506,16 @@ pub(super) fn build_precompact_hook_payload(sess: &Session, reason: &str) -> Val Value::Object(base) } +pub(super) fn build_postcompact_hook_payload(sess: &Session, reason: &str) -> Value { + let mut base = build_base_hook_payload(sess, ProjectHookEvent::PostCompact); + base.insert( + "event".to_string(), + Value::String(ProjectHookEvent::PostCompact.as_str().to_string()), + ); + base.insert("reason".to_string(), Value::String(reason.to_string())); + Value::Object(base) +} + pub(super) fn build_notification_hook_payload( sess: &Session, notification: &UserNotification, diff --git a/code-rs/core/src/config_types.rs b/code-rs/core/src/config_types.rs index 1e17529b966..09f532127f2 100644 --- a/code-rs/core/src/config_types.rs +++ b/code-rs/core/src/config_types.rs @@ -1385,6 +1385,13 @@ pub enum ProjectHookEvent { alias = "PreCompact" )] PreCompact, + #[serde( + rename = "post.compact", + alias = "post_compact", + alias = "post-compact", + alias = "PostCompact" + )] + PostCompact, #[serde(rename = "notification", alias = "Notification", alias = "notify")] Notification, } @@ -1402,6 +1409,7 @@ impl ProjectHookEvent { ProjectHookEvent::SubagentStop => "subagent.stop", ProjectHookEvent::UserPromptSubmit => "user.prompt_submit", ProjectHookEvent::PreCompact => "pre.compact", + ProjectHookEvent::PostCompact => "post.compact", ProjectHookEvent::Notification => "notification", } } @@ -1418,6 +1426,7 @@ impl ProjectHookEvent { ProjectHookEvent::SubagentStop => "SubagentStop", ProjectHookEvent::UserPromptSubmit => "UserPromptSubmit", ProjectHookEvent::PreCompact => "PreCompact", + ProjectHookEvent::PostCompact => "PostCompact", ProjectHookEvent::Notification => "Notification", } } @@ -1434,6 +1443,7 @@ impl ProjectHookEvent { ProjectHookEvent::SubagentStop => "subagent_stop", ProjectHookEvent::UserPromptSubmit => "user_prompt_submit", ProjectHookEvent::PreCompact => "pre_compact", + ProjectHookEvent::PostCompact => "post_compact", ProjectHookEvent::Notification => "notification", } } diff --git a/code-rs/core/tests/hook_events.rs b/code-rs/core/tests/hook_events.rs index 3243553b569..cc6d8ccf286 100644 --- a/code-rs/core/tests/hook_events.rs +++ b/code-rs/core/tests/hook_events.rs @@ -451,6 +451,41 @@ async fn precompact_hook_fires() { wait_for_log_contains(&log_path, "precompact:pre.compact").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn postcompact_hook_fires() { + let _guard = hook_test_guard(); + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("hooks.log"); + File::create(&log_path).unwrap(); + + let mut config = base_config(&code_home, &project_dir); + let hook_configs = vec![hook_config(ProjectHookEvent::PostCompact, "postcompact", &log_path)]; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + + let server = MockServer::start().await; + attach_mock_provider(&mut config, &server); + + let body = sse_message_body("summary", "msg-1", "resp-1"); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(body)) + .up_to_n_times(1) + .mount(&server) + .await; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex.submit(Op::Compact).await.unwrap(); + let _ = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + wait_for_log_contains(&log_path, "postcompact:post.compact").await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_stop_hook_fires() { let _guard = hook_test_guard(); From 7b6fa727c5e03fcc3ad25d4735ba8e1347826721 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 16:23:54 -0700 Subject: [PATCH 4/9] fix(hooks): honor hook decisions --- code-rs/core/src/codex/exec.rs | 84 +++++++++- code-rs/core/src/codex/streaming.rs | 249 ++++++++++++++++++++++------ 2 files changed, 283 insertions(+), 50 deletions(-) diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 81be8128c22..aa752219b30 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -189,6 +189,13 @@ impl HookRunResult { } } +fn hook_reason_from_messages(messages: &[String]) -> Option { + messages + .iter() + .find(|message| !message.trim().is_empty()) + .map(|message| message.trim().to_string()) +} + fn merge_permission_decision( current: Option, next: HookPermissionDecision, @@ -843,7 +850,7 @@ impl Session { } else { ProjectHookEvent::ToolBefore }; - self + let hook_result = self .run_hooks_for_exec_event( turn_diff_tracker, before_event, @@ -853,6 +860,78 @@ impl Session { attempt_req, ) .await; + let hook_reason = hook_reason_from_messages(&hook_result.system_messages); + self.enqueue_hook_system_messages(hook_result.system_messages); + if let Some(decision) = hook_result.permission_decision { + let rejection = match decision { + HookPermissionDecision::Allow => None, + HookPermissionDecision::Deny => Some("exec command blocked by hook".to_string()), + HookPermissionDecision::Ask => { + let rx_approve = self + .request_command_approval( + sub_id.clone(), + call_id.clone(), + params.command.clone(), + params.cwd.clone(), + hook_reason.clone(), + ) + .await; + let decision = rx_approve.await.unwrap_or_default(); + match decision { + ReviewDecision::Approved => None, + ReviewDecision::ApprovedForSession => { + self.add_approved_command(ApprovedCommandPattern::new( + params.command.clone(), + ApprovedCommandMatchKind::Exact, + None, + )); + None + } + ReviewDecision::Denied | ReviewDecision::Abort => { + Some("exec command blocked by hook".to_string()) + } + } + } + }; + + if let Some(message) = rejection { + let message = match hook_reason { + Some(reason) => format!("{message}: {reason}"), + None => message, + }; + let output = ExecToolCallOutput { + exit_code: 1, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(message.clone()), + aggregated_output: StreamOutput::new(message.clone()), + duration: Duration::ZERO, + timed_out: false, + }; + self.on_exec_command_begin( + turn_diff_tracker, + begin_ctx.clone(), + seq_hint, + output_index, + attempt_req, + ) + .await; + self + .on_exec_command_end( + turn_diff_tracker, + &sub_id, + &call_id, + &output, + is_apply_patch, + seq_hint.map(|h| h.saturating_add(1)), + output_index, + attempt_req, + ) + .await; + exec_guard.mark_completed(); + self.finalize_cancelled_execs(&sub_id).await; + return Ok(output); + } + } } } @@ -900,7 +979,7 @@ impl Session { } else { ProjectHookEvent::ToolAfter }; - self + let hook_result = self .run_hooks_for_exec_event( turn_diff_tracker, after_event, @@ -910,6 +989,7 @@ impl Session { attempt_req, ) .await; + self.enqueue_hook_system_messages(hook_result.system_messages); } } diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index ff45abe952c..dad448e1c22 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -3,6 +3,7 @@ use super::exec::{ ApplyPatchCommandContext, ExecCommandContext, ExecInvokeArgs, + HookPermissionDecision, build_precompact_hook_payload, build_stop_hook_payload, build_user_prompt_hook_payload, @@ -150,6 +151,124 @@ fn user_prompt_text_from_items(items: &[InputItem]) -> Option { } } +fn hook_reason_from_messages(messages: &[String]) -> Option { + messages + .iter() + .find(|message| !message.trim().is_empty()) + .map(|message| message.trim().to_string()) +} + +fn apply_updated_prompt_items(items: &mut Vec, updated_input: serde_json::Value) -> bool { + if updated_input.is_null() { + return false; + } + + if let Ok(replacement) = serde_json::from_value::>(updated_input.clone()) { + *items = replacement; + return true; + } + + if let Ok(replacement) = serde_json::from_value::(updated_input.clone()) { + *items = vec![replacement]; + return true; + } + + let Some(text) = updated_input.as_str() else { + return false; + }; + + let mut updated_items = Vec::with_capacity(items.len().saturating_add(1)); + let mut replaced = false; + for item in items.drain(..) { + match item { + InputItem::Text { .. } if !replaced => { + updated_items.push(InputItem::Text { + text: text.to_string(), + }); + replaced = true; + } + InputItem::Text { .. } => {} + other => updated_items.push(other), + } + } + if !replaced { + updated_items.push(InputItem::Text { + text: text.to_string(), + }); + } + *items = updated_items; + true +} + +async fn apply_user_prompt_hook_gate( + sess: &Session, + sub_id: &str, + items: Vec, +) -> Option> { + let Some(prompt_text) = user_prompt_text_from_items(&items) else { + return Some(items); + }; + + let payload = build_user_prompt_hook_payload(sess, &prompt_text); + let mut tracker = TurnDiffTracker::new(); + let attempt_req = sess.current_request_ordinal(); + let result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::UserPromptSubmit, + &payload, + None, + attempt_req, + ) + .await; + + let reason = hook_reason_from_messages(&result.system_messages); + sess.enqueue_hook_system_messages(result.system_messages); + + let mut items = items; + if let Some(updated_input) = result.updated_input { + apply_updated_prompt_items(&mut items, updated_input); + } + + match result.permission_decision { + None | Some(HookPermissionDecision::Allow) => Some(items), + Some(HookPermissionDecision::Deny) => { + let message = match reason { + Some(reason) => format!("user prompt blocked by hook: {reason}"), + None => "user prompt blocked by hook".to_string(), + }; + sess.notify_stream_error(sub_id, message).await; + None + } + Some(HookPermissionDecision::Ask) => { + let prompt_text = user_prompt_text_from_items(&items).unwrap_or(prompt_text); + let (preview, _) = preview_first_n_lines(&prompt_text, 12); + let approval_call_id = format!("user_prompt_hook_{}", uuid::Uuid::new_v4()); + let rx_approve = sess + .request_command_approval( + sub_id.to_string(), + approval_call_id, + vec!["user.prompt".to_string(), preview], + sess.get_cwd().to_path_buf(), + reason.clone(), + ) + .await; + let decision = rx_approve.await.unwrap_or_default(); + match decision { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => Some(items), + ReviewDecision::Denied | ReviewDecision::Abort => { + let message = match reason { + Some(reason) => format!("user prompt blocked by hook: {reason}"), + None => "user prompt blocked by hook".to_string(), + }; + sess.notify_stream_error(sub_id, message).await; + None + } + } + } + } +} + pub(super) async fn submission_loop( mut session_id: Uuid, config: Arc, @@ -843,21 +962,10 @@ pub(super) async fn submission_loop( // This prevents token buildup from old screenshots/status messages sess.cleanup_old_status_items().await; - if let Some(prompt_text) = user_prompt_text_from_items(&items) { - let payload = build_user_prompt_hook_payload(sess, &prompt_text); - let mut tracker = TurnDiffTracker::new(); - let attempt_req = sess.current_request_ordinal(); - let result = sess - .run_hooks_for_event( - &mut tracker, - ProjectHookEvent::UserPromptSubmit, - &payload, - None, - attempt_req, - ) - .await; - sess.enqueue_hook_system_messages(result.system_messages); - } + let items = match apply_user_prompt_hook_gate(sess, &sub.id, items).await { + Some(items) => items, + None => continue, + }; // Abort synchronously here to avoid a race that can kill the // newly spawned agent if the async abort runs after set_task. @@ -891,21 +999,10 @@ pub(super) async fn submission_loop( } else { // No task running: treat this as immediate user input without aborting. sess.cleanup_old_status_items().await; - if let Some(prompt_text) = user_prompt_text_from_items(&items) { - let payload = build_user_prompt_hook_payload(sess, &prompt_text); - let mut tracker = TurnDiffTracker::new(); - let attempt_req = sess.current_request_ordinal(); - let result = sess - .run_hooks_for_event( - &mut tracker, - ProjectHookEvent::UserPromptSubmit, - &payload, - None, - attempt_req, - ) - .await; - sess.enqueue_hook_system_messages(result.system_messages); - } + let items = match apply_user_prompt_hook_gate(sess, &sub.id, items).await { + Some(items) => items, + None => continue, + }; let turn_context = sess.make_turn_context(); let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items); sess.set_task(agent); @@ -1931,6 +2028,9 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S let turn_context = sess_clone.make_turn_context(); let submission_id = queued.submission_id; let items = queued.core_items; + let Some(items) = apply_user_prompt_hook_gate(&sess_clone, &submission_id, items).await else { + return; + }; let agent = AgentTask::spawn(Arc::clone(&sess_clone), turn_context, submission_id, items); sess_clone.set_task(agent); }); @@ -7717,7 +7817,7 @@ async fn handle_container_exec_with_params( }; // FileBeforeWrite hook for apply_patch - sess + let hook_result = sess .run_hooks_for_exec_event( turn_diff_tracker, ProjectHookEvent::FileBeforeWrite, @@ -7727,6 +7827,7 @@ async fn handle_container_exec_with_params( attempt_req, ) .await; + sess.enqueue_hook_system_messages(hook_result.system_messages); let patch_start = std::time::Instant::now(); @@ -7795,7 +7896,7 @@ async fn handle_container_exec_with_params( timed_out: false, }; - sess + let hook_result = sess .run_hooks_for_exec_event( turn_diff_tracker, ProjectHookEvent::FileAfterWrite, @@ -7805,6 +7906,7 @@ async fn handle_container_exec_with_params( attempt_req, ) .await; + sess.enqueue_hook_system_messages(hook_result.system_messages); if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() { let diff_event = sess.make_event( @@ -7961,7 +8063,7 @@ async fn handle_container_exec_with_params( // ToolBefore hook for shell/container.exec commands let params_for_hooks = params.clone(); - sess + let hook_result = sess .run_hooks_for_exec_event( turn_diff_tracker, ProjectHookEvent::ToolBefore, @@ -7971,6 +8073,55 @@ async fn handle_container_exec_with_params( attempt_req, ) .await; + let hook_reason = hook_reason_from_messages(&hook_result.system_messages); + sess.enqueue_hook_system_messages(hook_result.system_messages); + if let Some(decision) = hook_result.permission_decision { + match decision { + HookPermissionDecision::Allow => {} + HookPermissionDecision::Deny => { + let content = match hook_reason { + Some(reason) => format!("exec command blocked by hook: {reason}"), + None => "exec command blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + HookPermissionDecision::Ask => { + let rx_approve = sess + .request_command_approval( + sub_id.clone(), + call_id.clone(), + params.command.clone(), + params.cwd.clone(), + hook_reason.clone(), + ) + .await; + let decision = rx_approve.await.unwrap_or_default(); + match decision { + ReviewDecision::Approved => {} + ReviewDecision::ApprovedForSession => { + sess.add_approved_command(ApprovedCommandPattern::new( + params.command.clone(), + ApprovedCommandMatchKind::Exact, + None, + )); + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let content = match hook_reason { + Some(reason) => format!("exec command blocked by hook: {reason}"), + None => "exec command blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + } + } + } + } // Prepare tail buffer and background registry entry let tail_buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); @@ -8110,21 +8261,22 @@ async fn handle_container_exec_with_params( *slot = Some(out.clone()); } - if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) { - if let Some(sess_arc) = sess_for_hooks.clone() { - let mut hook_tracker = TurnDiffTracker::new(); - sess_arc - .run_hooks_for_exec_event( - &mut hook_tracker, - ProjectHookEvent::ToolAfter, - &exec_ctx_for_hooks, - ¶ms_for_after_hooks, - Some(&out), - attempt_req_for_task, - ) - .await; + if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) { + if let Some(sess_arc) = sess_for_hooks.clone() { + let mut hook_tracker = TurnDiffTracker::new(); + let hook_result = sess_arc + .run_hooks_for_exec_event( + &mut hook_tracker, + ProjectHookEvent::ToolAfter, + &exec_ctx_for_hooks, + ¶ms_for_after_hooks, + Some(&out), + attempt_req_for_task, + ) + .await; + sess_arc.enqueue_hook_system_messages(hook_result.system_messages); + } } - } // Only emit background completion notifications if the command actually backgrounded if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) { if !suppress_event_flag_task.load(std::sync::atomic::Ordering::Relaxed) { @@ -8198,7 +8350,7 @@ async fn handle_container_exec_with_params( } } - sess + let hook_result = sess .run_hooks_for_exec_event( turn_diff_tracker, ProjectHookEvent::ToolAfter, @@ -8208,6 +8360,7 @@ async fn handle_container_exec_with_params( attempt_req, ) .await; + sess.enqueue_hook_system_messages(hook_result.system_messages); return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content, success: Some(is_success) } }; } else { From 3ff2bd2d31aa418ee21aff66f4e808b478687cfa Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 16:54:26 -0700 Subject: [PATCH 5/9] fix(hooks): apply hook updates to exec flow --- code-rs/core/src/codex/exec.rs | 113 +++++++++++++-- code-rs/core/src/codex/streaming.rs | 214 ++++++++++++++++++---------- 2 files changed, 244 insertions(+), 83 deletions(-) diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index aa752219b30..6d94e67633f 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -2,6 +2,7 @@ use super::*; use super::session::{HookGuard, RunningExecMeta}; use code_protocol::models::ContentItem; use serde_json::{json, Map, Value}; +use shlex::split as shlex_split; fn synthetic_exec_end_payload(cancelled: bool) -> (i32, String) { if cancelled { @@ -196,6 +197,88 @@ fn hook_reason_from_messages(messages: &[String]) -> Option { .map(|message| message.trim().to_string()) } +fn command_from_value(value: &Value) -> Option> { + match value { + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let text = item.as_str()?; + out.push(text.to_string()); + } + if out.is_empty() { None } else { Some(out) } + } + Value::String(text) => shlex_split(text).filter(|tokens| !tokens.is_empty()), + _ => None, + } +} + +pub(super) fn apply_updated_exec_params( + params: &mut ExecParams, + ctx: &mut ExecCommandContext, + updated_input: Value, +) -> bool { + if updated_input.is_null() { + return false; + } + + let mut changed = false; + match updated_input { + Value::Array(_) | Value::String(_) => { + if let Some(command) = command_from_value(&updated_input) { + params.command = command; + changed = true; + } + } + Value::Object(map) => { + if let Some(command_value) = map.get("command") { + if let Some(command) = command_from_value(command_value) { + params.command = command; + changed = true; + } + } + if let Some(cwd_value) = map.get("cwd").and_then(|value| value.as_str()) { + let trimmed = cwd_value.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + params.cwd = if path.is_absolute() { + path + } else { + params.cwd.join(path) + }; + changed = true; + } + } + if let Some(timeout_value) = map.get("timeout_ms") { + if timeout_value.is_null() { + if params.timeout_ms.is_some() { + params.timeout_ms = None; + changed = true; + } + } else if let Some(timeout_ms) = timeout_value.as_u64() { + params.timeout_ms = Some(timeout_ms); + changed = true; + } + } + if let Some(env_map) = map.get("env").and_then(|value| value.as_object()) { + for (key, value) in env_map { + if let Some(text) = value.as_str() { + params.env.insert(key.clone(), text.to_string()); + changed = true; + } + } + } + } + _ => {} + } + + if changed { + ctx.command_for_display = params.command.clone(); + ctx.cwd = params.cwd.clone(); + } + + changed +} + fn merge_permission_decision( current: Option, next: HookPermissionDecision, @@ -802,7 +885,7 @@ impl Session { async fn run_exec_with_events_inner<'a>( &self, turn_diff_tracker: &mut TurnDiffTracker, - begin_ctx: ExecCommandContext, + mut begin_ctx: ExecCommandContext, exec_args: ExecInvokeArgs<'a>, seq_hint: Option, output_index: Option, @@ -834,10 +917,8 @@ impl Session { ); let ExecInvokeArgs { params, sandbox_type, sandbox_policy, sandbox_cwd, code_linux_sandbox_exe, stdout_stream } = exec_args; - let tracking_command = params.command.clone(); - let dry_run_analysis = analyze_command(&tracking_command); - let params = maybe_run_with_user_profile(params, self); - let params_for_hooks = if enable_hooks { + let mut params = maybe_run_with_user_profile(params, self); + let mut params_for_hooks = if enable_hooks { Some(params.clone()) } else { None @@ -860,9 +941,22 @@ impl Session { attempt_req, ) .await; - let hook_reason = hook_reason_from_messages(&hook_result.system_messages); - self.enqueue_hook_system_messages(hook_result.system_messages); - if let Some(decision) = hook_result.permission_decision { + let HookRunResult { + updated_input, + permission_decision, + system_messages, + .. + } = hook_result; + let hook_reason = hook_reason_from_messages(&system_messages); + self.enqueue_hook_system_messages(system_messages); + if let Some(updated_input) = updated_input { + if apply_updated_exec_params(&mut params, &mut begin_ctx, updated_input) { + if let Some(params_ref) = params_for_hooks.as_mut() { + *params_ref = params.clone(); + } + } + } + if let Some(decision) = permission_decision { let rejection = match decision { HookPermissionDecision::Allow => None, HookPermissionDecision::Deny => Some("exec command blocked by hook".to_string()), @@ -935,6 +1029,9 @@ impl Session { } } + let tracking_command = params.command.clone(); + let dry_run_analysis = analyze_command(&tracking_command); + self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req) .await; diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index dad448e1c22..56e8cfae8b7 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -4,6 +4,8 @@ use super::exec::{ ExecCommandContext, ExecInvokeArgs, HookPermissionDecision, + HookRunResult, + apply_updated_exec_params, build_precompact_hook_payload, build_stop_hook_payload, build_user_prompt_hook_payload, @@ -7827,7 +7829,57 @@ async fn handle_container_exec_with_params( attempt_req, ) .await; - sess.enqueue_hook_system_messages(hook_result.system_messages); + let HookRunResult { + permission_decision, + system_messages, + .. + } = hook_result; + let hook_reason = hook_reason_from_messages(&system_messages); + sess.enqueue_hook_system_messages(system_messages); + + let mut hook_user_approved = false; + if let Some(decision) = permission_decision { + match decision { + HookPermissionDecision::Allow => {} + HookPermissionDecision::Deny => { + let content = match hook_reason { + Some(reason) => format!("apply_patch blocked by hook: {reason}"), + None => "apply_patch blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + HookPermissionDecision::Ask => { + let rx_approve = sess + .request_command_approval( + sub_id.clone(), + call_id.clone(), + params.command.clone(), + params.cwd.clone(), + hook_reason.clone(), + ) + .await; + let decision = rx_approve.await.unwrap_or_default(); + match decision { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + hook_user_approved = true; + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let content = match hook_reason { + Some(reason) => format!("apply_patch blocked by hook: {reason}"), + None => "apply_patch blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + } + } + } + } let patch_start = std::time::Instant::now(); @@ -7844,7 +7896,7 @@ async fn handle_container_exec_with_params( ApplyPatchResult::Reply(item) => return item, ApplyPatchResult::Applied(run) => { hook_ctx.apply_patch.as_mut().map(|ctx| { - ctx.user_explicitly_approved_this_action = !run.auto_approved; + ctx.user_explicitly_approved_this_action = hook_user_approved || !run.auto_approved; }); let order_begin = crate::protocol::OrderMeta { @@ -7957,6 +8009,44 @@ async fn handle_container_exec_with_params( MaybeApplyPatchVerified::NotApplyPatch => {} } + let harness_summary_json: Option = None; + + let mut exec_command_context = ExecCommandContext { + sub_id: sub_id.clone(), + call_id: call_id.clone(), + command_for_display: params.command.clone(), + cwd: params.cwd.clone(), + apply_patch: None, + }; + + params = maybe_run_with_user_profile(params, sess); + + // ToolBefore hook for shell/container.exec commands + let mut params_for_hooks = params.clone(); + let hook_result = sess + .run_hooks_for_exec_event( + turn_diff_tracker, + ProjectHookEvent::ToolBefore, + &exec_command_context, + ¶ms_for_hooks, + None, + attempt_req, + ) + .await; + let HookRunResult { + updated_input, + permission_decision, + system_messages, + .. + } = hook_result; + let hook_reason = hook_reason_from_messages(&system_messages); + sess.enqueue_hook_system_messages(system_messages); + if let Some(updated_input) = updated_input { + if apply_updated_exec_params(&mut params, &mut exec_command_context, updated_input) { + params_for_hooks = params.clone(); + } + } + let safety = { let state = sess.state.lock().unwrap(); assess_command_safety( @@ -7967,8 +8057,53 @@ async fn handle_container_exec_with_params( params.with_escalated_permissions.unwrap_or(false), ) }; - let command_for_display = params.command.clone(); - let harness_summary_json: Option = None; + if let Some(decision) = permission_decision { + match decision { + HookPermissionDecision::Allow => {} + HookPermissionDecision::Deny => { + let content = match hook_reason { + Some(reason) => format!("exec command blocked by hook: {reason}"), + None => "exec command blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + HookPermissionDecision::Ask => { + let rx_approve = sess + .request_command_approval( + sub_id.clone(), + call_id.clone(), + params.command.clone(), + params.cwd.clone(), + hook_reason.clone(), + ) + .await; + let decision = rx_approve.await.unwrap_or_default(); + match decision { + ReviewDecision::Approved => {} + ReviewDecision::ApprovedForSession => { + sess.add_approved_command(ApprovedCommandPattern::new( + params.command.clone(), + ApprovedCommandMatchKind::Exact, + None, + )); + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let content = match hook_reason { + Some(reason) => format!("exec command blocked by hook: {reason}"), + None => "exec command blocked by hook".to_string(), + }; + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { content, success: None }, + }; + } + } + } + } + } let sandbox_type = match safety { SafetyCheck::AutoApprove { @@ -8050,78 +8185,7 @@ async fn handle_container_exec_with_params( } }; - let exec_command_context = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: command_for_display.clone(), - cwd: params.cwd.clone(), - apply_patch: None, - }; - let display_label = crate::util::strip_bash_lc_and_escape(&exec_command_context.command_for_display); - let params = maybe_run_with_user_profile(params, sess); - - // ToolBefore hook for shell/container.exec commands - let params_for_hooks = params.clone(); - let hook_result = sess - .run_hooks_for_exec_event( - turn_diff_tracker, - ProjectHookEvent::ToolBefore, - &exec_command_context, - ¶ms_for_hooks, - None, - attempt_req, - ) - .await; - let hook_reason = hook_reason_from_messages(&hook_result.system_messages); - sess.enqueue_hook_system_messages(hook_result.system_messages); - if let Some(decision) = hook_result.permission_decision { - match decision { - HookPermissionDecision::Allow => {} - HookPermissionDecision::Deny => { - let content = match hook_reason { - Some(reason) => format!("exec command blocked by hook: {reason}"), - None => "exec command blocked by hook".to_string(), - }; - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { content, success: None }, - }; - } - HookPermissionDecision::Ask => { - let rx_approve = sess - .request_command_approval( - sub_id.clone(), - call_id.clone(), - params.command.clone(), - params.cwd.clone(), - hook_reason.clone(), - ) - .await; - let decision = rx_approve.await.unwrap_or_default(); - match decision { - ReviewDecision::Approved => {} - ReviewDecision::ApprovedForSession => { - sess.add_approved_command(ApprovedCommandPattern::new( - params.command.clone(), - ApprovedCommandMatchKind::Exact, - None, - )); - } - ReviewDecision::Denied | ReviewDecision::Abort => { - let content = match hook_reason { - Some(reason) => format!("exec command blocked by hook: {reason}"), - None => "exec command blocked by hook".to_string(), - }; - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { content, success: None }, - }; - } - } - } - } - } // Prepare tail buffer and background registry entry let tail_buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); From 29f66bb6bdb5ad3fd9a3168644c704178ac6790f Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 17:08:02 -0700 Subject: [PATCH 6/9] docs(hooks): expand hook docs and examples --- docs/config.md | 137 +++++++++++++++++++++++++++++++++++---- hooks/ask_user_prompt.py | 19 +++++- hooks/check_shell.py | 21 ++++-- 3 files changed, 159 insertions(+), 18 deletions(-) diff --git a/docs/config.md b/docs/config.md index ef7da1b1c69..7fb7220654e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -920,37 +920,150 @@ run = ["./scripts/bootstrap.sh"] timeout_ms = 60000 [[projects."/Users/me/src/my-app".hooks]] +name = "lint-changed" event = "tool.after" run = "npm run lint -- --changed" ``` -Supported hook events: +Hook configuration fields: -- `session.start`: after the session is configured (once per launch) -- `session.end`: before shutdown completes -- `tool.before`: immediately before each exec/tool command runs -- `tool.after`: once an exec/tool command finishes (regardless of exit code) -- `file.before_write`: right before an `apply_patch` is applied -- `file.after_write`: after an `apply_patch` completes and diffs are emitted - -Hook commands run inside the same sandbox mode as the session and appear in the TUI as their own exec cells. Failures are surfaced as background events but do not block the main task. Each invocation receives environment variables such as `CODE_HOOK_EVENT`, `CODE_HOOK_NAME`, `CODE_HOOK_INDEX`, `CODE_HOOK_CALL_ID`, `CODE_HOOK_PAYLOAD` (JSON describing the context), `CODE_SESSION_CWD`, and—when applicable—`CODE_HOOK_SOURCE_CALL_ID`. Hooks may also set `cwd`, provide additional `env` entries, and specify `timeout_ms`. +| Field | Type | Notes | +| --- | --- | --- | +| `event` | string | Required. Hook event name (see list below). | +| `run` | string \| array | Required. Command to execute. String form is parsed like a shell command. | +| `name` | string | Optional label used in logs and env vars. | +| `cwd` | string | Optional working directory for the hook (relative paths resolve from project cwd). | +| `env` | map | Optional extra environment variables. | +| `timeout_ms` | number | Optional timeout in milliseconds (no timeout when omitted). | +| `run_in_background` | bool | Run without a TUI exec cell; still awaited and sandboxed. | + +How hooks run: + +- Hooks run in the order defined in the config. `CODE_HOOK_INDEX` reflects that order. +- Hooks run inside the same sandbox and approval mode as the session; hooks cannot request escalation. +- A hook may return control JSON to influence the workflow (details below). A hook may also stop further hooks for that event by returning `{"continue": false}` or exiting with status `2`. +- For `tool.after` and `file.after_write`, stdout/stderr in the payload are truncated to 2048 characters. + +Common environment variables (available in every hook): + +- `CODE_HOOK_EVENT`: canonical event name (e.g., `tool.after`). +- `CODE_HOOK_TRIGGER`: event slug (e.g., `tool_after`). +- `CODE_HOOK_CALL_ID`: generated id for the hook exec. +- `CODE_HOOK_SUB_ID`: current session/turn id. +- `CODE_HOOK_INDEX`: 1-based index for this hook within the event. +- `CODE_HOOK_PAYLOAD`: JSON payload string describing the context. +- `CODE_SESSION_CWD`: project/session cwd. +- `CODE_HOOK_NAME`: the configured hook name (if provided). +- `CODE_HOOK_SOURCE_CALL_ID`: exec call id that triggered the hook (tool/file hooks only). + +Common payload fields (inside `CODE_HOOK_PAYLOAD`): + +- `event`: canonical event name. +- `session_id`: session UUID. +- `transcript_path`: path to the session transcript or `null` when unavailable. +- `cwd`: project/session cwd. +- `permission_mode`: `allow` or `ask` (based on the approval policy). +- `hook_event_name`: legacy-style event name (e.g., `PreToolUse`). + +### Hook events + +Each payload includes the common fields above plus the event-specific data below. + +| Event | When it fires | Payload additions | +| --- | --- | --- | +| `session.start` | After session config completes (once per launch). | `sandbox_policy`, `approval_policy`. | +| `session.end` | Right before shutdown completes. | `sandbox_policy`, `approval_policy`. | +| `user.prompt_submit` | When the user submits a prompt. | `user_prompt`. | +| `tool.before` | Immediately before each exec/tool command. | `call_id`, `command`, `timeout_ms`, `cwd`. | +| `tool.after` | After each exec/tool command (success or failure). | `call_id`, `command`, `timeout_ms`, `cwd`, `exit_code`, `duration_ms`, `timed_out`, `stdout`, `stderr`, `success`, plus `tool_result` (same fields nested). | +| `file.before_write` | Right before an `apply_patch` is applied. | `call_id`, `command`, `timeout_ms`, `cwd`, `changes` (patch change list). | +| `file.after_write` | After an `apply_patch` completes. | Same as `file.before_write`, plus `exit_code`, `duration_ms`, `timed_out`, `stdout`, `stderr`, `success`. | +| `pre.compact` | Immediately before auto-compact runs. | `reason`. | +| `post.compact` | Right after auto-compact finishes. | `reason`. | +| `stop` | When a turn completes normally. | `reason` (e.g., `turn_complete`), `details` with `last_assistant_message`. | +| `subagent.stop` | When a sub-agent finishes. | `reason` (e.g., `subagent_complete`), `details` with `agent_id`, `agent_name`, `status`. | +| `notification` | Whenever a user notification is emitted. | `notification` (full `UserNotification` payload). | Example `tool.after` payload: ```json { "event": "tool.after", + "session_id": "3e8b...", + "hook_event_name": "PostToolUse", + "permission_mode": "allow", "call_id": "tool_12", "cwd": "/Users/me/src/my-app", "command": ["npm", "test"], + "timeout_ms": 120000, "exit_code": 1, "duration_ms": 1832, - "stdout": "…output truncated…", - "stderr": "…", - "timed_out": false + "timed_out": false, + "stdout": "...truncated...", + "stderr": "...truncated...", + "success": false, + "tool_result": { + "exit_code": 1, + "duration_ms": 1832, + "timed_out": false, + "stdout": "...truncated...", + "stderr": "...truncated...", + "success": false + } +} +``` + +### Hook output (control JSON) + +Hooks can print JSON to stdout or stderr to influence the flow. The parser accepts raw JSON or JSON wrapped in a fenced code block. + +Supported keys: + +- `continue`: `true`/`false` — stop running remaining hooks for this event when `false`. +- `systemMessage`: string — appended as a system message (Chat API only). +- `permissionDecision`: `allow` \| `ask` \| `deny` — gate `user.prompt_submit` or `tool.before`/`file.before_write`. +- `hookSpecificOutput.permissionDecision`: same as above (preferred nesting for structured output). +- `updatedInput`: modifies the incoming payload. + - For `user.prompt_submit`, it can be a string, `InputItem`, or list of `InputItem`s. + - For `tool.before`/`file.before_write`, it can be a string/array command or an object with `command`, `cwd`, `timeout_ms`, and `env`. + +Example: prompt gate (used by `hooks/ask_user_prompt.py`) + +```json +{ + "hookSpecificOutput": {"permissionDecision": "ask"}, + "systemMessage": "Hook gate: approve this prompt to continue." } ``` +Example: rewrite a tool command before execution + +```json +{ + "updatedInput": { + "command": ["npm", "run", "lint", "--", "--changed"], + "timeout_ms": 60000 + } +} +``` + +### Testing hooks locally + +This repo ships two example hooks under `hooks/` and a helper script: + +- `hooks/ask_user_prompt.py`: prompts for approval when the user prompt starts with `hook:`. +- `hooks/check_shell.py`: requests confirmation for risky shell commands (`rm -rf`, `mkfs`, etc.). +- `scripts/run-hooks-test.sh`: launches Code with a temporary config that enables those hooks. + +Test workflow: + +```bash +./build-fast.sh +scripts/run-hooks-test.sh +``` + +Then try a prompt like `hook: please summarize this repo` or run a shell command containing `rm -rf` to see the hook gate in action. + ## Project Commands Define project-scoped commands under `[[projects."".commands]]`. Each command needs a unique `name` and either an array (`command`) or string (`run`) describing how to invoke it. Optional fields include `description`, `cwd`, `env`, and `timeout_ms`. diff --git a/hooks/ask_user_prompt.py b/hooks/ask_user_prompt.py index 01a81b419ba..cd8a8a39499 100755 --- a/hooks/ask_user_prompt.py +++ b/hooks/ask_user_prompt.py @@ -1,12 +1,27 @@ #!/usr/bin/env python3 import json +import os import sys -def main(): + +def load_payload(): try: payload = json.load(sys.stdin) + if payload: + return payload except Exception: - payload = {} + pass + + env_payload = os.environ.get("CODE_HOOK_PAYLOAD") + if env_payload: + try: + return json.loads(env_payload) + except Exception: + return {} + return {} + +def main(): + payload = load_payload() prompt = (payload.get("user_prompt") or "").strip() lowered = prompt.lower() diff --git a/hooks/check_shell.py b/hooks/check_shell.py index a304356a1a3..c412a7f3ec4 100755 --- a/hooks/check_shell.py +++ b/hooks/check_shell.py @@ -1,16 +1,29 @@ #!/usr/bin/env python3 import json +import os import sys def load_payload(): try: - return json.load(sys.stdin) + payload = json.load(sys.stdin) + if payload: + return payload except Exception: - return {} + payload = None + + env_payload = os.environ.get("CODE_HOOK_PAYLOAD") + if env_payload: + try: + return json.loads(env_payload) + except Exception: + return {} + return payload or {} def command_from_payload(payload): - tool_input = payload.get("tool_input") or {} - cmd = tool_input.get("command") + cmd = payload.get("command") + if cmd is None: + tool_input = payload.get("tool_input") or {} + cmd = tool_input.get("command") if isinstance(cmd, list): return " ".join(cmd) if isinstance(cmd, str): From de2355c649103b3d323b86ba4f44cc5ec3b12acc Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 21:00:07 -0700 Subject: [PATCH 7/9] fix(hooks): add tool success and auto pre-compact --- code-rs/core/src/codex/exec.rs | 1 + code-rs/core/src/codex/streaming.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 6d94e67633f..6978ce089c4 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -509,6 +509,7 @@ fn build_exec_hook_payload( "success": out.exit_code == 0, }), ); + base.insert("success".to_string(), json!(out.exit_code == 0)); base.insert("exit_code".to_string(), json!(out.exit_code)); base.insert("duration_ms".to_string(), json!(out.duration.as_millis())); base.insert("timed_out".to_string(), json!(out.timed_out)); diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index 56e8cfae8b7..c9e492af0c1 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -1880,6 +1880,19 @@ async fn run_agent(sess: Arc, turn_context: Arc, sub_id: S ) .await; + let payload = build_precompact_hook_payload(&sess, "auto"); + let mut tracker = TurnDiffTracker::new(); + let hook_result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::PreCompact, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(hook_result.system_messages); + // Choose between local and remote compact based on auth mode, // matching upstream codex-rs behavior if compact::should_use_remote_compact_task(&sess).await { @@ -2281,6 +2294,19 @@ async fn run_turn( ) .await; + let payload = build_precompact_hook_payload(&sess, "auto"); + let mut tracker = TurnDiffTracker::new(); + let hook_result = sess + .run_hooks_for_event( + &mut tracker, + ProjectHookEvent::PreCompact, + &payload, + None, + attempt_req, + ) + .await; + sess.enqueue_hook_system_messages(hook_result.system_messages); + let previous_input_snapshot = input.clone(); let compacted_history = if compact::should_use_remote_compact_task(sess).await { run_inline_remote_auto_compact_task( From e009982a5437d4e173445ce14749f30e683f20d8 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 6 Jan 2026 21:32:41 -0700 Subject: [PATCH 8/9] fix(hooks): hide hook exec output and sync session --- code-rs/core/src/codex/exec.rs | 23 +++++++++++++++++++---- code-rs/core/src/codex/session.rs | 13 +++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 6978ce089c4..2f7d1d330ca 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -733,10 +733,20 @@ impl Session { timed_out: _, } = output; // Because stdout and stderr could each be up to 100 KiB, we send - // truncated versions. + // truncated versions. Hook execs suppress output to avoid leaking + // implementation details in the UI. const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB - let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect(); - let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect(); + let is_hook_exec = call_id.contains("_hook_"); + let stdout = if is_hook_exec { + String::new() + } else { + stdout.text.chars().take(MAX_STREAM_OUTPUT).collect() + }; + let stderr = if is_hook_exec { + String::new() + } else { + stderr.text.chars().take(MAX_STREAM_OUTPUT).collect() + }; // Precompute formatted output if needed in future for logging/pretty UI. let msg = if is_apply_patch { @@ -1311,10 +1321,15 @@ impl Session { justification: None, }; + let display_label = if let Some(name) = &hook.name { + format!("Hook: {} ({})", event.hook_event_name(), name) + } else { + format!("Hook: {}", event.hook_event_name()) + }; let exec_ctx = ExecCommandContext { sub_id: sub_id.clone(), call_id: call_id.clone(), - command_for_display: exec_params.command.clone(), + command_for_display: vec![display_label], cwd: exec_params.cwd.clone(), apply_patch: None, }; diff --git a/code-rs/core/src/codex/session.rs b/code-rs/core/src/codex/session.rs index 38b0d846d0e..fe3480979d0 100644 --- a/code-rs/core/src/codex/session.rs +++ b/code-rs/core/src/codex/session.rs @@ -1,8 +1,8 @@ use super::*; use super::exec::build_notification_hook_payload; +use serde_json::Value; use super::streaming::{ AgentTask, - MAX_TOOL_OUTPUT_BYTES_FOR_MODEL, TRUNCATION_MARKER, TimelineReplayContext, debug_history, @@ -362,6 +362,7 @@ pub(crate) struct Session { pub(super) confirm_guard: ConfirmGuardRuntime, pub(super) project_hooks: ProjectHooks, pub(super) project_commands: Vec, + pub(super) tool_output_max_bytes: usize, pub(super) hook_guard: AtomicBool, pub(super) github: Arc>, pub(super) validation: Arc>, @@ -916,7 +917,7 @@ impl Session { } let (_, was_truncated, prefix_end, suffix_start) = - truncate_middle_bytes(&aggregated, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL); + truncate_middle_bytes(&aggregated, self.tool_output_max_bytes); if !was_truncated { return; } @@ -1024,6 +1025,13 @@ impl Session { /// Sends the given event to the client and swallows the send error, if /// any, logging it as an error. pub(super) fn make_turn_context(&self) -> Arc { + self.make_turn_context_with_schema(None) + } + + pub(super) fn make_turn_context_with_schema( + &self, + final_output_json_schema: Option, + ) -> Arc { Arc::new(TurnContext { client: self.client.clone(), cwd: self.cwd.clone(), @@ -1036,6 +1044,7 @@ impl Session { shell_environment_policy: self.shell_environment_policy.clone(), is_review_mode: false, text_format_override: self.next_turn_text_format.lock().unwrap().take(), + final_output_json_schema, }) } From 9dec227c9799c262f7293749277a132720d39bb2 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Sun, 11 Jan 2026 20:55:44 -0700 Subject: [PATCH 9/9] chore(gitignore): ignore local convenience scripts and ccstatusline Co-Authored-By: Claude Opus 4.5 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 9dae69f8c50..cc35ffa8abe 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,12 @@ coverage/ # personal files personal/ +# local convenience scripts +run.sh +build-and-run.sh +install-alias.sh +ccstatusline/ + # os .DS_Store Thumbs.db