mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
+482
-4
@@ -8,7 +8,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::Receiver;
|
||||
@@ -51,6 +51,7 @@ use self::compact::collect_user_messages;
|
||||
|
||||
/// Initial submission ID for session configuration
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
const HOOK_OUTPUT_LIMIT: usize = 2048;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ConfirmGuardRuntime {
|
||||
@@ -458,6 +459,7 @@ use crate::client_common::ResponseEvent;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::config::{persist_model_selection, Config};
|
||||
use crate::config_types::ProjectHookEvent;
|
||||
use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::conversation_history::ConversationHistory;
|
||||
use crate::error::CodexErr;
|
||||
@@ -489,6 +491,7 @@ use crate::dry_run_guard::{analyze_command, DryRunAnalysis, DryRunDisposition, D
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::plan_tool::handle_update_plan;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::project_features::{ProjectCommand, ProjectHook, ProjectHooks};
|
||||
use crate::protocol::AgentMessageDeltaEvent;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -823,6 +826,28 @@ pub(crate) struct Session {
|
||||
/// Track the last screenshot path and hash to detect changes
|
||||
last_screenshot_info: Mutex<Option<(PathBuf, Vec<u8>, Vec<u8>)>>, // (path, phash, dhash)
|
||||
confirm_guard: ConfirmGuardRuntime,
|
||||
project_hooks: ProjectHooks,
|
||||
project_commands: Vec<ProjectCommand>,
|
||||
hook_guard: AtomicBool,
|
||||
}
|
||||
|
||||
struct HookGuard<'a> {
|
||||
flag: &'a AtomicBool,
|
||||
}
|
||||
|
||||
impl<'a> HookGuard<'a> {
|
||||
fn try_acquire(flag: &'a AtomicBool) -> Option<Self> {
|
||||
flag
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.ok()
|
||||
.map(|_| Self { flag })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HookGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.flag.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1451,17 +1476,66 @@ impl Session {
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) -> crate::error::Result<ExecToolCallOutput> {
|
||||
self
|
||||
.run_exec_with_events_inner(
|
||||
turn_diff_tracker,
|
||||
begin_ctx,
|
||||
exec_args,
|
||||
seq_hint,
|
||||
output_index,
|
||||
attempt_req,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_exec_with_events_inner<'a>(
|
||||
&self,
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
begin_ctx: ExecCommandContext,
|
||||
exec_args: ExecInvokeArgs<'a>,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
enable_hooks: bool,
|
||||
) -> crate::error::Result<ExecToolCallOutput> {
|
||||
let is_apply_patch = begin_ctx.apply_patch.is_some();
|
||||
let sub_id = begin_ctx.sub_id.clone();
|
||||
let call_id = begin_ctx.call_id.clone();
|
||||
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
|
||||
.await;
|
||||
|
||||
let ExecInvokeArgs { params, sandbox_type, sandbox_policy, sandbox_cwd, codex_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 {
|
||||
Some(params.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if enable_hooks {
|
||||
if let Some(params_ref) = params_for_hooks.as_ref() {
|
||||
let before_event = if is_apply_patch {
|
||||
ProjectHookEvent::FileBeforeWrite
|
||||
} else {
|
||||
ProjectHookEvent::ToolBefore
|
||||
};
|
||||
self
|
||||
.run_hooks_for_exec_event(
|
||||
turn_diff_tracker,
|
||||
before_event,
|
||||
&begin_ctx,
|
||||
params_ref,
|
||||
None,
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
|
||||
.await;
|
||||
|
||||
let result = process_exec_tool_call(params, sandbox_type, sandbox_policy, sandbox_cwd, codex_linux_sandbox_exe, stdout_stream)
|
||||
.await;
|
||||
@@ -1494,6 +1568,26 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
|
||||
if enable_hooks {
|
||||
if let Some(params_ref) = params_for_hooks.as_ref() {
|
||||
let after_event = if is_apply_patch {
|
||||
ProjectHookEvent::FileAfterWrite
|
||||
} else {
|
||||
ProjectHookEvent::ToolAfter
|
||||
};
|
||||
self
|
||||
.run_hooks_for_exec_event(
|
||||
turn_diff_tracker,
|
||||
after_event,
|
||||
&begin_ctx,
|
||||
params_ref,
|
||||
Some(borrowed),
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(analysis) = dry_run_analysis.as_ref() {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.dry_run_guard.note_execution(analysis);
|
||||
@@ -1521,6 +1615,253 @@ impl Session {
|
||||
let _ = self.tx_event.send(event).await;
|
||||
}
|
||||
|
||||
fn resolve_internal_sandbox(&self, with_escalated_permissions: bool) -> SandboxType {
|
||||
match assess_safety_for_untrusted_command(
|
||||
self.approval_policy,
|
||||
&self.sandbox_policy,
|
||||
with_escalated_permissions,
|
||||
) {
|
||||
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
|
||||
SafetyCheck::AskUser | SafetyCheck::Reject { .. } => {
|
||||
crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_hooks_for_exec_event(
|
||||
&self,
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
event: ProjectHookEvent,
|
||||
exec_ctx: &ExecCommandContext,
|
||||
params: &ExecParams,
|
||||
output: Option<&ExecToolCallOutput>,
|
||||
attempt_req: u64,
|
||||
) {
|
||||
if self.project_hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let hooks: Vec<ProjectHook> = self.project_hooks.hooks_for(event).cloned().collect();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else {
|
||||
return;
|
||||
};
|
||||
let payload = build_exec_hook_payload(event, exec_ctx, params, output);
|
||||
for (idx, hook) in hooks.into_iter().enumerate() {
|
||||
self
|
||||
.run_hook_command(turn_diff_tracker, &hook, event, &payload, Some(exec_ctx), attempt_req, idx)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_session_hooks(&self, event: ProjectHookEvent) {
|
||||
if self.project_hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let hooks: Vec<ProjectHook> = self.project_hooks.hooks_for(event).cloned().collect();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else {
|
||||
return;
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session_payload(&self, event: ProjectHookEvent) -> Value {
|
||||
match event {
|
||||
ProjectHookEvent::SessionStart => json!({
|
||||
"event": event.as_str(),
|
||||
"cwd": self.cwd.to_string_lossy(),
|
||||
"sandbox_policy": format!("{}", self.sandbox_policy),
|
||||
"approval_policy": format!("{}", self.approval_policy),
|
||||
}),
|
||||
ProjectHookEvent::SessionEnd => json!({
|
||||
"event": event.as_str(),
|
||||
"cwd": self.cwd.to_string_lossy(),
|
||||
"sandbox_policy": format!("{}", self.sandbox_policy),
|
||||
"approval_policy": format!("{}", self.approval_policy),
|
||||
}),
|
||||
_ => json!({ "event": event.as_str() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_hook_command(
|
||||
&self,
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
hook: &ProjectHook,
|
||||
event: ProjectHookEvent,
|
||||
payload: &Value,
|
||||
base_ctx: Option<&ExecCommandContext>,
|
||||
attempt_req: u64,
|
||||
index: usize,
|
||||
) {
|
||||
let sub_id = base_ctx
|
||||
.map(|ctx| ctx.sub_id.clone())
|
||||
.unwrap_or_else(|| INITIAL_SUBMIT_ID.to_string());
|
||||
let base_slug = base_ctx
|
||||
.map(|ctx| sanitize_identifier(&ctx.call_id))
|
||||
.unwrap_or_else(|| event.slug().to_string());
|
||||
let call_id = format!("{base_slug}_hook_{}_{}", event.slug(), index + 1);
|
||||
|
||||
let mut env = hook.env.clone();
|
||||
env.entry("CODE_HOOK_EVENT".to_string())
|
||||
.or_insert_with(|| event.as_str().to_string());
|
||||
env.entry("CODE_HOOK_TRIGGER".to_string())
|
||||
.or_insert_with(|| event.slug().to_string());
|
||||
env.insert("CODE_HOOK_CALL_ID".to_string(), call_id.clone());
|
||||
env.insert("CODE_HOOK_SUB_ID".to_string(), sub_id.clone());
|
||||
env.insert("CODE_HOOK_INDEX".to_string(), (index + 1).to_string());
|
||||
env.insert("CODE_HOOK_PAYLOAD".to_string(), payload.to_string());
|
||||
env.entry("CODE_SESSION_CWD".to_string())
|
||||
.or_insert_with(|| self.cwd.to_string_lossy().to_string());
|
||||
if let Some(name) = &hook.name {
|
||||
env.entry("CODE_HOOK_NAME".to_string())
|
||||
.or_insert_with(|| name.clone());
|
||||
}
|
||||
if let Some(ctx) = base_ctx {
|
||||
env.entry("CODE_HOOK_SOURCE_CALL_ID".to_string())
|
||||
.or_insert_with(|| ctx.call_id.clone());
|
||||
}
|
||||
|
||||
let exec_params = ExecParams {
|
||||
command: hook.command.clone(),
|
||||
cwd: hook.resolved_cwd(self.get_cwd()),
|
||||
timeout_ms: hook.timeout_ms,
|
||||
env,
|
||||
with_escalated_permissions: Some(false),
|
||||
justification: None,
|
||||
};
|
||||
|
||||
let exec_ctx = ExecCommandContext {
|
||||
sub_id: sub_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
command_for_display: exec_params.command.clone(),
|
||||
cwd: exec_params.cwd.clone(),
|
||||
apply_patch: None,
|
||||
};
|
||||
|
||||
let sandbox_type = self.resolve_internal_sandbox(false);
|
||||
let exec_args = ExecInvokeArgs {
|
||||
params: exec_params,
|
||||
sandbox_type,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
sandbox_cwd: self.get_cwd(),
|
||||
codex_linux_sandbox_exe: &self.codex_linux_sandbox_exe,
|
||||
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"));
|
||||
self
|
||||
.notify_background_event(
|
||||
&sub_id,
|
||||
format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn find_project_command(&self, candidate: &str) -> Option<ProjectCommand> {
|
||||
self.project_commands
|
||||
.iter()
|
||||
.find(|cmd| cmd.matches(candidate))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
async fn run_project_command(
|
||||
&self,
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
sub_id: &str,
|
||||
name: &str,
|
||||
attempt_req: u64,
|
||||
) {
|
||||
let Some(command) = self.find_project_command(name) else {
|
||||
self
|
||||
.notify_background_event(
|
||||
sub_id,
|
||||
format!("Unknown project command `{}`", name.trim()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let mut env = command.env.clone();
|
||||
env.entry("CODE_PROJECT_COMMAND_NAME".to_string())
|
||||
.or_insert_with(|| command.name.clone());
|
||||
if let Some(desc) = &command.description {
|
||||
env.entry("CODE_PROJECT_COMMAND_DESCRIPTION".to_string())
|
||||
.or_insert_with(|| desc.clone());
|
||||
}
|
||||
env.entry("CODE_SESSION_CWD".to_string())
|
||||
.or_insert_with(|| self.cwd.to_string_lossy().to_string());
|
||||
|
||||
let exec_params = ExecParams {
|
||||
command: command.command.clone(),
|
||||
cwd: command.resolved_cwd(self.get_cwd()),
|
||||
timeout_ms: command.timeout_ms,
|
||||
env,
|
||||
with_escalated_permissions: Some(false),
|
||||
justification: None,
|
||||
};
|
||||
|
||||
let call_id = format!("project_cmd_{}", sanitize_identifier(&command.name));
|
||||
let exec_ctx = ExecCommandContext {
|
||||
sub_id: sub_id.to_string(),
|
||||
call_id: call_id.clone(),
|
||||
command_for_display: exec_params.command.clone(),
|
||||
cwd: exec_params.cwd.clone(),
|
||||
apply_patch: None,
|
||||
};
|
||||
|
||||
let sandbox_type = self.resolve_internal_sandbox(false);
|
||||
let exec_args = ExecInvokeArgs {
|
||||
params: exec_params,
|
||||
sandbox_type,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
sandbox_cwd: self.get_cwd(),
|
||||
codex_linux_sandbox_exe: &self.codex_linux_sandbox_exe,
|
||||
stdout_stream: None,
|
||||
};
|
||||
|
||||
if let Err(err) = self
|
||||
.run_exec_with_events(turn_diff_tracker, exec_ctx, exec_args, None, None, attempt_req)
|
||||
.await
|
||||
{
|
||||
self
|
||||
.notify_background_event(
|
||||
sub_id,
|
||||
format!(
|
||||
"Project command `{}` failed: {}",
|
||||
command.name,
|
||||
get_error_message_ui(&err)
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full turn input by concatenating the current conversation
|
||||
/// history with additional items for this turn.
|
||||
/// Browser screenshots are filtered out from history to keep them ephemeral.
|
||||
@@ -1826,6 +2167,119 @@ pub(crate) struct ApplyPatchCommandContext {
|
||||
pub(crate) changes: HashMap<PathBuf, FileChange>,
|
||||
}
|
||||
|
||||
fn sanitize_identifier(value: &str) -> String {
|
||||
let mut slug = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
} else {
|
||||
slug.push('_');
|
||||
}
|
||||
}
|
||||
while slug.starts_with('_') {
|
||||
slug.remove(0);
|
||||
}
|
||||
if slug.is_empty() {
|
||||
slug.push_str("hook");
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
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}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exec_hook_payload(
|
||||
event: ProjectHookEvent,
|
||||
ctx: &ExecCommandContext,
|
||||
params: &ExecParams,
|
||||
output: Option<&ExecToolCallOutput>,
|
||||
) -> 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,
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
ProjectHookEvent::FileBeforeWrite => {
|
||||
let changes = ctx
|
||||
.apply_patch
|
||||
.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,
|
||||
})
|
||||
}
|
||||
ProjectHookEvent::FileAfterWrite => {
|
||||
let changes = ctx
|
||||
.apply_patch
|
||||
.as_ref()
|
||||
.and_then(|p| serde_json::to_value(&p.changes).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// A series of Turns in response to user input.
|
||||
pub(crate) struct AgentTask {
|
||||
sess: Arc<Session>,
|
||||
@@ -2198,6 +2652,9 @@ async fn submission_loop(
|
||||
last_system_status: Mutex::new(None),
|
||||
last_screenshot_info: Mutex::new(None),
|
||||
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
|
||||
project_hooks: config.project_hooks.clone(),
|
||||
project_commands: config.project_commands.clone(),
|
||||
hook_guard: AtomicBool::new(false),
|
||||
}));
|
||||
if let Some(sess_arc) = &sess {
|
||||
if !config.always_allow_commands.is_empty() {
|
||||
@@ -2260,6 +2717,10 @@ async fn submission_loop(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sess_arc) = &sess {
|
||||
sess_arc.run_session_hooks(ProjectHookEvent::SessionStart).await;
|
||||
}
|
||||
|
||||
// Initialize agent manager after SessionConfigured is sent
|
||||
if !agent_manager_initialized {
|
||||
let mut manager = AGENT_MANAGER.write().await;
|
||||
@@ -2395,6 +2856,20 @@ async fn submission_loop(
|
||||
});
|
||||
}
|
||||
|
||||
Op::RunProjectCommand { name } => {
|
||||
let sess = match sess.as_ref() {
|
||||
Some(sess) => sess,
|
||||
None => {
|
||||
send_no_session_event(sub.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tracker = TurnDiffTracker::new();
|
||||
let attempt_req = sess.current_request_ordinal();
|
||||
sess.run_project_command(&mut tracker, &sub.id, &name, attempt_req)
|
||||
.await;
|
||||
}
|
||||
|
||||
Op::GetHistoryEntryRequest { offset, log_id } => {
|
||||
let config = config.clone();
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -2472,6 +2947,9 @@ async fn submission_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref sess_arc) = sess {
|
||||
sess_arc.run_session_hooks(ProjectHookEvent::SessionEnd).await;
|
||||
}
|
||||
let event = match sess {
|
||||
Some(ref sess_arc) => sess_arc.make_event(&sub.id, EventMsg::ShutdownComplete),
|
||||
None => Event {
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::config_types::ThemeName;
|
||||
use crate::config_types::ThemeColors;
|
||||
use crate::config_types::McpServerConfig;
|
||||
use crate::config_types::Notifications;
|
||||
use crate::config_types::ProjectCommandConfig;
|
||||
use crate::config_types::ProjectHookConfig;
|
||||
use crate::config_types::SandboxWorkspaceWrite;
|
||||
use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::config_types::ShellEnvironmentPolicyToml;
|
||||
@@ -29,6 +31,7 @@ use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::config_types::ReasoningEffort;
|
||||
use crate::config_types::ReasoningSummary;
|
||||
use crate::project_features::{load_project_commands, ProjectCommand, ProjectHooks};
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use dirs::home_dir;
|
||||
@@ -97,6 +100,12 @@ pub struct Config {
|
||||
/// Commands the user has permanently approved for this project/session.
|
||||
pub always_allow_commands: Vec<ApprovedCommandPattern>,
|
||||
|
||||
/// Project-level lifecycle hooks configured for the active workspace.
|
||||
pub project_hooks: ProjectHooks,
|
||||
|
||||
/// Project-specific commands available in the active workspace.
|
||||
pub project_commands: Vec<ProjectCommand>,
|
||||
|
||||
pub shell_environment_policy: ShellEnvironmentPolicy,
|
||||
/// Patterns requiring an explicit confirm prefix before running.
|
||||
pub confirm_guard: ConfirmGuardConfig,
|
||||
@@ -1368,6 +1377,10 @@ pub struct ProjectConfig {
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
#[serde(default)]
|
||||
pub always_allow_commands: Option<Vec<AllowedCommand>>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<ProjectHookConfig>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<ProjectCommandConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
@@ -1652,6 +1665,13 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
let project_hooks = project_override
|
||||
.map(|cfg| ProjectHooks::from_configs(&cfg.hooks, &resolved_cwd))
|
||||
.unwrap_or_default();
|
||||
let project_commands = project_override
|
||||
.map(|cfg| load_project_commands(&cfg.commands, &resolved_cwd))
|
||||
.unwrap_or_default();
|
||||
|
||||
let tools_web_search_request = override_tools_web_search_request
|
||||
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
|
||||
.unwrap_or(false);
|
||||
@@ -1752,6 +1772,8 @@ impl Config {
|
||||
approval_policy: effective_approval,
|
||||
sandbox_policy,
|
||||
always_allow_commands,
|
||||
project_hooks,
|
||||
project_commands,
|
||||
shell_environment_policy,
|
||||
confirm_guard,
|
||||
disable_response_storage: config_profile
|
||||
@@ -2444,6 +2466,8 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
project_hooks: ProjectHooks::default(),
|
||||
project_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
auto_upgrade_enabled: false,
|
||||
@@ -2510,6 +2534,8 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
project_hooks: ProjectHooks::default(),
|
||||
project_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
auto_upgrade_enabled: false,
|
||||
@@ -2591,6 +2617,8 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
project_hooks: ProjectHooks::default(),
|
||||
project_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: true,
|
||||
auto_upgrade_enabled: false,
|
||||
@@ -2657,6 +2685,9 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
project_hooks: ProjectHooks::default(),
|
||||
project_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
auto_upgrade_enabled: false,
|
||||
|
||||
@@ -7,6 +7,9 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
use shlex::split as shlex_split;
|
||||
|
||||
use serde::de::{self, Deserializer};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum_macros::Display;
|
||||
@@ -771,6 +774,103 @@ pub enum TextVerbosity {
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum CommandField {
|
||||
List(Vec<String>),
|
||||
String(String),
|
||||
}
|
||||
|
||||
fn deserialize_command_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = CommandField::deserialize(deserializer)?;
|
||||
match value {
|
||||
CommandField::List(items) => Ok(items),
|
||||
CommandField::String(text) => {
|
||||
if text.trim().is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
shlex_split(&text).ok_or_else(|| de::Error::custom("failed to parse command string"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProjectHookEvent {
|
||||
#[serde(rename = "session.start")]
|
||||
SessionStart,
|
||||
#[serde(rename = "session.end")]
|
||||
SessionEnd,
|
||||
#[serde(rename = "tool.before")]
|
||||
ToolBefore,
|
||||
#[serde(rename = "tool.after")]
|
||||
ToolAfter,
|
||||
#[serde(rename = "file.before_write")]
|
||||
FileBeforeWrite,
|
||||
#[serde(rename = "file.after_write")]
|
||||
FileAfterWrite,
|
||||
}
|
||||
|
||||
impl ProjectHookEvent {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectHookEvent::SessionStart => "session.start",
|
||||
ProjectHookEvent::SessionEnd => "session.end",
|
||||
ProjectHookEvent::ToolBefore => "tool.before",
|
||||
ProjectHookEvent::ToolAfter => "tool.after",
|
||||
ProjectHookEvent::FileBeforeWrite => "file.before_write",
|
||||
ProjectHookEvent::FileAfterWrite => "file.after_write",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectHookEvent::SessionStart => "session_start",
|
||||
ProjectHookEvent::SessionEnd => "session_end",
|
||||
ProjectHookEvent::ToolBefore => "tool_before",
|
||||
ProjectHookEvent::ToolAfter => "tool_after",
|
||||
ProjectHookEvent::FileBeforeWrite => "file_before_write",
|
||||
ProjectHookEvent::FileAfterWrite => "file_after_write",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ProjectHookConfig {
|
||||
pub event: ProjectHookEvent,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(alias = "run", deserialize_with = "deserialize_command_vec")]
|
||||
pub command: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub run_in_background: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ProjectCommandConfig {
|
||||
pub name: String,
|
||||
#[serde(alias = "run", deserialize_with = "deserialize_command_vec")]
|
||||
pub command: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<codex_protocol::config_types::ReasoningEffort> for ReasoningEffort {
|
||||
fn from(v: codex_protocol::config_types::ReasoningEffort) -> Self {
|
||||
match v {
|
||||
|
||||
@@ -68,6 +68,7 @@ mod openai_model_info;
|
||||
mod openai_tools;
|
||||
pub mod plan_tool;
|
||||
pub mod project_doc;
|
||||
pub mod project_features;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config_types::{ProjectCommandConfig, ProjectHookConfig, ProjectHookEvent};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProjectHook {
|
||||
pub event: ProjectHookEvent,
|
||||
pub name: Option<String>,
|
||||
pub command: Vec<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub run_in_background: bool,
|
||||
}
|
||||
|
||||
impl ProjectHook {
|
||||
pub fn resolved_cwd(&self, session_cwd: &Path) -> PathBuf {
|
||||
match &self.cwd {
|
||||
Some(path) if path.is_absolute() => path.clone(),
|
||||
Some(path) => session_cwd.join(path),
|
||||
None => session_cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ProjectHooks {
|
||||
hooks: HashMap<ProjectHookEvent, Vec<ProjectHook>>,
|
||||
}
|
||||
|
||||
impl ProjectHooks {
|
||||
pub fn from_configs(configs: &[ProjectHookConfig], project_root: &Path) -> Self {
|
||||
let mut map: HashMap<ProjectHookEvent, Vec<ProjectHook>> = HashMap::new();
|
||||
for cfg in configs {
|
||||
if cfg.command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let hook = ProjectHook {
|
||||
event: cfg.event,
|
||||
name: cfg.name.clone(),
|
||||
command: cfg.command.clone(),
|
||||
cwd: resolve_optional_path(&cfg.cwd, project_root),
|
||||
env: cfg.env.clone().unwrap_or_default(),
|
||||
timeout_ms: cfg.timeout_ms,
|
||||
run_in_background: cfg.run_in_background.unwrap_or(false),
|
||||
};
|
||||
map.entry(cfg.event).or_default().push(hook);
|
||||
}
|
||||
Self { hooks: map }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hooks.values().all(|hooks| hooks.is_empty())
|
||||
}
|
||||
|
||||
pub fn hooks_for(&self, event: ProjectHookEvent) -> impl Iterator<Item = &ProjectHook> {
|
||||
self.hooks
|
||||
.get(&event)
|
||||
.into_iter()
|
||||
.flat_map(|hooks| hooks.iter())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProjectCommand {
|
||||
pub name: String,
|
||||
pub command: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl ProjectCommand {
|
||||
pub fn matches(&self, candidate: &str) -> bool {
|
||||
self.name.eq_ignore_ascii_case(candidate.trim())
|
||||
}
|
||||
|
||||
pub fn resolved_cwd(&self, session_cwd: &Path) -> PathBuf {
|
||||
match &self.cwd {
|
||||
Some(path) if path.is_absolute() => path.clone(),
|
||||
Some(path) => session_cwd.join(path),
|
||||
None => session_cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_project_commands(configs: &[ProjectCommandConfig], project_root: &Path) -> Vec<ProjectCommand> {
|
||||
let mut commands: Vec<ProjectCommand> = Vec::new();
|
||||
for cfg in configs {
|
||||
let name = cfg.name.trim();
|
||||
if name.is_empty() || cfg.command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = ProjectCommand {
|
||||
name: name.to_string(),
|
||||
command: cfg.command.clone(),
|
||||
description: cfg.description.clone(),
|
||||
cwd: resolve_optional_path(&cfg.cwd, project_root),
|
||||
env: cfg.env.clone().unwrap_or_default(),
|
||||
timeout_ms: cfg.timeout_ms,
|
||||
};
|
||||
|
||||
if let Some(existing) = commands.iter_mut().find(|cmd| cmd.matches(name)) {
|
||||
*existing = entry;
|
||||
} else {
|
||||
commands.push(entry);
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn resolve_optional_path(raw: &Option<String>, project_root: &Path) -> Option<PathBuf> {
|
||||
let value = raw.as_ref()?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(value);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
Some(project_root.join(path))
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,11 @@ pub enum Op {
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Execute a project-scoped custom command defined in configuration.
|
||||
RunProjectCommand {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Internally queue a developer-role message to be included in the next turn.
|
||||
AddPendingInputDeveloper {
|
||||
/// The developer message text to add to pending input.
|
||||
|
||||
@@ -1276,6 +1276,11 @@ impl App<'_> {
|
||||
widget.insert_str("@");
|
||||
}
|
||||
}
|
||||
SlashCommand::Cmd => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_project_command(command_args);
|
||||
}
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_status_output();
|
||||
|
||||
@@ -10662,6 +10662,60 @@ impl ChatWidget<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn handle_project_command(&mut self, args: String) {
|
||||
let name = args.trim();
|
||||
if name.is_empty() {
|
||||
self.history_push(crate::history_cell::new_error_event(
|
||||
"`/cmd` — provide a project command name".to_string(),
|
||||
));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.config.project_commands.is_empty() {
|
||||
self.history_push(crate::history_cell::new_error_event(
|
||||
"No project commands configured for this workspace.".to_string(),
|
||||
));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cmd) = self
|
||||
.config
|
||||
.project_commands
|
||||
.iter()
|
||||
.find(|command| command.matches(name))
|
||||
.cloned()
|
||||
{
|
||||
let notice = if let Some(desc) = &cmd.description {
|
||||
format!("Running project command `{}` — {}", cmd.name, desc)
|
||||
} else {
|
||||
format!("Running project command `{}`", cmd.name)
|
||||
};
|
||||
self.insert_background_event_early(notice);
|
||||
self.request_redraw();
|
||||
self.submit_op(Op::RunProjectCommand { name: cmd.name });
|
||||
} else {
|
||||
let available: Vec<String> = self
|
||||
.config
|
||||
.project_commands
|
||||
.iter()
|
||||
.map(|cmd| cmd.name.clone())
|
||||
.collect();
|
||||
let suggestion = if available.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!(" Available commands: {}", available.join(", "))
|
||||
};
|
||||
self.history_push(crate::history_cell::new_error_event(format!(
|
||||
"Unknown project command `{}`.{}",
|
||||
name,
|
||||
suggestion
|
||||
)));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn switch_cwd(
|
||||
&mut self,
|
||||
new_cwd: std::path::PathBuf,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub enum SlashCommand {
|
||||
Compact,
|
||||
Diff,
|
||||
Mention,
|
||||
Cmd,
|
||||
Status,
|
||||
Update,
|
||||
Theme,
|
||||
@@ -65,6 +66,7 @@ impl SlashCommand {
|
||||
SlashCommand::Quit => "exit Code",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Cmd => "run a project command",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Update => "check for updates and optionally upgrade",
|
||||
SlashCommand::Theme => "switch between color themes",
|
||||
|
||||
+66
-1
@@ -614,6 +614,69 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
|
||||
> [!NOTE]
|
||||
> `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together.
|
||||
|
||||
## Project Hooks
|
||||
|
||||
Use the `[projects]` table to scope settings to a specific workspace path. In addition to `trust_level`, `approval_policy`, and `always_allow_commands`, you can attach lifecycle hooks that run commands automatically when notable events occur.
|
||||
|
||||
```toml
|
||||
[projects."/Users/me/src/my-app"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[[projects."/Users/me/src/my-app".hooks]]
|
||||
name = "bootstrap"
|
||||
event = "session.start"
|
||||
run = ["./scripts/bootstrap.sh"]
|
||||
timeout_ms = 60000
|
||||
|
||||
[[projects."/Users/me/src/my-app".hooks]]
|
||||
event = "tool.after"
|
||||
run = "npm run lint -- --changed"
|
||||
```
|
||||
|
||||
Supported hook events:
|
||||
|
||||
- `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`.
|
||||
|
||||
Example `tool.after` payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "tool.after",
|
||||
"call_id": "tool_12",
|
||||
"cwd": "/Users/me/src/my-app",
|
||||
"command": ["npm", "test"],
|
||||
"exit_code": 1,
|
||||
"duration_ms": 1832,
|
||||
"stdout": "…output truncated…",
|
||||
"stderr": "…",
|
||||
"timed_out": false
|
||||
}
|
||||
```
|
||||
|
||||
## Project Commands
|
||||
|
||||
Define project-scoped commands under `[[projects."<path>".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`.
|
||||
|
||||
```toml
|
||||
[[projects."/Users/me/src/my-app".commands]]
|
||||
name = "setup"
|
||||
description = "Install dependencies"
|
||||
run = ["pnpm", "install"]
|
||||
|
||||
[[projects."/Users/me/src/my-app".commands]]
|
||||
name = "unit"
|
||||
run = "cargo test --lib"
|
||||
```
|
||||
|
||||
Project commands appear in the TUI via `/cmd <name>` and run through the standard execution pipeline. During execution Codex sets `CODE_PROJECT_COMMAND_NAME`, `CODE_PROJECT_COMMAND_DESCRIPTION` (when provided), and `CODE_SESSION_CWD` so scripts can tailor their behaviour.
|
||||
|
||||
## Config reference
|
||||
|
||||
| Key | Type / Values | Notes |
|
||||
@@ -646,6 +709,9 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
|
||||
| `model_providers.<id>.stream_max_retries` | number | SSE stream retry count (default: 5). |
|
||||
| `model_providers.<id>.stream_idle_timeout_ms` | number | SSE idle timeout (ms) (default: 300000). |
|
||||
| `project_doc_max_bytes` | number | Max bytes to read from `AGENTS.md`. |
|
||||
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
|
||||
| `projects.<path>.hooks` | array<table> | Lifecycle hooks for that workspace (see "Project Hooks"). |
|
||||
| `projects.<path>.commands` | array<table> | Project commands exposed via `/cmd`. |
|
||||
| `profile` | string | Active profile name. |
|
||||
| `profiles.<name>.*` | various | Profile‑scoped overrides of the same keys. |
|
||||
| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). |
|
||||
@@ -665,6 +731,5 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
|
||||
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
|
||||
| `use_experimental_reasoning_summary` | boolean | Use experimental summary for reasoning chain. |
|
||||
| `responses_originator_header_internal_override` | string | Override `originator` header value. |
|
||||
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
|
||||
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
|
||||
| `tools.web_search_allowed_domains` | array<string> | Optional allow-list for web search (filters.allowed_domains). |
|
||||
|
||||
@@ -29,6 +29,7 @@ Notes
|
||||
from the repository root (not inside another branch worktree).
|
||||
- `/merge`: merge the current worktree branch back into the default branch and
|
||||
remove the worktree. Run this from inside the worktree created by `/branch`.
|
||||
- `/cmd <name>`: run a project command defined for the current workspace.
|
||||
|
||||
## UX & Display
|
||||
|
||||
|
||||
Reference in New Issue
Block a user