diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7247794eaf..ec08be521b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4416,6 +4416,13 @@ "mode" ], "type": "object" + }, + "WorkspaceMutationOperation": { + "enum": [ + "setWorkingDirectory", + "addWorkspaceRoot" + ], + "type": "string" } }, "description": "Request from the client to the server.", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 236379aba4..56ed666429 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -546,6 +546,12 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadSettingsUpdateResponse, }, + #[experimental("thread/workspace/update")] + ThreadWorkspaceUpdate => "thread/workspace/update" { + params: v2::ThreadWorkspaceUpdateParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadWorkspaceUpdateResponse, + }, #[experimental("thread/memoryMode/set")] ThreadMemoryModeSet => "thread/memoryMode/set" { params: v2::ThreadMemoryModeSetParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c570b87ce4..58cc436427 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3712,6 +3712,30 @@ fn thread_settings_update_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn thread_workspace_update_params_round_trip() { + let params = ThreadWorkspaceUpdateParams { + thread_id: "thread_123".to_string(), + operation: WorkspaceMutationOperation::SetWorkingDirectory, + path: "../stack-layer".to_string(), + }; + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized, + json!({ + "threadId": "thread_123", + "operation": "setWorkingDirectory", + "path": "../stack-layer" + }) + ); + assert_eq!( + serde_json::from_value::(serialized) + .expect("params should deserialize"), + params + ); +} + #[test] fn thread_settings_update_params_preserve_field_level_experimental_gates() { let permissions = ThreadSettingsUpdateParams { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 6f860750d7..6ace2d514d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -9,6 +9,7 @@ use super::ThreadSource; use super::Turn; use super::TurnEnvironmentParams; use super::TurnItemsView; +use super::WorkspaceMutationOperation; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; pub use codex_protocol::capabilities::CapabilityRootLocation; @@ -281,6 +282,24 @@ pub struct ThreadSettingsUpdateParams { #[ts(export_to = "v2/")] pub struct ThreadSettingsUpdateResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadWorkspaceUpdateParams { + pub thread_id: String, + pub operation: WorkspaceMutationOperation, + pub path: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadWorkspaceUpdateResponse { + pub changed: bool, + pub cwd: AbsolutePathBuf, + pub runtime_workspace_roots: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6afe50dd55..90fe246d5a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -149,6 +149,7 @@ Example with notification opt-out: - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. - `thread/goal/cleared` — notification emitted whenever a thread goal is removed. - `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. Experimental clients also receive `threadSettings.runtimeWorkspaceRoots`, including roots added by model workspace mutations. +- `thread/workspace/update` — experimental direct user workspace mutation; accepts `threadId`, `operation` (`setWorkingDirectory` or `addWorkspaceRoot`), and an absolute or cwd-relative `path`, then returns the authoritative runtime cwd and workspace roots. This method supports exactly one selected execution environment and does not request an additional approval because invoking it is an explicit user action. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/delete` — hard-delete an active or archived thread and any spawned descendant threads; returns `{}` on success and emits `thread/deleted` for each deleted thread. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6747830b89..bc8884635c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1131,6 +1131,9 @@ impl MessageProcessor { .thread_settings_update(&request_id, params) .await } + ClientRequest::ThreadWorkspaceUpdate { params, .. } => { + self.turn_processor.thread_workspace_update(params).await + } ClientRequest::ThreadMemoryModeSet { params, .. } => { self.thread_processor.thread_memory_mode_set(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8988250758..349af75f5b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -256,6 +256,8 @@ use codex_app_server_protocol::ThreadUnarchivedNotification; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::ThreadWorkspaceUpdateParams; +use codex_app_server_protocol::ThreadWorkspaceUpdateResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnError; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 2ab2b2992c..7c6f69ba2d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -131,6 +131,15 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_workspace_update( + &self, + params: ThreadWorkspaceUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_workspace_update_inner(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, @@ -734,6 +743,22 @@ impl TurnRequestProcessor { Ok(ThreadSettingsUpdateResponse {}) } + async fn thread_workspace_update_inner( + &self, + params: ThreadWorkspaceUpdateParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let response = thread + .update_runtime_workspace(params.operation.to_core(), params.path) + .await + .map_err(|err| invalid_request(format!("failed to update thread workspace: {err}")))?; + Ok(ThreadWorkspaceUpdateResponse { + changed: response.changed, + cwd: response.cwd, + runtime_workspace_roots: response.workspace_roots, + }) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index c4cb1e8e12..dd2f459091 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -167,6 +167,13 @@ pub struct BackgroundTerminalInfo { pub cwd: AbsolutePathBuf, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserWorkspaceMutationResult { + pub changed: bool, + pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, +} + /// Conduit for the bidirectional stream of messages that compose a thread /// (formerly called a conversation) in Codex. impl CodexThread { @@ -189,6 +196,52 @@ impl CodexThread { self.codex.submit(op).await } + pub async fn update_runtime_workspace( + &self, + operation: codex_protocol::request_permissions::WorkspaceMutationOperation, + path: String, + ) -> anyhow::Result { + if self.codex.session.active_turn.lock().await.is_some() { + anyhow::bail!("workspace mutation is unavailable while a task is in progress"); + } + let turn = self.codex.session.new_default_turn().await; + let plan = crate::tools::handlers::workspace_mutation::plan_workspace_mutation( + self.codex.session.as_ref(), + &turn, + operation.clone(), + path, + ) + .await + .map_err(|err| anyhow::anyhow!("{}: {}", err.code, err.message))?; + if plan.changed { + self.codex + .session + .update_runtime_workspace( + turn.as_ref(), + matches!( + operation, + codex_protocol::request_permissions::WorkspaceMutationOperation::SetWorkingDirectory + ) + .then_some(plan.cwd.clone()), + plan.workspace_roots.clone(), + ) + .await?; + self.codex + .session + .send_event( + turn.as_ref(), + crate::session::thread_settings_applied_event(self.codex.session.as_ref()) + .await, + ) + .await; + } + Ok(UserWorkspaceMutationResult { + changed: plan.changed, + cwd: plan.cwd, + workspace_roots: plan.workspace_roots, + }) + } + /// Returns the session telemetry handle for thread-scoped production instrumentation. pub fn session_telemetry(&self) -> SessionTelemetry { self.codex.session.services.session_telemetry.clone() diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index bafe48ee1f..2f45a174cc 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -28,6 +28,7 @@ pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; pub use codex_thread::TryStartTurnIfIdleError; pub use codex_thread::TryStartTurnIfIdleRejectionReason; +pub use codex_thread::UserWorkspaceMutationResult; pub use session::turn_context::TurnContext; mod agent; mod attestation; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index b1ba37adbb..e0975ece4e 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -33,7 +33,7 @@ pub(crate) mod tool_search_spec; pub(crate) mod unified_exec; mod view_image; pub(crate) mod view_image_spec; -mod workspace_mutation; +pub(crate) mod workspace_mutation; pub(crate) mod workspace_mutation_spec; use codex_sandboxing::policy_transforms::intersect_permission_profiles; diff --git a/codex-rs/core/src/tools/handlers/workspace_mutation.rs b/codex-rs/core/src/tools/handlers/workspace_mutation.rs index 2c3fa2db70..9c718d72a9 100644 --- a/codex-rs/core/src/tools/handlers/workspace_mutation.rs +++ b/codex-rs/core/src/tools/handlers/workspace_mutation.rs @@ -61,10 +61,10 @@ struct WorkspaceMutationArgs { path: String, } -struct WorkspaceMutationPlan { - changed: bool, - cwd: AbsolutePathBuf, - workspace_roots: Vec, +pub(crate) struct WorkspaceMutationPlan { + pub(crate) changed: bool, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, } struct WorkspacePermissionRequest { @@ -87,15 +87,78 @@ struct WorkspaceMutationResult { } #[derive(Serialize)] -struct WorkspaceMutationError { - code: &'static str, - message: String, - cwd: AbsolutePathBuf, - workspace_roots: Vec, +pub(crate) struct WorkspaceMutationError { + pub(crate) code: &'static str, + pub(crate) message: String, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, #[serde(skip_serializing_if = "is_zero")] omitted_workspace_roots: usize, } +pub(crate) async fn plan_workspace_mutation( + session: &Session, + turn: &TurnContext, + operation: WorkspaceMutationOperation, + path: String, +) -> Result { + let current = session.runtime_workspace_snapshot().await; + let Some(environment) = turn + .environments + .primary() + .filter(|_| turn.environments.turn_environments.len() == 1) + else { + return Err(WorkspaceMutationError { + code: "unsupported_environment_count", + message: "workspace mutation requires exactly one execution environment".to_string(), + cwd: current.cwd, + workspace_roots: current.workspace_roots, + omitted_workspace_roots: 0, + }); + }; + let requested = current.cwd.join(path); + let fs = environment.environment.get_filesystem(); + let canonical = fs + .canonicalize(&requested, /*sandbox*/ None) + .await + .map_err(|err| WorkspaceMutationError { + code: io_error_code(&err), + message: err.to_string(), + cwd: current.cwd.clone(), + workspace_roots: current.workspace_roots.clone(), + omitted_workspace_roots: 0, + })?; + let metadata = fs + .get_metadata(&canonical, /*sandbox*/ None) + .await + .map_err(|err| WorkspaceMutationError { + code: io_error_code(&err), + message: err.to_string(), + cwd: current.cwd.clone(), + workspace_roots: current.workspace_roots.clone(), + omitted_workspace_roots: 0, + })?; + if !metadata.is_directory { + return Err(WorkspaceMutationError { + code: "not_a_directory", + message: format!( + "workspace mutation target is not a directory: {}", + canonical.as_path().display() + ), + cwd: current.cwd, + workspace_roots: current.workspace_roots, + omitted_workspace_roots: 0, + }); + } + + Ok(workspace_mutation_plan( + operation, + ¤t.cwd, + ¤t.workspace_roots, + canonical, + )) +} + impl ToolExecutor for WorkspaceMutationHandler { fn tool_name(&self) -> ToolName { ToolName::plain(match self.operation { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 48ad956789..e872e9e4c8 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -7,6 +7,7 @@ use super::resize_reflow::trailing_run_start; use super::*; use crate::config_update::format_config_error; use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome; +use codex_app_server_protocol::WorkspaceMutationOperation; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -360,6 +361,46 @@ impl App { AppEvent::RestoreCancelledTurn(prompt) => { self.apply_cancelled_turn_edit(prompt); } + AppEvent::UpdateThreadWorkspace { operation, path } => { + let Some(thread_id) = self.active_thread_id else { + self.chat_widget.add_error_message( + "Workspace commands are unavailable before the session starts.".to_string(), + ); + return Ok(AppRunControl::Continue); + }; + match app_server + .thread_workspace_update(thread_id, operation, path) + .await + { + Ok(response) => { + let message = match (operation, response.changed) { + ( + WorkspaceMutationOperation::SetWorkingDirectory, + /*changed*/ true, + ) => { + format!("Working directory: {}", response.cwd.as_path().display()) + } + ( + WorkspaceMutationOperation::AddWorkspaceRoot, + /*changed*/ true, + ) => match response.runtime_workspace_roots.last() { + Some(root) => { + format!("Added workspace root: {}", root.as_path().display()) + } + None => "Workspace roots updated.".to_string(), + }, + (_, /*changed*/ false) => format!( + "Workspace unchanged. Working directory: {}", + response.cwd.as_path().display() + ), + }; + self.chat_widget.add_info_message(message, /*hint*/ None); + } + Err(err) => self + .chat_widget + .add_error_message(format!("Failed to update workspace: {err}")), + } + } AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ee6364c0de..af4dc99f50 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::WorkspaceMutationOperation; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -258,6 +259,12 @@ pub(crate) enum AppEvent { /// Restore an output-free interrupted turn into the composer and roll it back. RestoreCancelledTurn(UserMessage), + /// Apply an explicit user-requested runtime workspace mutation. + UpdateThreadWorkspace { + operation: WorkspaceMutationOperation, + path: String, + }, + /// Approve one retry of a recent auto-review denial selected in the TUI. ApproveRecentAutoReviewDenial { thread_id: ThreadId, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 856defffcf..e93b2a02e6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -107,6 +107,8 @@ use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnarchiveResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadWorkspaceUpdateParams; +use codex_app_server_protocol::ThreadWorkspaceUpdateResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; @@ -115,6 +117,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WorkspaceMutationOperation; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; @@ -723,6 +726,26 @@ impl AppServerSession { } } + pub(crate) async fn thread_workspace_update( + &mut self, + thread_id: ThreadId, + operation: WorkspaceMutationOperation, + path: String, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadWorkspaceUpdate { + request_id, + params: ThreadWorkspaceUpdateParams { + thread_id: thread_id.to_string(), + operation, + path, + }, + }) + .await + .wrap_err("thread/workspace/update failed in TUI") + } + pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index b5da5ca8fd..d052748155 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -34,6 +34,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; +const ADD_DIR_USAGE: &str = "Usage: /add-dir "; impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. @@ -298,6 +299,15 @@ impl ChatWidget { ); } } + SlashCommand::Cwd => { + self.add_info_message( + format!("Working directory: {}", self.config.cwd.display()), + /*hint*/ None, + ); + } + SlashCommand::AddDir => { + self.add_error_message(ADD_DIR_USAGE.to_string()); + } SlashCommand::Side | SlashCommand::Btw => { self.request_empty_side_conversation(cmd); } @@ -829,6 +839,20 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args }); } + SlashCommand::Cwd if !trimmed.is_empty() => { + self.app_event_tx.send(AppEvent::UpdateThreadWorkspace { + operation: + codex_app_server_protocol::WorkspaceMutationOperation::SetWorkingDirectory, + path: args, + }); + } + SlashCommand::AddDir if !trimmed.is_empty() => { + self.app_event_tx.send(AppEvent::UpdateThreadWorkspace { + operation: + codex_app_server_protocol::WorkspaceMutationOperation::AddWorkspaceRoot, + path: args, + }); + } SlashCommand::Pets if matches!( args.trim().to_ascii_lowercase().as_str(), @@ -976,6 +1000,8 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Cwd + | SlashCommand::AddDir | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e61ffd48fe..f17ce09cca 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -87,6 +87,36 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Cwd => "show or change the session working directory: /cwd [path]", + SlashCommand::AddDir => "add a directory to the session workspace: /add-dir ", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -170,6 +174,8 @@ impl SlashCommand { | SlashCommand::Btw | SlashCommand::Resume | SlashCommand::SandboxReadRoot + | SlashCommand::Cwd + | SlashCommand::AddDir ) } @@ -212,6 +218,7 @@ impl SlashCommand { | SlashCommand::Logout | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, + SlashCommand::Cwd | SlashCommand::AddDir => false, SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Raw