Restore Frodex fork command and debug hooks

This commit is contained in:
Friel
2026-05-04 02:32:03 +00:00
parent efdf88acb6
commit c224273d92
19 changed files with 929 additions and 27 deletions
+9 -7
View File
@@ -1704,15 +1704,17 @@ async fn parent_mcp_tool_snapshot_for_source(
}
fn fork_previous_response_id_enabled() -> bool {
std::env::var(CODEX_EXPERIMENTAL_FORK_PREVIOUS_RESPONSE_ID_ENV)
.is_ok_and(|value| fork_previous_response_id_value_enabled(&value))
let value = std::env::var(CODEX_EXPERIMENTAL_FORK_PREVIOUS_RESPONSE_ID_ENV).ok();
fork_previous_response_id_value_enabled(value.as_deref())
}
fn fork_previous_response_id_value_enabled(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
fn fork_previous_response_id_value_enabled(value: Option<&str>) -> bool {
value.is_none_or(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
async fn parent_response_continuation_for_source(
+7 -2
View File
@@ -108,19 +108,24 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
fn fork_previous_response_id_env_value_parses_truthy_values() {
for value in ["1", "true", "TRUE", "yes", "on"] {
assert!(
fork_previous_response_id_value_enabled(value),
fork_previous_response_id_value_enabled(Some(value)),
"{value} should enable previous response forking"
);
}
for value in ["", "0", "false", "off", "no", "enabled"] {
assert!(
!fork_previous_response_id_value_enabled(value),
!fork_previous_response_id_value_enabled(Some(value)),
"{value} should not enable previous response forking"
);
}
}
#[test]
fn fork_previous_response_id_is_enabled_by_default() {
assert!(fork_previous_response_id_value_enabled(/*value*/ None));
}
#[tokio::test]
async fn previous_response_fork_rollout_items_preserve_latest_turn_context() {
let harness = AgentControlHarness::new().await;
+12 -1
View File
@@ -6,6 +6,15 @@ use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnEnvironmentSelection;
use tokio::sync::Semaphore;
const CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS_ENV: &str = "CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS";
fn materialize_ephemeral_rollouts_for_debug() -> bool {
std::env::var(CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS_ENV).is_ok_and(|value| {
let value = value.trim();
!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
})
}
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
@@ -382,8 +391,9 @@ impl Session {
// - initialize thread persistence with new or resumed session info
// - perform default shell discovery
// - load history metadata (skipped for subagents)
let materialize_ephemeral_rollout = materialize_ephemeral_rollouts_for_debug();
let thread_persistence_fut = async {
if config.ephemeral {
if config.ephemeral && !materialize_ephemeral_rollout {
Ok::<_, anyhow::Error>(None)
} else {
let live_thread = match &initial_history {
@@ -442,6 +452,7 @@ impl Session {
"session_init.thread_persistence",
otel.name = "session_init.thread_persistence",
session_init.ephemeral = config.ephemeral,
session_init.materialize_ephemeral_rollout = materialize_ephemeral_rollout,
));
let state_db_fut = async {
if config.ephemeral {
@@ -64,6 +64,25 @@ impl ToolHandler for Handler {
)
.await;
session
.services
.agent_control
.finish_watchdog_helper(helper_thread_id)
.await;
session
.services
.agent_control
.finish_watchdog_helper_thread(helper_thread_id)
.await
.map_err(|err| collab_agent_error(helper_thread_id, err))?;
session
.services
.agent_control
.close_agent(target_thread_id)
.await
.map_err(|err| collab_agent_error(target_thread_id, err))?;
if let Some(message) = args.message
&& !message.trim().is_empty()
{
@@ -74,13 +93,6 @@ impl ToolHandler for Handler {
.await;
}
session
.services
.agent_control
.close_agent(target_thread_id)
.await
.map_err(|err| collab_agent_error(target_thread_id, err))?;
Ok(WatchdogSelfCloseResult {
previous_status: status,
})
@@ -29,6 +29,16 @@ impl ToolHandler for Handler {
"watchdog.snooze is only available in watchdog check-in threads.".to_string(),
));
};
session
.services
.agent_control
.finish_watchdog_helper_thread(session.conversation_id)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to finish watchdog helper after snooze: {err}"
))
})?;
let _ = args.reason;
Ok(WatchdogSnoozeResult {
target_thread_id: result.target_thread_id.to_string(),
@@ -730,7 +730,12 @@ async fn watchdog_snooze_suppresses_helper_and_clears_active_helper() {
.start_thread((*turn.config).clone())
.await
.expect("watchdog handle should start");
let helper_thread_id = session.conversation_id;
let helper = manager
.start_thread((*turn.config).clone())
.await
.expect("watchdog helper thread should start");
let helper_thread_id = helper.thread_id;
session.conversation_id = helper_thread_id;
session.services.agent_control = agent_control.clone();
let mut config = (*turn.config).clone();
config
@@ -999,7 +1004,6 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
.start_thread((*turn.config).clone())
.await
.expect("watchdog handle should start");
let helper_thread_id = session.conversation_id;
session.services.agent_control = agent_control.clone();
let mut config = (*turn.config).clone();
config
@@ -1015,10 +1019,29 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
child_depth: 0,
interval_s: 60,
prompt: "check in".to_string(),
config,
config: config.clone(),
})
.await
.expect("watchdog registration should succeed");
let helper_thread_id = agent_control
.spawn_agent(
config,
vec![UserInput::Text {
text: "watchdog helper implementation detail".to_string(),
text_elements: Vec::new(),
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: owner.thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("watchdog".to_string()),
})),
)
.await
.expect("watchdog helper should start");
session.conversation_id = helper_thread_id;
agent_control
.set_watchdog_active_helper_for_tests(target.thread_id, helper_thread_id)
.await;
@@ -1043,6 +1066,19 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
agent_control.get_status(target.thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
agent_control.get_status(helper_thread_id).await,
AgentStatus::NotFound
);
let listed_agents = agent_control
.list_agents(&SessionSource::Cli, /*path_prefix*/ None)
.await
.expect("list_agents should succeed after self-close");
assert!(
!listed_agents
.iter()
.any(|agent| agent.agent_name == target.thread_id.to_string())
);
let expected = InterAgentCommunication::new(
AgentPath::try_from("/root/watchdog").expect("watchdog path"),
AgentPath::root(),
@@ -1073,6 +1109,97 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
assert_eq!(close_event.status, AgentStatus::Running);
}
#[tokio::test]
async fn watchdog_close_self_removes_watchdog_handle_from_list_agents() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
let agent_control = manager.agent_control();
session.services.agent_control = agent_control.clone();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::AgentWatchdog)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
let enabled_config = config.clone();
turn.config = Arc::new(config);
let root_session = Arc::new(session);
let root_turn = Arc::new(turn);
let spawn_output = SpawnAgentHandlerV2
.handle(invocation(
root_session,
root_turn,
"spawn_agent",
function_payload(json!({
"message": "check this branch periodically",
"task_name": "ping_watchdog",
"agent_type": "watchdog"
})),
))
.await
.expect("watchdog spawn should succeed");
let (_, spawn_success) = expect_text_output(spawn_output);
let watchdog_id = agent_control
.resolve_agent_reference(root.thread_id, &SessionSource::Cli, "ping_watchdog")
.await
.expect("watchdog path should resolve");
assert_eq!(spawn_success, Some(true));
let helper_id = agent_control
.spawn_agent(
enabled_config,
vec![UserInput::Text {
text: "watchdog helper implementation detail".to_string(),
text_elements: Vec::new(),
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("watchdog".to_string()),
})),
)
.await
.expect("watchdog helper should start");
agent_control
.set_watchdog_active_helper_for_tests(watchdog_id, helper_id)
.await;
let (mut helper_session, helper_turn) = make_session_and_context().await;
helper_session.services.agent_control = agent_control.clone();
helper_session.conversation_id = helper_id;
WatchdogSelfCloseHandler
.handle(invocation(
Arc::new(helper_session),
Arc::new(helper_turn),
"close_self",
function_payload(json!({"message": "watchdog done"})),
))
.await
.expect("watchdog helper should self-close");
let listed_agents = agent_control
.list_agents(&SessionSource::Cli, /*path_prefix*/ None)
.await
.expect("list_agents should succeed after self-close");
assert!(
!listed_agents
.iter()
.any(|agent| agent.agent_name == "/root/ping_watchdog")
);
}
#[tokio::test]
async fn multi_agent_v2_spawn_requires_task_name() {
let (mut session, mut turn) = make_session_and_context().await;
+18
View File
@@ -51,3 +51,21 @@ fn does_not_persist_rollout_file_in_ephemeral_mode() -> anyhow::Result<()> {
assert_eq!(session_rollout_count(test.home_path()), 0);
Ok(())
}
#[test]
fn materializes_rollout_file_for_ephemeral_mode_when_debug_env_is_enabled() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture = find_resource!("tests/fixtures/cli_responses_fixture.sse")?;
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS", "1")
.arg("--skip-git-repo-check")
.arg("--ephemeral")
.arg("ephemeral debug behavior")
.assert()
.code(0);
assert_eq!(session_rollout_count(test.home_path()), 1);
Ok(())
}
+41 -3
View File
@@ -5,6 +5,9 @@
use super::resize_reflow::trailing_run_start;
use super::*;
use crate::terminal_multiplexer::FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE;
use crate::terminal_multiplexer::ForkPaneSpawnResult;
use crate::terminal_multiplexer::spawn_fork_in_new_pane;
const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
@@ -115,7 +118,7 @@ impl App {
}
}
}
AppEvent::ForkCurrentSession => {
AppEvent::ForkCurrentSession { placement } => {
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
@@ -127,11 +130,46 @@ impl App {
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
let terminal_info = codex_terminal_detection::terminal_info();
if terminal_info.multiplexer.is_none() {
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
}
if let Some(thread_id) = self.chat_widget.thread_id() {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
if let Some(multiplexer) = terminal_info.multiplexer.as_ref() {
match spawn_fork_in_new_pane(
multiplexer,
&thread_id,
&self.config,
&self.harness_overrides.additional_writable_roots,
placement,
)
.await
{
ForkPaneSpawnResult::Spawned => {
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
ForkPaneSpawnResult::InvalidPlacement(message) => {
self.chat_widget.add_error_message(message);
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
ForkPaneSpawnResult::Failed(err) => {
self.chat_widget.add_error_message(format!(
"Failed to open a new pane for /fork: {err}"
));
}
}
} else if placement.is_some() {
self.chat_widget.add_error_message(
FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE.to_string(),
);
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
match app_server.fork_thread(self.config.clone(), thread_id).await {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
+12 -1
View File
@@ -172,7 +172,9 @@ pub(crate) enum AppEvent {
ResumeSessionByIdOrName(String),
/// Fork the current session into a new thread.
ForkCurrentSession,
ForkCurrentSession {
placement: Option<ForkPanePlacement>,
},
/// Request to exit the application.
///
@@ -890,6 +892,15 @@ pub(crate) struct RealtimeWebrtcOffer {
pub(crate) handle: RealtimeWebrtcSessionHandle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForkPanePlacement {
Left,
Right,
Up,
Down,
Float,
}
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
+54 -1
View File
@@ -9,6 +9,9 @@ use super::*;
use crate::app_event::ThreadGoalSetMode;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::slash_commands;
use crate::terminal_multiplexer::fork_command_usage;
use crate::terminal_multiplexer::fork_pane_options;
use crate::terminal_multiplexer::parse_fork_pane_placement;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
@@ -145,7 +148,8 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
let terminal_info = terminal_info();
self.dispatch_fork_command(terminal_info.multiplexer.as_ref());
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
@@ -722,6 +726,18 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::Fork => {
let mut parts = trimmed.split_whitespace();
let placement = parts.next().and_then(parse_fork_pane_placement);
if placement.is_none() || parts.next().is_some() {
self.add_error_message(fork_command_usage(
terminal_info().multiplexer.as_ref(),
));
} else {
self.app_event_tx
.send(AppEvent::ForkCurrentSession { placement });
}
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
@@ -812,6 +828,43 @@ impl ChatWidget {
self.queued_command_drain_result(cmd)
}
pub(super) fn dispatch_fork_command(&mut self, multiplexer: Option<&Multiplexer>) {
if let Some(multiplexer) = multiplexer {
self.open_fork_popup(multiplexer);
} else {
self.app_event_tx
.send(AppEvent::ForkCurrentSession { placement: None });
}
}
fn open_fork_popup(&mut self, multiplexer: &Multiplexer) {
let items = fork_pane_options(multiplexer)
.iter()
.map(|option| {
let placement = option.placement;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::ForkCurrentSession {
placement: Some(placement),
});
})];
SelectionItem {
name: format!("/fork {}", option.name),
description: Some(option.description.to_string()),
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Fork into a new pane".to_string()),
subtitle: Some("Choose where to open the fork.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
#[cfg(target_os = "windows")]
let allow_elevate_sandbox = {
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Fork into a new pane
Choose where to open the fork.
› 1. /fork right Open the fork in a pane to the right.
2. /fork left Open the fork in a pane to the left.
3. /fork up Open the fork in a pane above.
4. /fork down Open the fork in a pane below.
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Fork into a new pane
Choose where to open the fork.
› 1. /fork float Open the fork in a floating pane.
2. /fork right Open the fork in a pane to the right.
3. /fork down Open the fork in a pane below.
Press enter to confirm or esc to go back
@@ -1,4 +1,5 @@
use super::*;
use codex_terminal_detection::Multiplexer;
use pretty_assertions::assert_eq;
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
@@ -1648,9 +1649,42 @@ async fn slash_resume_with_arg_requests_named_session() {
async fn slash_fork_requests_current_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Fork);
chat.dispatch_fork_command(/*multiplexer*/ None);
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ForkCurrentSession { placement: None })
);
}
#[tokio::test]
async fn slash_fork_opens_tmux_popup() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_fork_command(Some(&Multiplexer::Tmux { version: None }));
assert!(
rx.try_recv().is_err(),
"expected /fork in tmux to open a popup instead of dispatching immediately"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("fork_selection_popup_tmux", popup);
}
#[tokio::test]
async fn slash_fork_opens_zellij_popup() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_fork_command(Some(&Multiplexer::Zellij {}));
assert!(
rx.try_recv().is_err(),
"expected /fork in zellij to open a popup instead of dispatching immediately"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("fork_selection_popup_zellij", popup);
}
#[tokio::test]
+1
View File
@@ -166,6 +166,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod subagent_panel;
mod terminal_multiplexer;
mod terminal_palette;
mod terminal_probe;
mod terminal_title;
+1
View File
@@ -149,6 +149,7 @@ impl SlashCommand {
self,
SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Fork
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Fast
@@ -0,0 +1,6 @@
---
source: tui/src/terminal_multiplexer.rs
assertion_line: 367
expression: fork_command_usage(None)
---
Usage: /fork
@@ -0,0 +1,5 @@
---
source: tui/src/terminal_multiplexer.rs
expression: "fork_command_usage(Some(&Multiplexer::Tmux { version: None }))"
---
Usage: /fork [right|left|up|down]
@@ -0,0 +1,5 @@
---
source: tui/src/terminal_multiplexer.rs
expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))"
---
Usage: /fork [float|right|down]
+538
View File
@@ -0,0 +1,538 @@
use crate::app_event::ForkPanePlacement;
use crate::legacy_core::config::Config;
use codex_protocol::ThreadId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_terminal_detection::Multiplexer;
use shlex::try_join;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
pub(crate) struct MultiplexerSpawnConfig {
pub(crate) program: PathBuf,
pub(crate) args: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ForkPaneSpawnResult {
Spawned,
InvalidPlacement(String),
Failed(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ForkPaneOption {
pub(crate) placement: ForkPanePlacement,
pub(crate) name: &'static str,
pub(crate) description: &'static str,
}
const TMUX_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[
ForkPaneOption {
placement: ForkPanePlacement::Right,
name: "right",
description: "Open the fork in a pane to the right.",
},
ForkPaneOption {
placement: ForkPanePlacement::Left,
name: "left",
description: "Open the fork in a pane to the left.",
},
ForkPaneOption {
placement: ForkPanePlacement::Up,
name: "up",
description: "Open the fork in a pane above.",
},
ForkPaneOption {
placement: ForkPanePlacement::Down,
name: "down",
description: "Open the fork in a pane below.",
},
];
const ZELLIJ_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[
ForkPaneOption {
placement: ForkPanePlacement::Float,
name: "float",
description: "Open the fork in a floating pane.",
},
ForkPaneOption {
placement: ForkPanePlacement::Right,
name: "right",
description: "Open the fork in a pane to the right.",
},
ForkPaneOption {
placement: ForkPanePlacement::Down,
name: "down",
description: "Open the fork in a pane below.",
},
];
pub(crate) fn fork_pane_options(multiplexer: &Multiplexer) -> &'static [ForkPaneOption] {
match multiplexer {
Multiplexer::Zellij {} => ZELLIJ_FORK_PANE_OPTIONS,
Multiplexer::Tmux { .. } => TMUX_FORK_PANE_OPTIONS,
}
}
pub(crate) fn parse_fork_pane_placement(arg: &str) -> Option<ForkPanePlacement> {
match arg.to_ascii_lowercase().as_str() {
"left" => Some(ForkPanePlacement::Left),
"right" => Some(ForkPanePlacement::Right),
"up" => Some(ForkPanePlacement::Up),
"down" => Some(ForkPanePlacement::Down),
"float" => Some(ForkPanePlacement::Float),
_ => None,
}
}
fn codex_executable() -> PathBuf {
std::env::current_exe()
.map(|path| resolve_codex_executable(&path))
.unwrap_or_else(|_| PathBuf::from("codex"))
}
fn resolve_codex_executable(current_exe: &Path) -> PathBuf {
let Some(file_name) = current_exe.file_name().and_then(|name| name.to_str()) else {
return PathBuf::from("codex");
};
let Some(base_name) = file_name
.strip_suffix(".exe")
.unwrap_or(file_name)
.strip_prefix("codex-tui")
else {
return current_exe.to_path_buf();
};
if !base_name.is_empty() {
return current_exe.to_path_buf();
}
let sibling = if file_name.ends_with(".exe") {
current_exe.with_file_name("codex.exe")
} else {
current_exe.with_file_name("codex")
};
if sibling.is_file() {
sibling
} else {
PathBuf::from("codex")
}
}
fn fork_command_parts(
exe: &Path,
thread_id: &ThreadId,
config: &Config,
additional_writable_roots: &[PathBuf],
) -> Vec<String> {
let mut args = vec![
exe.display().to_string(),
"fork".to_string(),
"-C".to_string(),
config.cwd.display().to_string(),
];
match config.permissions.approval_policy.value() {
AskForApproval::UnlessTrusted => {
args.push("-a".to_string());
args.push("untrusted".to_string());
}
AskForApproval::OnFailure => {
args.push("-a".to_string());
args.push("on-failure".to_string());
}
AskForApproval::OnRequest => {
args.push("-a".to_string());
args.push("on-request".to_string());
}
AskForApproval::Never => {
args.push("-a".to_string());
args.push("never".to_string());
}
AskForApproval::Granular(granular_config) => {
let sandbox_approval = granular_config.sandbox_approval;
let rules = granular_config.rules;
let skill_approval = granular_config.skill_approval;
let request_permissions = granular_config.request_permissions;
let mcp_elicitations = granular_config.mcp_elicitations;
args.push("-c".to_string());
args.push(format!(
"approval_policy={{ granular = {{ sandbox_approval = {sandbox_approval}, rules = {rules}, skill_approval = {skill_approval}, request_permissions = {request_permissions}, mcp_elicitations = {mcp_elicitations} }} }}"
));
}
}
if let Some(profile) = config.active_profile.as_deref() {
args.push("-p".to_string());
args.push(profile.to_string());
}
if let Some(model) = config.model.as_deref() {
args.push("-m".to_string());
args.push(model.to_string());
}
if let Some(sandbox_mode) = sandbox_mode_arg(&config.legacy_sandbox_policy()) {
args.push("-s".to_string());
args.push(sandbox_mode.to_string());
}
if config.web_search_mode.value() == WebSearchMode::Live {
args.push("--search".to_string());
}
for root in additional_writable_roots {
args.push("--add-dir".to_string());
args.push(root.display().to_string());
}
args.push(thread_id.to_string());
args
}
fn sandbox_mode_arg(policy: &SandboxPolicy) -> Option<&'static str> {
match policy {
SandboxPolicy::DangerFullAccess => Some("danger-full-access"),
SandboxPolicy::ReadOnly { .. } => Some("read-only"),
SandboxPolicy::WorkspaceWrite { .. } => Some("workspace-write"),
SandboxPolicy::ExternalSandbox { .. } => None,
}
}
fn zellij_direction(placement: ForkPanePlacement) -> Option<&'static str> {
match placement {
ForkPanePlacement::Right => Some("right"),
ForkPanePlacement::Down => Some("down"),
_ => None,
}
}
fn build_zellij_new_pane_args(
command: &[String],
thread_id: &ThreadId,
placement: Option<ForkPanePlacement>,
) -> Vec<String> {
let mut args = vec![
"action".to_string(),
"new-pane".to_string(),
"--close-on-exit".to_string(),
];
args.push("--name".to_string());
args.push(format!("Fork of {thread_id}"));
if let Some(placement) = placement {
if placement == ForkPanePlacement::Float {
args.push("--floating".to_string());
} else if let Some(direction) = zellij_direction(placement) {
args.push("--direction".to_string());
args.push(direction.to_string());
} else {
unreachable!("invalid zellij placement");
}
}
args.push("--".to_string());
args.extend(command.iter().cloned());
args
}
fn tmux_split_flags(placement: Option<ForkPanePlacement>) -> [&'static str; 2] {
match placement {
None | Some(ForkPanePlacement::Right) => ["-h", ""],
Some(ForkPanePlacement::Left) => ["-h", "-b"],
Some(ForkPanePlacement::Down) => ["-v", ""],
Some(ForkPanePlacement::Up) => ["-v", "-b"],
_ => unreachable!("invalid tmux placement"),
}
}
fn build_tmux_new_pane_args(
command: &[String],
placement: Option<ForkPanePlacement>,
) -> Vec<String> {
let command =
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
let flags = tmux_split_flags(placement);
let mut args = vec!["split-window".to_string(), flags[0].to_string()];
if !flags[1].is_empty() {
args.push(flags[1].to_string());
}
args.push(command);
args
}
fn fork_spawn_config(
multiplexer: &Multiplexer,
exe: &Path,
thread_id: &ThreadId,
config: &Config,
additional_writable_roots: &[PathBuf],
placement: Option<ForkPanePlacement>,
) -> MultiplexerSpawnConfig {
let command = fork_command_parts(exe, thread_id, config, additional_writable_roots);
match multiplexer {
Multiplexer::Zellij {} => MultiplexerSpawnConfig {
program: PathBuf::from("zellij"),
args: build_zellij_new_pane_args(&command, thread_id, placement),
},
Multiplexer::Tmux { .. } => MultiplexerSpawnConfig {
program: PathBuf::from("tmux"),
args: build_tmux_new_pane_args(&command, placement),
},
}
}
const TMUX_FLOAT_UNSUPPORTED_MESSAGE: &str = "tmux does not support /fork float.";
const ZELLIJ_UNSUPPORTED_MESSAGE: &str = "Zellij only supports /fork [float|right|down].";
pub(crate) const FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE: &str =
"Fork pane placement requires a terminal multiplexer.";
pub(crate) fn fork_command_usage(multiplexer: Option<&Multiplexer>) -> String {
let Some(multiplexer) = multiplexer else {
return "Usage: /fork".to_string();
};
let options = fork_pane_options(multiplexer);
if options.is_empty() {
return "Usage: /fork".to_string();
}
let options = options
.iter()
.map(|option| option.name)
.collect::<Vec<_>>()
.join("|");
format!("Usage: /fork [{options}]")
}
fn validate_fork_placement_for_multiplexer(
multiplexer: &Multiplexer,
placement: Option<ForkPanePlacement>,
) -> Result<(), String> {
match multiplexer {
Multiplexer::Zellij {} => {
if placement.is_none_or(|placement| {
ZELLIJ_FORK_PANE_OPTIONS
.iter()
.any(|option| option.placement == placement)
}) {
Ok(())
} else {
Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string())
}
}
Multiplexer::Tmux { .. } => {
if placement.is_none_or(|placement| {
TMUX_FORK_PANE_OPTIONS
.iter()
.any(|option| option.placement == placement)
}) {
Ok(())
} else {
Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string())
}
}
}
}
pub(crate) async fn spawn_fork_in_new_pane(
multiplexer: &Multiplexer,
thread_id: &ThreadId,
config: &Config,
additional_writable_roots: &[PathBuf],
placement: Option<ForkPanePlacement>,
) -> ForkPaneSpawnResult {
if let Err(err) = validate_fork_placement_for_multiplexer(multiplexer, placement) {
return ForkPaneSpawnResult::InvalidPlacement(err);
}
let exe = codex_executable();
let spawn_config = fork_spawn_config(
multiplexer,
&exe,
thread_id,
config,
additional_writable_roots,
placement,
);
let MultiplexerSpawnConfig { program, args } = spawn_config;
let program_display = program.display().to_string();
match tokio::task::spawn_blocking(move || {
Command::new(&program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
})
.await
{
Ok(Ok(_)) => ForkPaneSpawnResult::Spawned,
Ok(Err(err)) => {
ForkPaneSpawnResult::Failed(format!("failed to run {program_display}: {err}"))
}
Err(err) => {
ForkPaneSpawnResult::Failed(format!("failed to spawn {program_display} pane: {err}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn resolve_codex_executable_prefers_sibling_codex_for_codex_tui() {
let tempdir = tempdir().expect("tempdir");
let current_exe = tempdir.path().join("codex-tui");
let sibling = tempdir.path().join("codex");
std::fs::write(&sibling, b"").expect("create sibling codex");
assert_eq!(resolve_codex_executable(&current_exe), sibling);
}
#[test]
fn resolve_codex_executable_keeps_non_tui_binary() {
let current_exe = PathBuf::from("/tmp/codex");
assert_eq!(resolve_codex_executable(&current_exe), current_exe);
}
#[test]
fn validate_zellij_fork_placement_rejects_left() {
assert_eq!(
validate_fork_placement_for_multiplexer(
&Multiplexer::Zellij {},
Some(ForkPanePlacement::Left),
),
Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string())
);
}
#[test]
fn validate_tmux_fork_placement_rejects_float() {
assert_eq!(
validate_fork_placement_for_multiplexer(
&Multiplexer::Tmux { version: None },
Some(ForkPanePlacement::Float),
),
Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string())
);
}
#[test]
fn fork_command_usage_is_contextual() {
assert_snapshot!(
"fork_command_usage_default",
fork_command_usage(/*multiplexer*/ None)
);
assert_snapshot!(
"fork_command_usage_tmux",
fork_command_usage(Some(&Multiplexer::Tmux { version: None }))
);
assert_snapshot!(
"fork_command_usage_zellij",
fork_command_usage(Some(&Multiplexer::Zellij {}))
);
}
#[tokio::test]
async fn fork_command_parts_include_current_session_overrides() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config.active_profile = Some("work".to_string());
config.model = Some("gpt-5".to_string());
config.cwd =
AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd");
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy");
config
.set_legacy_sandbox_policy(SandboxPolicy::new_workspace_write_policy())
.expect("sandbox policy");
config
.web_search_mode
.set(WebSearchMode::Live)
.expect("web search mode");
let command = fork_command_parts(
Path::new("/bin/codex"),
&ThreadId::new(),
&config,
&[PathBuf::from("/extra")],
);
let thread_id = command.last().expect("thread id").clone();
assert_eq!(
command,
vec![
"/bin/codex".to_string(),
"fork".to_string(),
"-C".to_string(),
"/repo".to_string(),
"-a".to_string(),
"on-request".to_string(),
"-p".to_string(),
"work".to_string(),
"-m".to_string(),
"gpt-5".to_string(),
"-s".to_string(),
"workspace-write".to_string(),
"--search".to_string(),
"--add-dir".to_string(),
"/extra".to_string(),
thread_id,
]
);
}
#[tokio::test]
async fn fork_command_parts_preserve_granular_approval_policy() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config.cwd =
AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd");
config
.permissions
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: false,
skill_approval: true,
request_permissions: false,
mcp_elicitations: true,
}))
.expect("approval policy");
let command = fork_command_parts(Path::new("/bin/codex"), &ThreadId::new(), &config, &[]);
let thread_id = command.last().expect("thread id").clone();
assert_eq!(
command,
vec![
"/bin/codex".to_string(),
"fork".to_string(),
"-C".to_string(),
"/repo".to_string(),
"-c".to_string(),
"approval_policy={ granular = { sandbox_approval = true, rules = false, skill_approval = true, request_permissions = false, mcp_elicitations = true } }".to_string(),
"-s".to_string(),
"read-only".to_string(),
thread_id,
]
);
}
}