diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index db76e363ec..88edc22f9e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -192,7 +192,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 72948270a6..39dbd17fe6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", + "remote_plugin", "tool_suggest", "tool_call_mcp_elicitation", ]; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d6101995fa..782dba3edf 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -340,6 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -352,6 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -381,6 +383,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("remote_plugin")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -474,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5b1e7d4cc8..5848d1f444 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap { pub delete_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, + /// Change from cursor to end of line and enter insert mode (`C`). + pub change_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6af30b4071..06b7c56d5b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2834,6 +2834,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3500,6 +3501,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3765,6 +3767,14 @@ ], "description": "Cancel a pending operator and return to normal mode." }, + "change_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change from cursor to end of line and enter insert mode (`C`)." + }, "delete_char": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8209272f10..b5ba938ef7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1113,6 +1113,108 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("parent shutdown should submit"); } +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let startup_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + startup_turn_context.as_ref(), + &[ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "parent startup developer context".to_string(), + }], + phase: None, + }], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should drop startup prefix") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "parent startup developer context"), + "bounded fork should drop parent startup context even when fewer turns exist than requested" + ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "bounded forked child should still rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + #[tokio::test] async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index e56d5ce170..67f733e0ba 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -905,6 +905,7 @@ pub(crate) fn build_guardian_review_session_config( .map(guardian_policy_prompt_with_config) .unwrap_or_else(guardian_policy_prompt), ); + guardian_config.notify = None; guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); guardian_config diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 0544b27770..00f0d9be38 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2215,6 +2215,25 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() { ); } +#[tokio::test] +async fn guardian_review_session_config_clears_legacy_notify() { + let mut parent_config = test_config().await; + parent_config.notify = Some(vec![ + "/path/to/notify".to_string(), + "turn-ended".to_string(), + ]); + + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); + + assert_eq!(guardian_config.notify, None); +} + #[tokio::test] async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config().await; diff --git a/codex-rs/core/src/tasks/idle_extension.rs b/codex-rs/core/src/tasks/idle_extension.rs index 3880379ba3..08dcea9840 100644 --- a/codex-rs/core/src/tasks/idle_extension.rs +++ b/codex-rs/core/src/tasks/idle_extension.rs @@ -12,7 +12,7 @@ pub(super) fn schedule_turn(session: &Arc) { if session .services .extensions - .idle_turn_contributors() + .thread_lifecycle_contributors() .is_empty() { return; @@ -77,14 +77,15 @@ async fn has_active_or_pending_work(session: &Session) -> bool { async fn next_idle_turn_input(session: &Session) -> Option> { let collaboration_mode = session.collaboration_mode().await; - for contributor in session.services.extensions.idle_turn_contributors() { + for contributor in session.services.extensions.thread_lifecycle_contributors() { let Some(items) = contributor - .next_idle_turn(codex_extension_api::IdleTurnInput { + .on_thread_idle(codex_extension_api::ThreadIdleInput { collaboration_mode: &collaboration_mode, session_store: &session.services.session_extension_data, thread_store: &session.services.thread_extension_data, }) .await + .map(|request| request.items) else { continue; }; diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index e20ee53d47..69e615c4fb 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -130,7 +130,8 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( /// Return a suffix of `items` that keeps the last `n_from_end` fork turns. /// -/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout. +/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn +/// boundary and still drops pre-turn startup context. pub(crate) fn truncate_rollout_to_last_n_fork_turns( items: &[RolloutItem], n_from_end: usize, @@ -140,11 +141,14 @@ pub(crate) fn truncate_rollout_to_last_n_fork_turns( } let fork_turn_positions = fork_turn_positions_in_rollout(items); - if fork_turn_positions.len() <= n_from_end { - return items.to_vec(); - } - - let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end]; + let Some(keep_idx) = fork_turn_positions + .len() + .checked_sub(n_from_end) + .map(|position| fork_turn_positions[position]) + .or_else(|| fork_turn_positions.first().copied()) + else { + return Vec::new(); + }; items[keep_idx..].to_vec() } diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index df370a0546..e0a8837165 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -29,6 +29,17 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn developer_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + } +} + fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem { let communication = InterAgentCommunication::new( AgentPath::root(), @@ -197,6 +208,23 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() { ); } +#[test] +fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() { + let rollout = vec![ + RolloutItem::ResponseItem(developer_msg("startup developer context")), + RolloutItem::ResponseItem(user_msg("current task")), + RolloutItem::ResponseItem(assistant_msg("answer")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + let expected = rollout[1..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + #[test] fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() { let rollout = vec![ diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs new file mode 100644 index 0000000000..df38fb4353 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -0,0 +1,170 @@ +#![cfg(not(target_os = "windows"))] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::fs_wait; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::time::Duration; +use tempfile::TempDir; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let notify_dir = TempDir::new()?; + let notify_script = notify_dir.path().join("notify.sh"); + fs::write( + ¬ify_script, + r#"#!/bin/bash +set -e +payload_path="$(dirname "${0}")/notify.jsonl" +printf '%s\n' "${@: -1}" >> "${payload_path}""#, + )?; + fs::set_permissions(¬ify_script, fs::Permissions::from_mode(0o755))?; + let notify_file = notify_dir.path().join("notify.jsonl"); + let notify_script_str = notify_script.to_str().unwrap().to_string(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.notify = Some(vec![notify_script_str]); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build(&server).await?; + + let output_file = test.cwd.path().join("guardian-review-notify.txt"); + let command = format!("printf guardian-approved > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-parent-tool"), + ev_function_call( + "exec-call", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-parent-tool"), + ]), + sse(vec![ + ev_response_created("resp-guardian-review"), + ev_assistant_message( + "msg-guardian-review", + &json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The command writes a marker file in the workspace.", + }) + .to_string(), + ), + ev_completed("resp-guardian-review"), + ]), + sse(vec![ + ev_response_created("resp-parent-done"), + ev_assistant_message("msg-parent-done", "done"), + ev_completed("resp-parent-done"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let guardian_request = responses + .requests() + .into_iter() + .find(|request| request.body_contains_text("Exercise Guardian approval routing.")) + .expect("expected Guardian review request"); + assert!(guardian_request.body_contains_text(&command)); + + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payloads: Vec = notify_payload_raw + .lines() + .map(serde_json::from_str::) + .collect::>()?; + + assert_eq!( + payloads.len(), + 1, + "unexpected notify payloads: {payloads:?}" + ); + assert_eq!( + payloads[0]["input-messages"], + json!(["run a command that requires Guardian review"]) + ); + assert_eq!(payloads[0]["last-assistant-message"], json!("done")); + assert!( + !notify_payload_raw.contains( + "The following is the Codex agent history whose request action you are assessing." + ), + "Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}" + ); + assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved"); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f47de672bd..f4ab6c1d6d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,8 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; +#[cfg(not(target_os = "windows"))] +mod guardian_review; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index e01552c9c1..5a4dc0f86b 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -1,12 +1,9 @@ //! Contributor traits and inputs that let extensions hook into host-owned workflows. use std::future::Future; -use std::pin::Pin; use std::sync::Arc; -use codex_protocol::config_types::CollaborationMode; use codex_protocol::items::TurnItem; -use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsageInfo; @@ -22,6 +19,8 @@ mod turn_lifecycle; pub use prompt::PromptFragment; pub use prompt::PromptSlot; +pub use thread_lifecycle::ThreadIdleInput; +pub use thread_lifecycle::ThreadIdleRequest; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; @@ -58,6 +57,15 @@ pub trait ThreadLifecycleContributor: Send + Sync { /// Called after the host constructs a runtime from persisted history. async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + /// Called after the host has drained immediately pending thread work. + /// + /// Implementations may use host capabilities captured by the extension to + /// submit follow-up input. The host remains responsible for deciding + /// whether that input starts a turn, is queued, or is ignored. + async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) -> Option { + None + } + /// Called before the host drops the thread runtime and thread-scoped store. async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} } @@ -149,26 +157,6 @@ pub struct ToolContributionInput<'a> { pub persistent_thread: bool, } -/// Context supplied when the host is idle and extensions may request work. -pub struct IdleTurnInput<'a> { - /// Effective collaboration mode for the next default turn. - pub collaboration_mode: &'a CollaborationMode, - /// Store scoped to the host session runtime. - pub session_store: &'a ExtensionData, - /// Store scoped to this thread runtime. - pub thread_store: &'a ExtensionData, -} - -pub type IdleTurnFuture<'a> = - Pin>> + Send + 'a>>; - -/// Extension contribution that can request host-owned work while a thread is idle. -pub trait IdleTurnContributor: Send + Sync { - fn next_idle_turn<'a>(&'a self, _input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> { - Box::pin(std::future::ready(None)) - } -} - /// Contributor for host-owned tool lifecycle gates. /// /// Implementations should use these callbacks to observe tool execution without diff --git a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs index d50f5a1e2f..03ad128101 100644 --- a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs @@ -1,3 +1,6 @@ +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::models::ResponseInputItem; + use crate::ExtensionData; /// Input supplied when the host starts a runtime for a thread. @@ -18,6 +21,21 @@ pub struct ThreadResumeInput<'a> { pub thread_store: &'a ExtensionData, } +/// Input supplied when the host has no immediately pending thread work. +pub struct ThreadIdleInput<'a> { + /// Effective collaboration mode for the next default turn. + pub collaboration_mode: &'a CollaborationMode, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} + +/// Extension request to start a new idle turn. +pub struct ThreadIdleRequest { + pub items: Vec, +} + /// Input supplied when the host stops a thread runtime. pub struct ThreadStopInput<'a> { /// Store scoped to the host session runtime. diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index f92aca46b6..225a559d4c 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -25,11 +25,10 @@ pub use codex_tools::parse_tool_input_schema_without_compaction; pub use contributors::ApprovalReviewContributor; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; -pub use contributors::IdleTurnContributor; -pub use contributors::IdleTurnFuture; -pub use contributors::IdleTurnInput; pub use contributors::PromptFragment; pub use contributors::PromptSlot; +pub use contributors::ThreadIdleInput; +pub use contributors::ThreadIdleRequest; pub use contributors::ThreadLifecycleContributor; pub use contributors::ThreadResumeInput; pub use contributors::ThreadStartInput; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 1dd2f9e620..a359726257 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -7,7 +7,6 @@ use crate::ConfigContributor; use crate::ContextContributor; use crate::ExtensionData; use crate::ExtensionEventSink; -use crate::IdleTurnContributor; use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; @@ -27,7 +26,6 @@ pub struct ExtensionRegistryBuilder { tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, - idle_turn_contributors: Vec>, approval_review_contributors: Vec>, } @@ -44,7 +42,6 @@ impl Default for ExtensionRegistryBuilder { tool_contributors: Vec::new(), tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), - idle_turn_contributors: Vec::new(), } } } @@ -116,11 +113,6 @@ impl ExtensionRegistryBuilder { self.turn_item_contributors.push(contributor); } - /// Registers one idle-turn contributor. - pub fn idle_turn_contributor(&mut self, contributor: Arc) { - self.idle_turn_contributors.push(contributor); - } - /// Finishes construction and returns the immutable registry. pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { @@ -134,7 +126,6 @@ impl ExtensionRegistryBuilder { tool_contributors: self.tool_contributors, tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, - idle_turn_contributors: self.idle_turn_contributors, } } } @@ -150,7 +141,6 @@ pub struct ExtensionRegistry { tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, - idle_turn_contributors: Vec>, approval_review_contributors: Vec>, } @@ -219,11 +209,6 @@ impl ExtensionRegistry { pub fn turn_item_contributors(&self) -> &[Arc] { &self.turn_item_contributors } - - /// Returns the registered idle-turn contributors. - pub fn idle_turn_contributors(&self) -> &[Arc] { - &self.idle_turn_contributors - } } /// Creates an empty shared registry for hosts that do not register contributions. diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index 2a679837cc..e0a84f5f8b 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -83,6 +83,17 @@ impl GoalAccountingState { self.inner().current_turn_id.clone() } + pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool { + let inner = self.inner(); + if inner.current_turn_id.as_deref() != Some(turn_id) { + return false; + } + let Some(turn) = inner.turns.get(turn_id) else { + return false; + }; + turn.account_tokens && turn.active_goal_id.is_some() + } + pub(crate) fn record_token_usage( &self, turn_id: impl Into, diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 4eb09fcee1..a007191f04 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -7,9 +7,8 @@ use codex_extension_api::ConfigContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::IdleTurnContributor; -use codex_extension_api::IdleTurnFuture; -use codex_extension_api::IdleTurnInput; +use codex_extension_api::ThreadIdleInput; +use codex_extension_api::ThreadIdleRequest; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadResumeInput; use codex_extension_api::ThreadStartInput; @@ -128,6 +127,21 @@ where ); } } + + async fn on_thread_idle(&self, input: ThreadIdleInput<'_>) -> Option { + let runtime = goal_runtime_handle(input.thread_store)?; + match runtime + .idle_continuation_items(input.collaboration_mode.mode) + .await + { + Ok(Some(items)) => Some(ThreadIdleRequest { items }), + Ok(None) => None, + Err(err) => { + tracing::warn!("failed to request idle goal continuation: {err}"); + None + } + } + } } impl ConfigContributor for GoalExtension @@ -417,28 +431,6 @@ where } } -impl IdleTurnContributor for GoalExtension -where - C: Send + Sync + 'static, -{ - fn next_idle_turn<'a>(&'a self, input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> { - Box::pin(async move { - let runtime = goal_runtime_handle(input.thread_store)?; - match runtime - .idle_continuation_items(input.collaboration_mode.mode) - .await - { - Ok(Some(items)) => Some(items), - Ok(None) => None, - Err(err) => { - tracing::warn!("failed to request idle goal continuation: {err}"); - None - } - } - }) - } -} - pub fn install_with_backend( registry: &mut ExtensionRegistryBuilder, state_dbs: Arc, @@ -460,8 +452,7 @@ pub fn install_with_backend( registry.turn_lifecycle_contributor(extension.clone()); registry.token_usage_contributor(extension.clone()); registry.tool_lifecycle_contributor(extension.clone()); - registry.tool_contributor(extension.clone()); - registry.idle_turn_contributor(extension); + registry.tool_contributor(extension); } fn goal_runtime_handle(thread_store: &ExtensionData) -> Option> { diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 12a347d771..8609c2d7c2 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -177,6 +177,54 @@ impl GoalRuntimeHandle { Ok(()) } + pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + if !self.is_enabled() { + return Ok(()); + } + + if !self + .inner + .accounting_state + .turn_is_current_active_goal(turn_id) + { + return Ok(()); + } + + let progress_event_id = format!("{turn_id}:usage-limit-progress"); + self.account_active_goal_progress( + turn_id, + progress_event_id.as_str(), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await?; + + let previous_status = self + .current_goal_status_for_metrics(/*expected_goal_id*/ None) + .await?; + let Some(goal) = self + .inner + .state_dbs + .thread_goals() + .usage_limit_active_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + return Ok(()); + }; + self.inner + .metrics + .record_terminal_if_status_changed(previous_status, &goal); + self.inner.accounting_state.clear_active_goal(); + let goal = protocol_goal_from_state(goal); + self.inner.event_emitter.thread_goal_updated( + format!("{turn_id}:usage-limit"), + Some(turn_id.to_string()), + goal, + ); + Ok(()) + } + pub async fn restore_after_resume(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); @@ -201,45 +249,6 @@ impl GoalRuntimeHandle { Ok(()) } - pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { - if !self.is_enabled() { - return Ok(()); - } - - self.account_active_goal_progress( - turn_id, - &format!("{turn_id}:usage-limit"), - codex_state::GoalAccountingMode::ActiveOnly, - BudgetLimitedGoalDisposition::ClearActive, - ) - .await?; - let previous_status = self - .current_goal_status_for_metrics(/*expected_goal_id*/ None) - .await?; - let Some(goal) = self - .inner - .state_dbs - .thread_goals() - .usage_limit_active_thread_goal(self.thread_id()) - .await - .map_err(|err| err.to_string())? - else { - self.inner.accounting_state.finish_turn(turn_id); - return Ok(()); - }; - self.inner - .metrics - .record_terminal_if_status_changed(previous_status, &goal); - self.inner.accounting_state.finish_turn(turn_id); - let goal = protocol_goal_from_state(goal); - self.inner.event_emitter.thread_goal_updated( - format!("{turn_id}:usage-limit"), - Some(turn_id.to_string()), - goal, - ); - Ok(()) - } - pub async fn idle_continuation_items( &self, collaboration_mode: ModeKind, diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 1dfc8e3c41..ee5abe3ca7 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -8,7 +8,7 @@ use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::FunctionCallError; -use codex_extension_api::IdleTurnInput; +use codex_extension_api::ThreadIdleInput; use codex_extension_api::ThreadResumeInput; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; @@ -409,7 +409,7 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re assert_eq!( vec![ CapturedGoalEvent { - event_id: "turn-1:usage-limit".to_string(), + event_id: "turn-1:usage-limit-progress".to_string(), turn_id: Some("turn-1".to_string()), status: ThreadGoalStatus::Active, tokens_used: 23, @@ -423,11 +423,34 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re ], harness.sink.goal_events() ); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 50, /*cached_input_tokens*/ 5, + /*output_tokens*/ 20, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 70, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell-after-usage-limit", "shell") + .await; + harness.stop_turn("turn-1").await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); Ok(()) } #[tokio::test] -async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Result<()> { +async fn thread_idle_hook_requests_hidden_goal_continuation() -> anyhow::Result<()> { let runtime = test_runtime().await?; let thread_id = test_thread_id()?; seed_thread_metadata(runtime.as_ref(), thread_id).await?; @@ -462,6 +485,159 @@ async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ + "objective": "ship goal extension backend", + "token_budget": 25, + }), + )) + .await?; + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, + /*output_tokens*/ 10, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 30, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell", "shell") + .await; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 24, /*cached_input_tokens*/ 5, + /*output_tokens*/ 16, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 40, + ), + ) + .await; + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(35, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "turn-1:usage-limit-progress".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::BudgetLimited, + tokens_used: 35, + }, + CapturedGoalEvent { + event_id: "turn-1:usage-limit".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::UsageLimited, + tokens_used: 35, + }, + ], + harness.sink.goal_events() + ); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default()) + .await; + harness.sink.clear(); + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-plan") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.stop_turn("turn-1").await; + harness.start_turn("turn-2", &TokenUsage::default()).await; + harness.sink.clear(); + + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + #[tokio::test] async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -815,8 +991,14 @@ impl GoalExtensionHarness { } async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) { + self.start_turn_with_mode(turn_id, ModeKind::Default, usage) + .await; + } + + async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) { let turn_store = ExtensionData::new(turn_id); - let collaboration_mode = default_collaboration_mode(); + let mut collaboration_mode = default_collaboration_mode(); + collaboration_mode.mode = mode; for contributor in self.registry.turn_lifecycle_contributors() { contributor .on_turn_start(TurnStartInput { @@ -911,16 +1093,16 @@ impl GoalExtensionHarness { async fn next_idle_turn(&self, mode: ModeKind) -> Option> { let mut collaboration_mode = default_collaboration_mode(); collaboration_mode.mode = mode; - for contributor in self.registry.idle_turn_contributors() { + for contributor in self.registry.thread_lifecycle_contributors() { if let Some(request) = contributor - .next_idle_turn(IdleTurnInput { + .on_thread_idle(ThreadIdleInput { collaboration_mode: &collaboration_mode, session_store: &self.session_store, thread_store: &self.thread_store, }) .await { - return Some(request); + return Some(request.items); } } None diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index e5b497d355..7881845e45 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -7,11 +7,10 @@ use super::auth::BedrockAuthMethod; use super::auth::resolve_auth_method; const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; -const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [ +const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ "us-east-2", "us-east-1", "us-west-2", - "us-gov-west-1", "ap-southeast-3", "ap-south-1", "ap-northeast-1", @@ -73,10 +72,6 @@ mod tests { base_url("ap-northeast-1").expect("supported region"), "https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1" ); - assert_eq!( - base_url("us-gov-west-1").expect("supported region"), - "https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1" - ); } #[test] diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 4b8b5f8920..b3e1edb901 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -400,16 +400,16 @@ impl App { } pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { - let config = self.config.clone(); + let cwd = self.config.cwd.to_path_buf(); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); - if !config.features.enabled(Feature::Plugins) { + if !self.config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); return; } tokio::spawn(async move { - match fetch_plugin_mentions(request_handle, config.cwd.to_path_buf()).await { + match fetch_plugin_mentions(request_handle, cwd).await { Ok(plugins) => { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: Some(plugins), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 18ded9bbbc..5af2c49233 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -506,9 +506,6 @@ impl App { self.chat_widget .on_marketplace_add_loaded(cwd.clone(), source, result); if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace add"); - } self.fetch_plugins_list(app_server, cwd); } } @@ -516,14 +513,7 @@ impl App { let marketplace_contents_changed = matches!(&result, Ok(response) if !response.upgraded_roots.is_empty()); if marketplace_contents_changed { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after marketplace upgrade" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_marketplace_upgrade_loaded(cwd.clone(), result); @@ -558,11 +548,7 @@ impl App { ); if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace remove"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); self.fetch_plugins_list(app_server, cwd); } } @@ -609,11 +595,7 @@ impl App { } => { let install_succeeded = result.is_ok(); if install_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after plugin install"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), @@ -665,14 +647,7 @@ impl App { self.pending_plugin_enabled_writes.remove(&plugin_id); let update_succeeded = result.is_ok(); if update_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin toggle" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_plugin_enabled_set(cwd, plugin_id, enabled, result); @@ -1303,14 +1278,7 @@ impl App { } => { let uninstall_succeeded = result.is_ok(); if uninstall_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin uninstall" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget.on_plugin_uninstall_loaded( cwd.clone(), @@ -1705,14 +1673,6 @@ impl App { { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!( - error = %err, - "failed to refresh config after skill toggle" - ); - } } Err(err) => { let path_display = path.display(); @@ -1749,11 +1709,6 @@ impl App { { Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!(error = %err, "failed to refresh config after app toggle"); - } } Err(err) => { self.chat_widget.add_error_message(format!( @@ -2098,6 +2053,11 @@ impl App { } } + fn refresh_plugin_mentions_after_config_write(&mut self) { + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + async fn apply_keymap_clear(&mut self, context: String, action: String) { let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( &self.config.tui_keymap, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 8f15aed79a..a403dc232c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -684,7 +684,6 @@ impl App { } AppCommand::ReloadUserConfig => { app_server.reload_user_config().await?; - self.refresh_in_memory_config_from_disk().await?; Ok(true) } AppCommand::OverrideTurnContext { .. } => { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap new file mode 100644 index 0000000000..0a41c2d80f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "states.join(\"\\n\\n\")" +--- +alpha beta gamma + ^ + +alpha beta gamma + ^ + +alpha beta gamma + ^ diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 96f6cf8cbb..d931b14b2c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,7 +755,12 @@ impl TextArea { return; } if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { - self.kill_to_end_of_line(); + self.vim_kill_to_end_of_line(); + return; + } + if self.vim_normal_keymap.change_to_line_end.is_pressed(event) { + self.vim_kill_to_end_of_line(); + self.vim_mode = VimMode::Insert; return; } if self.vim_normal_keymap.yank_line.is_pressed(event) { @@ -914,7 +919,7 @@ impl TextArea { VimMotion::Down => self.move_cursor_down(), VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), - VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()), VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), } @@ -1007,6 +1012,13 @@ impl TextArea { } } + fn vim_kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + if self.cursor_pos < eol { + self.kill_range(self.cursor_pos..eol); + } + } + pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); let range = if self.cursor_pos == bol { @@ -1734,7 +1746,11 @@ impl TextArea { } pub(crate) fn end_of_next_word(&self) -> usize { - let suffix = &self.text[self.cursor_pos..]; + self.end_of_next_word_from(self.cursor_pos) + } + + fn end_of_next_word_from(&self, cursor_pos: usize) -> usize { + let suffix = &self.text[cursor_pos..]; let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else { return self.text.len(); }; @@ -1742,16 +1758,16 @@ impl TextArea { let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())]; let mut pieces = split_word_pieces(run).into_iter().peekable(); let Some((start, piece)) = pieces.next() else { - return self.cursor_pos + first_non_ws; + return cursor_pos + first_non_ws; }; - let word_start = self.cursor_pos + first_non_ws + start; + let word_start = cursor_pos + first_non_ws + start; let mut end = word_start + piece.len(); if piece.chars().all(is_word_separator) { while let Some((idx, piece)) = pieces.peek() { if !piece.chars().all(is_word_separator) { break; } - end = self.cursor_pos + first_non_ws + *idx + piece.len(); + end = cursor_pos + first_non_ws + *idx + piece.len(); pieces.next(); } } @@ -1759,8 +1775,22 @@ impl TextArea { self.adjust_pos_out_of_elements(end, /*prefer_start*/ false) } - fn vim_word_end_cursor(&self) -> usize { + fn vim_word_end_exclusive(&self) -> usize { let end = self.end_of_next_word(); + let target = if end > self.cursor_pos { + self.prev_atomic_boundary(end) + } else { + end + }; + if target == self.cursor_pos && end < self.text.len() { + self.end_of_next_word_from(end) + } else { + end + } + } + + fn vim_word_end_cursor(&self) -> usize { + let end = self.vim_word_end_exclusive(); if end > self.cursor_pos { self.prev_atomic_boundary(end) } else { @@ -2275,6 +2305,60 @@ mod tests { assert_eq!(t.cursor(), 11); } + #[test] + fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + assert_eq!(t.kill_buffer, "world"); + } + + #[test] + fn vim_uppercase_c_changes_to_line_end() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn vim_d_at_line_end_does_not_remove_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_c_at_line_end_enters_insert_without_removing_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "hello".len()); + assert_eq!(t.kill_buffer, ""); + } + #[test] fn vim_shift_o_opens_line_above_with_shift_only_binding() { let mut t = ta_with("hello\nworld"); @@ -2348,6 +2432,62 @@ mod tests { assert_eq!(t.kill_buffer, "c"); } + #[test] + fn vim_e_advances_from_each_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + let mut states = Vec::new(); + + for _ in 0..3 { + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor()))); + } + + insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n")); + } + + #[test] + fn vim_delete_to_word_end_advances_from_existing_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph + assert_eq!(t.kill_buffer, "a beta"); + } + + #[test] + fn vim_e_from_word_end_can_land_on_trailing_space() { + let mut t = ta_with("alpha "); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), "alpha ".len()); + } + + #[test] + fn vim_e_advances_across_atomic_element_word_ends() { + let mut t = TextArea::new(); + t.insert_str("alpha "); + t.insert_element(""); + t.insert_str(" gamma"); + let element_start = t.elements[0].range.start; + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), element_start); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), "alpha gamm".len()); + } + #[test] fn vim_dollar_lands_on_line_end_character() { let mut t = ta_with("abc\n123"); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 533e28f3a2..bc54224c40 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -156,6 +156,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, + pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, @@ -469,6 +470,7 @@ impl RuntimeKeymap { move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), + change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), start_delete_operator: resolve_local!( @@ -786,6 +788,10 @@ impl RuntimeKeymap { shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) ], + change_to_line_end: default_bindings![ + shift(KeyCode::Char('c')), + plain(KeyCode::Char('C')) + ], yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], @@ -1176,6 +1182,10 @@ impl RuntimeKeymap { "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), ), + ( + "change_to_line_end", + self.vim_normal.change_to_line_end.as_slice(), + ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 9ce827b8a9..fb3ecd9f2c 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), + action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."), action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."), @@ -263,6 +264,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), + ("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end), ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), @@ -373,6 +375,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), + ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 898108c755..bc41e50b24 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 93 actions, 1 customized, 2 unbound. + 94 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 4d9440cb4e..e0c1aa7ffe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 94 actions, 0 customized, 3 unbound. + 95 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 58b1b80803..856ac05d1e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (93 selectable) +tab: All (94 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (34 selectable) +tab: Vim (35 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 6a98da9238..57b0131002 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 93 actions, 0 customized, 2 unbound. + 94 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 9d651ee4f0..4d10013649 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 93 actions, 0 customized, 2 unbound. + 94 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 8ebc4fbb82..6973d482e2 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -1,11 +1,18 @@ +[CmdletBinding()] param( - [string]$Release = "latest" + [string]$Release = $env:CODEX_RELEASE ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" +if ([string]::IsNullOrWhiteSpace($Release)) { + $Release = "latest" +} + +$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$" + function Write-Step { param( [string]$Message @@ -27,6 +34,10 @@ function Prompt-YesNo { [string]$Prompt ) + if ($NonInteractive) { + return $false + } + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { return $false } @@ -55,6 +66,16 @@ function Normalize-Version { return $RawVersion } +function Assert-ValidReleaseVersion { + param( + [string]$Version + ) + + if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") { + throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." + } +} + function Find-ReleaseAssetMetadata { param( [string]$AssetName, @@ -141,6 +162,22 @@ function Path-Contains { return $false } +function Prepend-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + $needle = $Entry.TrimEnd("\") + $segments = @($Entry) + if (-not [string]::IsNullOrWhiteSpace($PathValue)) { + $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) | + Where-Object { $_.TrimEnd("\") -ine $needle } + } + + return ($segments -join ";") +} + function Invoke-WithInstallLock { param( [string]$LockPath, @@ -181,6 +218,7 @@ function Remove-StaleInstallArtifacts { function Resolve-Version { $normalizedVersion = Normalize-Version -RawVersion $Release + Assert-ValidReleaseVersion -Version $normalizedVersion if ($normalizedVersion -ne "latest") { return $normalizedVersion } @@ -191,7 +229,9 @@ function Resolve-Version { exit 1 } - return (Normalize-Version -RawVersion $release.tag_name) + $resolvedVersion = Normalize-Version -RawVersion $release.tag_name + Assert-ValidReleaseVersion -Version $resolvedVersion + return $resolvedVersion } function Get-VersionFromBinary { @@ -839,7 +879,16 @@ try { Maybe-HandleConflictingInstall -Conflict $conflictingInstall $userPath = [Environment]::GetEnvironmentVariable("Path", "User") -if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { +$prioritizeVisibleBin = $null -ne $conflictingInstall +if ($prioritizeVisibleBin) { + $newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir + if ($newUserPath -cne $userPath) { + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Step "PATH updated for future PowerShell sessions." + } else { + Write-Step "$visibleBinDir is already first on PATH." + } +} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { $newUserPath = $visibleBinDir } else { @@ -854,7 +903,9 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { Write-Step "PATH is already configured for future PowerShell sessions." } -if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { +if ($prioritizeVisibleBin) { + $env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir +} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($env:Path)) { $env:Path = $visibleBinDir } else { diff --git a/scripts/install/install.sh b/scripts/install/install.sh index cc4fc91345..23980b0c5e 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -2,7 +2,8 @@ set -eu -RELEASE="latest" +RELEASE="${CODEX_RELEASE:-latest}" +NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}" BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" BIN_PATH="$BIN_DIR/codex" @@ -46,6 +47,19 @@ normalize_version() { esac } +validate_version() { + version="$1" + + if [ "$version" = "latest" ]; then + return + fi + + if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then + echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2 + exit 1 + fi +} + parse_args() { while [ "$#" -gt 0 ]; do case "$1" in @@ -60,6 +74,10 @@ parse_args() { --help | -h) cat </dev/null; then printf '%s [y/N] ' "$prompt" >/dev/tty if ! IFS= read -r answer