Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Schoepke
31a9c54dba initial vibe code 2026-03-03 10:44:24 -08:00
Dylan Hurd
e10df4ba10 fix(core) shell_snapshot multiline exports (#12642)
## Summary
Codex discovered this one - shell_snapshot tests were breaking on my
machine because I had a multiline env var. We should handle these!

## Testing
- [x] existing tests pass
- [x] Updated unit tests
2026-03-02 12:08:17 -07:00
jif-oai
f8838fd6f3 feat: enable ma through /agent (#13246)
<img width="639" height="139" alt="Screenshot 2026-03-02 at 16 06 41"
src="https://github.com/user-attachments/assets/c006fcec-c1e7-41ce-bb84-c121d5ffb501"
/>

Then
<img width="372" height="37" alt="Screenshot 2026-03-02 at 16 06 49"
src="https://github.com/user-attachments/assets/aa4ad703-e7e7-4620-9032-f5cd4f48ff79"
/>
2026-03-02 18:37:29 +00:00
Charley Cunningham
7979ce453a tui: restore draft footer hints (#13202)
## Summary
- restore `Tab to queue` when a draft is present and the agent is
running
- keep draft-idle footers passive by showing the normal footer or status
line instead of `? for shortcuts`
- align footer snapshot coverage with the updated draft footer behavior

## Codex author
`codex resume 019c7f1c-43aa-73e0-97c7-40f457396bb0`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 10:26:13 -08:00
Eric Traut
7709bf32a3 Fix project trust config parsing so CLI overrides work (#13090)
Fixes #13076

This PR fixes a bug that causes command-line config overrides for MCP
subtables to not be merged correctly.

Summary
- make project trust loading go through the dedicated struct so CLI
overrides can update trusted project-local MCP transports

---------

Co-authored-by: jif-oai <jif@openai.com>
2026-03-02 11:10:38 -07:00
Michael Bolin
3241c1c6cc fix: use https://git.savannah.gnu.org/git/bash instead of https://github.com/bolinfest/bash (#13057)
Historically, we cloned the Bash repo from
https://github.com/bminor/bash, but for whatever reason, it was removed
at some point.

I had a local clone of it, so I pushed it to
https://github.com/bolinfest/bash so that we could continue running our
CI job. I did this in https://github.com/openai/codex/pull/9563, and as
you can see, I did not tamper with the commit hash we used as the basis
of this build.

Using a personal fork is not great, so this PR changes the CI job to use
what appears to be considered the source of truth for Bash, which is
https://git.savannah.gnu.org/git/bash.git.

Though in testing this out, it appears this Git server does not support
the combination of `git clone --depth 1
https://git.savannah.gnu.org/git/bash` and `git fetch --depth 1 origin
a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b`, as it fails with the
following error:

```
error: Server does not allow request for unadvertised object a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
```

so unfortunately this means that we have to do a full clone instead of a
shallow clone in our CI jobs, which will be a bit slower.

Also updated `codex-rs/shell-escalation/README.md` to reflect this
change.
2026-03-02 09:09:54 -08:00
31 changed files with 3421 additions and 65 deletions

View File

@@ -146,9 +146,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
@@ -188,9 +187,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc

View File

@@ -379,6 +379,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"ps_repl": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
@@ -502,6 +505,14 @@
"plan_mode_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
},
"ps_repl_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
},
"sandbox_mode": {
"$ref": "#/definitions/SandboxMode"
},
@@ -1750,6 +1761,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"ps_repl": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
@@ -2088,6 +2102,14 @@
},
"type": "object"
},
"ps_repl_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
},
"review_model": {
"description": "Review model override used by the `/review` feature.",
"type": "string"

View File

@@ -88,6 +88,7 @@ pub(crate) async fn apply_role_to_config(
cwd: Some(config.cwd.clone()),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
ps_repl_path: config.ps_repl_path.clone(),
js_repl_node_path: config.js_repl_node_path.clone(),
..Default::default()
},

View File

@@ -277,6 +277,8 @@ use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::ps_repl::PsReplHandle;
use crate::tools::ps_repl::resolve_compatible_pwsh;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
@@ -378,6 +380,16 @@ impl Codex {
config.features.disable(Feature::JsReplToolsOnly);
config.startup_warnings.push(message);
}
if config.features.enabled(Feature::PsRepl)
&& let Err(err) = resolve_compatible_pwsh(config.ps_repl_path.as_deref()).await
{
let message = format!(
"Disabled `ps_repl` for this session because the configured PowerShell runtime is unavailable or incompatible. {err}"
);
warn!("{message}");
config.features.disable(Feature::PsRepl);
config.startup_warnings.push(message);
}
let allowed_skills_for_implicit_invocation =
loaded_skills.allowed_skills_for_implicit_invocation();
@@ -608,6 +620,7 @@ pub(crate) struct Session {
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) services: SessionServices,
ps_repl: Arc<PsReplHandle>,
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
}
@@ -663,6 +676,7 @@ pub(crate) struct TurnContext {
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) ps_repl: Arc<PsReplHandle>,
pub(crate) js_repl: Arc<JsReplHandle>,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
@@ -751,6 +765,7 @@ impl TurnContext {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy,
ps_repl: Arc::clone(&self.ps_repl),
js_repl: Arc::clone(&self.js_repl),
dynamic_tools: self.dynamic_tools.clone(),
turn_metadata_state: self.turn_metadata_state.clone(),
@@ -1056,6 +1071,7 @@ impl Session {
model_info: ModelInfo,
network: Option<NetworkProxy>,
sub_id: String,
ps_repl: Arc<PsReplHandle>,
js_repl: Arc<JsReplHandle>,
skills_outcome: Arc<SkillLoadOutcome>,
) -> TurnContext {
@@ -1125,6 +1141,7 @@ impl Session {
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: model_info.truncation_policy.into(),
ps_repl,
js_repl,
dynamic_tools: session_configuration.dynamic_tools.clone(),
turn_metadata_state,
@@ -1519,6 +1536,7 @@ impl Session {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let sess = Arc::new(Session {
conversation_id,
@@ -1530,6 +1548,7 @@ impl Session {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});
@@ -2167,6 +2186,7 @@ impl Session {
.as_ref()
.map(StartedNetworkProxy::proxy),
sub_id,
Arc::clone(&self.ps_repl),
Arc::clone(&self.js_repl),
skills_outcome,
);
@@ -4695,6 +4715,7 @@ async fn spawn_review_thread(
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
ps_repl: Arc::clone(&sess.ps_repl),
js_repl: Arc::clone(&sess.js_repl),
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
truncation_policy: model_info.truncation_policy.into(),
@@ -8334,6 +8355,7 @@ mod tests {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Session::make_turn_context(
@@ -8345,6 +8367,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&ps_repl),
Arc::clone(&js_repl),
skills_outcome,
);
@@ -8359,6 +8382,7 @@ mod tests {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
};
@@ -8502,6 +8526,7 @@ mod tests {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Arc::new(Session::make_turn_context(
@@ -8513,6 +8538,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&ps_repl),
Arc::clone(&js_repl),
skills_outcome,
));
@@ -8527,6 +8553,7 @@ mod tests {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});

View File

@@ -394,6 +394,9 @@ pub struct Config {
/// code via [`ConfigOverrides`].
pub main_execve_wrapper_exe: Option<PathBuf>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<PathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
@@ -1121,6 +1124,9 @@ pub struct ConfigToml {
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: Option<u64>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<AbsolutePathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
@@ -1553,6 +1559,7 @@ pub struct ConfigOverrides {
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub main_execve_wrapper_exe: Option<PathBuf>,
pub ps_repl_path: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
pub zsh_path: Option<PathBuf>,
@@ -1683,6 +1690,7 @@ impl Config {
config_profile: config_profile_key,
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
ps_repl_path: ps_repl_path_override,
js_repl_node_path: js_repl_node_path_override,
js_repl_node_module_dirs: js_repl_node_module_dirs_override,
zsh_path: zsh_path_override,
@@ -1981,6 +1989,9 @@ impl Config {
"experimental compact prompt file",
)?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
let ps_repl_path = ps_repl_path_override
.or(config_profile.ps_repl_path.map(Into::into))
.or(cfg.ps_repl_path.map(Into::into));
let js_repl_node_path = js_repl_node_path_override
.or(config_profile.js_repl_node_path.map(Into::into))
.or(cfg.js_repl_node_path.map(Into::into));
@@ -2150,6 +2161,7 @@ impl Config {
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
ps_repl_path,
js_repl_node_path,
js_repl_node_module_dirs,
zsh_path,
@@ -4909,6 +4921,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5037,6 +5050,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5163,6 +5177,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5275,6 +5290,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,

View File

@@ -33,6 +33,8 @@ pub struct ConfigProfile {
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,

View File

@@ -6,7 +6,6 @@ mod macos;
mod tests;
use crate::config::ConfigToml;
use crate::config::deserialize_config_toml_with_base;
use crate::config_loader::layer_io::LoadedConfigLayers;
use crate::git_info::resolve_root_git_project_for_trust;
use codex_app_server_protocol::ConfigLayerSource;
@@ -576,6 +575,11 @@ struct ProjectTrustContext {
user_config_file: AbsolutePathBuf,
}
#[derive(Deserialize)]
struct ProjectTrustConfigToml {
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
}
struct ProjectTrustDecision {
trust_level: Option<TrustLevel>,
trust_key: String,
@@ -666,10 +670,16 @@ async fn project_trust_context(
config_base_dir: &Path,
user_config_file: &AbsolutePathBuf,
) -> io::Result<ProjectTrustContext> {
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
let project_trust_config: ProjectTrustConfigToml = {
let _guard = AbsolutePathBufGuard::new(config_base_dir);
merged_config
.clone()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
};
let project_root = find_project_root(cwd, project_root_markers).await?;
let projects = config_toml.projects.unwrap_or_default();
let projects = project_trust_config.projects.unwrap_or_default();
let project_root_key = project_root.as_path().to_string_lossy().to_string();
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());

View File

@@ -1114,6 +1114,91 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted()
-> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
[mcp_servers.sentry]
url = "https://mcp.sentry.dev/mcp"
enabled = false
"#,
)
.await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.cli_overrides(vec![(
"mcp_servers.sentry.enabled".to_string(),
TomlValue::Boolean(true),
)])
.fallback_cwd(Some(nested))
.build()
.await?;
let server = config
.mcp_servers
.get()
.get("sentry")
.expect("trusted project MCP server should load");
assert!(server.enabled);
Ok(())
}
#[tokio::test]
async fn cli_override_for_disabled_project_local_mcp_server_returns_invalid_transport()
-> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
[mcp_servers.sentry]
url = "https://mcp.sentry.dev/mcp"
enabled = false
"#,
)
.await?;
let err = ConfigBuilder::default()
.codex_home(codex_home)
.cli_overrides(vec![(
"mcp_servers.sentry.enabled".to_string(),
TomlValue::Boolean(true),
)])
.fallback_cwd(Some(nested))
.build()
.await
.expect_err("untrusted project layer should not provide MCP transport");
assert!(
err.to_string().contains("invalid transport")
&& err.to_string().contains("mcp_servers.sentry"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> {
let tmp = tempdir()?;

View File

@@ -78,6 +78,8 @@ pub enum Feature {
ShellTool,
// Experimental
/// Enable PowerShell REPL tools backed by a persistent pwsh kernel.
PsRepl,
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
JsRepl,
/// Only expose js_repl tools directly to the model.
@@ -460,6 +462,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PsRepl,
key: "ps_repl",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::JsRepl,
key: "js_repl",
@@ -825,6 +833,12 @@ mod tests {
assert_eq!(Feature::JsRepl.default_enabled(), false);
}
#[test]
fn ps_repl_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::PsRepl.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::PsRepl.default_enabled(), false);
}
#[test]
fn collab_is_legacy_alias_for_multi_agent() {
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));

View File

@@ -69,6 +69,25 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
Some(section)
}
fn render_ps_repl_instructions(config: &Config) -> Option<String> {
if !config.features.enabled(Feature::PsRepl) {
return None;
}
let mut section = String::from("## PowerShell REPL (pwsh)\n");
section.push_str(
"- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n",
);
section.push_str("- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
section.push_str("- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n");
section.push_str("- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n");
section.push_str("- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n");
section.push_str("- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n");
section.push_str("- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.");
Some(section)
}
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(
@@ -103,6 +122,13 @@ pub(crate) async fn get_user_instructions(
output.push_str(&js_repl_section);
}
if let Some(ps_repl_section) = render_ps_repl_instructions(config) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&ps_repl_section);
}
let skills_section = skills.and_then(render_skills_section);
if let Some(skills_section) = skills_section {
if !output.is_empty() {
@@ -492,6 +518,19 @@ mod tests {
assert_eq!(res, expected);
}
#[tokio::test]
async fn ps_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
cfg.features.enable(Feature::PsRepl);
let res = get_user_instructions(&cfg, None)
.await
.expect("ps_repl instructions expected");
let expected = "## PowerShell REPL (pwsh)\n- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.";
assert_eq!(res, expected);
}
/// When both system instructions *and* a project doc are present the two
/// should be concatenated with the separator.
#[tokio::test]

View File

@@ -360,19 +360,17 @@ alias_count=$(alias -p | wc -l | tr -d ' ')
echo "# aliases $alias_count"
alias -p
echo ''
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
line=$0
name=line
sub(/^(export|declare -x|typeset -x) /, "", name)
sub(/=.*/, "", name)
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
next
}
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
print line
}
}')
export_lines=$(
while IFS= read -r name; do
if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then
continue
fi
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
continue
fi
declare -xp "$name" 2>/dev/null || true
done < <(compgen -e)
)
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# exports $export_count"
if [ -n "$export_lines" ]; then
@@ -671,6 +669,46 @@ mod tests {
Ok(())
}
#[cfg(unix)]
#[test]
fn bash_snapshot_preserves_multiline_exports() -> Result<()> {
let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----";
let output = Command::new("/bin/bash")
.arg("-c")
.arg(bash_snapshot_script())
.env("BASH_ENV", "/dev/null")
.env("MULTILINE_CERT", multiline_cert)
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"),
"snapshot should include the multiline export name"
);
let dir = tempdir()?;
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(&snapshot_path, stdout.as_bytes())?;
let validate = Command::new("/bin/bash")
.arg("-c")
.arg("set -e; . \"$1\"")
.arg("bash")
.arg(&snapshot_path)
.env("BASH_ENV", "/dev/null")
.output()?;
assert!(
validate.status.success(),
"snapshot validation failed: {}",
String::from_utf8_lossy(&validate.stderr)
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {

View File

@@ -20,6 +20,7 @@ pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
pub enum ToolCallSource {
Direct,
JsRepl,
PsRepl,
}
#[derive(Clone)]

View File

@@ -8,6 +8,7 @@ mod mcp;
mod mcp_resource;
pub(crate) mod multi_agents;
mod plan;
mod ps_repl;
mod read_file;
mod request_user_input;
mod search_tool_bm25;
@@ -38,6 +39,8 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use multi_agents::MultiAgentHandler;
pub use plan::PlanHandler;
pub use ps_repl::PsReplHandler;
pub use ps_repl::PsReplResetHandler;
pub use read_file::ReadFileHandler;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;

View File

@@ -0,0 +1,363 @@
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::protocol::ExecCommandSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventFailure;
use crate::tools::events::ToolEventStage;
use crate::tools::handlers::parse_arguments;
use crate::tools::ps_repl::PS_REPL_PRAGMA_PREFIX;
use crate::tools::ps_repl::PsExecResult;
use crate::tools::ps_repl::PsReplArgs;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
pub struct PsReplHandler;
pub struct PsReplResetHandler;
fn join_outputs(stdout: &str, stderr: &str) -> String {
if stdout.is_empty() {
stderr.to_string()
} else if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}\n{stderr}")
}
}
fn build_ps_repl_exec_output(
output: &str,
error: Option<&str>,
duration: Duration,
) -> ExecToolCallOutput {
let stdout = output.to_string();
let stderr = error.unwrap_or("").to_string();
let aggregated_output = join_outputs(&stdout, &stderr);
ExecToolCallOutput {
exit_code: if error.is_some() { 1 } else { 0 },
stdout: StreamOutput::new(stdout),
stderr: StreamOutput::new(stderr),
aggregated_output: StreamOutput::new(aggregated_output),
duration,
timed_out: false,
}
}
async fn emit_ps_repl_exec_begin(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
) {
let emitter = ToolEmitter::shell(
vec!["ps_repl".to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
false,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
emitter.emit(ctx, ToolEventStage::Begin).await;
}
async fn emit_ps_repl_exec_end(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
output: &str,
error: Option<&str>,
duration: Duration,
) {
let exec_output = build_ps_repl_exec_output(output, error, duration);
let emitter = ToolEmitter::shell(
vec!["ps_repl".to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
false,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
let stage = if error.is_some() {
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
} else {
ToolEventStage::Success(exec_output)
};
emitter.emit(ctx, stage).await;
}
#[async_trait]
impl ToolHandler for PsReplHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
payload,
call_id,
..
} = invocation;
if !session.features().enabled(Feature::PsRepl) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is disabled by feature flag".to_string(),
));
}
let args = match payload {
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
_ => {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects custom or function payload".to_string(),
));
}
};
let manager = turn.ps_repl.manager().await?;
let started_at = Instant::now();
emit_ps_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
let result = manager
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
.await;
let result = match result {
Ok(result) => result,
Err(err) => {
let message = err.to_string();
emit_ps_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&call_id,
"",
Some(&message),
started_at.elapsed(),
)
.await;
return Err(err);
}
};
emit_ps_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&call_id,
&result.output,
None,
started_at.elapsed(),
)
.await;
Ok(build_tool_output(result))
}
}
fn build_tool_output(result: PsExecResult) -> ToolOutput {
let PsExecResult {
output,
content_items,
} = result;
let mut items = Vec::with_capacity(content_items.len() + 1);
if !output.is_empty() {
items.push(FunctionCallOutputContentItem::InputText {
text: output.clone(),
});
}
items.extend(content_items);
ToolOutput::Function {
body: if items.is_empty() {
FunctionCallOutputBody::Text(output)
} else {
FunctionCallOutputBody::ContentItems(items)
},
success: Some(true),
}
}
#[async_trait]
impl ToolHandler for PsReplResetHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
if !invocation.session.features().enabled(Feature::PsRepl) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is disabled by feature flag".to_string(),
));
}
let manager = invocation.turn.ps_repl.manager().await?;
manager.reset().await?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text("ps_repl kernel reset".to_string()),
success: Some(true),
})
}
}
fn parse_freeform_args(input: &str) -> Result<PsReplArgs, FunctionCallError> {
if input.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects raw PowerShell tool input (non-empty). Provide PowerShell source text, optionally with first-line `# codex-ps-repl: ...`."
.to_string(),
));
}
let mut args = PsReplArgs {
code: input.to_string(),
timeout_ms: None,
};
let mut lines = input.splitn(2, '\n');
let first_line = lines.next().unwrap_or_default();
let rest = lines.next().unwrap_or_default();
let trimmed = first_line.trim_start();
let Some(pragma) = trimmed.strip_prefix(PS_REPL_PRAGMA_PREFIX) else {
reject_json_or_quoted_source(&args.code)?;
return Ok(args);
};
let mut timeout_ms: Option<u64> = None;
let directive = pragma.trim();
if !directive.is_empty() {
for token in directive.split_whitespace() {
let (key, value) = token.split_once('=').ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"ps_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
))
})?;
match key {
"timeout_ms" => {
if timeout_ms.is_some() {
return Err(FunctionCallError::RespondToModel(
"ps_repl pragma specifies timeout_ms more than once".to_string(),
));
}
let parsed = value.parse::<u64>().map_err(|_| {
FunctionCallError::RespondToModel(format!(
"ps_repl pragma timeout_ms must be an integer; got `{value}`"
))
})?;
timeout_ms = Some(parsed);
}
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"ps_repl pragma only supports timeout_ms; got `{key}`"
)));
}
}
}
}
if rest.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"ps_repl pragma must be followed by PowerShell source on subsequent lines".to_string(),
));
}
reject_json_or_quoted_source(rest)?;
args.code = rest.to_string();
args.timeout_ms = timeout_ms;
Ok(args)
}
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
let trimmed = code.trim();
if trimmed.starts_with("```") {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects raw PowerShell source, not markdown code fences. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`)."
.to_string(),
));
}
if is_quoted_source(trimmed) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
.to_string(),
));
}
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
return Ok(());
};
match value {
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
.to_string(),
)),
_ => Ok(()),
}
}
fn is_quoted_source(input: &str) -> bool {
input.len() >= 2
&& ((input.starts_with('"') && input.ends_with('"'))
|| (input.starts_with('\'') && input.ends_with('\'')))
}
#[cfg(test)]
mod tests {
use super::parse_freeform_args;
use pretty_assertions::assert_eq;
#[test]
fn parse_freeform_args_without_pragma() {
let args = parse_freeform_args("Write-Output 'ok'").expect("parse args");
assert_eq!(args.code, "Write-Output 'ok'");
assert_eq!(args.timeout_ms, None);
}
#[test]
fn parse_freeform_args_with_pragma() {
let input = "# codex-ps-repl: timeout_ms=15000\nWrite-Output 'ok'";
let args = parse_freeform_args(input).expect("parse args");
assert_eq!(args.code, "Write-Output 'ok'");
assert_eq!(args.timeout_ms, Some(15_000));
}
#[test]
fn parse_freeform_args_rejects_unknown_key() {
let err = parse_freeform_args("# codex-ps-repl: nope=1\nWrite-Output 'ok'")
.expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl pragma only supports timeout_ms; got `nope`"
);
}
#[test]
fn parse_freeform_args_rejects_json_wrapped_code() {
let err =
parse_freeform_args(r#"{"code":"Write-Output 'ok'"}"#).expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
);
}
#[test]
fn parse_freeform_args_rejects_quoted_source() {
let err = parse_freeform_args("'Write-Output ok'").expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
);
}
}

View File

@@ -5,6 +5,7 @@ pub mod js_repl;
pub(crate) mod network_approval;
pub mod orchestrator;
pub mod parallel;
pub mod ps_repl;
pub mod registry;
pub mod router;
pub mod runtimes;

View File

@@ -0,0 +1,187 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$script:CodexTmpDir = if ($env:CODEX_PS_TMP_DIR) {
$env:CODEX_PS_TMP_DIR
} else {
(Get-Location).Path
}
$script:ToolCounter = 0
$script:ActiveExecId = $null
function Send-KernelMessage {
param(
[Parameter(Mandatory)]
[hashtable]$Message
)
$json = $Message | ConvertTo-Json -Compress -Depth 100
[Console]::Out.WriteLine($json)
[Console]::Out.Flush()
}
function Read-KernelMessage {
while ($true) {
$line = [Console]::In.ReadLine()
if ($null -eq $line) {
return $null
}
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
try {
return $line | ConvertFrom-Json -AsHashtable -Depth 100
} catch {
continue
}
}
}
function Format-StreamItem {
param($Item)
if ($null -eq $Item) {
return $null
}
if ($Item -is [string]) {
return $Item.TrimEnd("`r", "`n")
}
$text = $Item | Out-String -Width 4096
$trimmed = $text.TrimEnd("`r", "`n")
if ([string]::IsNullOrEmpty($trimmed)) {
return $null
}
$trimmed
}
function Wait-ToolResult {
param(
[Parameter(Mandatory)]
[string]$Id
)
while ($true) {
$message = Read-KernelMessage
if ($null -eq $message) {
throw "ps_repl kernel closed while waiting for tool result"
}
if ($message.type -ne 'run_tool_result') {
throw "ps_repl kernel received unexpected message while waiting for tool result: $($message.type)"
}
if ($message.id -ne $Id) {
throw "ps_repl kernel received mismatched tool result: expected $Id, got $($message.id)"
}
return $message
}
}
function Invoke-CodexTool {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name,
[object]$Arguments
)
if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Invoke-CodexTool expects a non-empty tool name"
}
if ($null -eq $script:ActiveExecId) {
throw "Invoke-CodexTool can only be used while a ps_repl exec is running"
}
$toolId = "{0}-tool-{1}" -f $script:ActiveExecId, $script:ToolCounter
$script:ToolCounter += 1
$argumentsJson = '{}'
if ($PSBoundParameters.ContainsKey('Arguments')) {
if ($Arguments -is [string]) {
$argumentsJson = $Arguments
} else {
$argumentsJson = $Arguments | ConvertTo-Json -Compress -Depth 100
}
}
Send-KernelMessage @{
type = 'run_tool'
id = $toolId
exec_id = $script:ActiveExecId
tool_name = $Name
arguments = $argumentsJson
}
$result = Wait-ToolResult -Id $toolId
if (-not $result.ok) {
if ($null -ne $result.error -and -not [string]::IsNullOrWhiteSpace([string]$result.error)) {
throw [System.Exception]::new([string]$result.error)
}
throw [System.Exception]::new('tool failed')
}
$result.response
}
$script:Codex = [pscustomobject]@{
TmpDir = $script:CodexTmpDir
}
$null = $script:Codex | Add-Member -MemberType ScriptMethod -Name Tool -Value {
param($Name, $Arguments)
if ($PSBoundParameters.ContainsKey('Arguments')) {
Invoke-CodexTool -Name $Name -Arguments $Arguments
} else {
Invoke-CodexTool -Name $Name
}
}
Set-Variable -Name Codex -Scope Script -Value $script:Codex
Set-Variable -Name CodexTmpDir -Scope Script -Value $script:CodexTmpDir
while ($true) {
$message = Read-KernelMessage
if ($null -eq $message) {
break
}
if ($message.type -ne 'exec') {
continue
}
$script:ActiveExecId = [string]$message.id
try {
$scriptBlock = [scriptblock]::Create([string]$message.code)
$items = @(. $scriptBlock *>&1)
$outputLines = foreach ($item in $items) {
$formatted = Format-StreamItem -Item $item
if ($null -ne $formatted -and $formatted -ne '') {
$formatted
}
}
$output = [string]::Join("`n", @($outputLines))
Send-KernelMessage @{
type = 'exec_result'
id = [string]$message.id
ok = $true
output = $output
error = $null
}
} catch {
$errorMessage = if ($_.Exception -and $_.Exception.Message) {
[string]$_.Exception.Message
} else {
[string]$_
}
Send-KernelMessage @{
type = 'exec_result'
id = [string]$message.id
ok = $false
output = ''
error = $errorMessage
}
} finally {
$script:ActiveExecId = $null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@ pub(crate) struct ToolsConfig {
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
pub search_tool: bool,
pub request_permission_enabled: bool,
pub ps_repl_enabled: bool,
pub js_repl_enabled: bool,
pub js_repl_tools_only: bool,
pub collab_tools: bool,
@@ -77,6 +78,7 @@ impl ToolsConfig {
session_source,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_ps_repl = features.enabled(Feature::PsRepl);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
@@ -136,6 +138,7 @@ impl ToolsConfig {
agent_roles: BTreeMap::new(),
search_tool: include_search_tool,
request_permission_enabled,
ps_repl_enabled: include_ps_repl,
js_repl_enabled: include_js_repl,
js_repl_tools_only: include_js_repl_tools_only,
collab_tools: include_collab_tools,
@@ -1329,6 +1332,47 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
})
}
fn create_ps_repl_tool() -> ToolSpec {
const PS_REPL_FREEFORM_GRAMMAR: &str = r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE ps_source
plain_source: PS_SOURCE
ps_source: PS_SOURCE
PRAGMA_LINE: /[ \t]*# codex-ps-repl:[^\r\n]*/
NEWLINE: /\r?\n/
PS_SOURCE: /(?:\s*)(?:[^\s{\"'`]|#[^\r\n]|`[^`])[\s\S]*/
"#;
ToolSpec::Freeform(FreeformTool {
name: "ps_repl".to_string(),
description: "Runs PowerShell in a persistent pwsh kernel. This is a freeform tool: send raw PowerShell source text, optionally with a first-line pragma like `# codex-ps-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
.to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: PS_REPL_FREEFORM_GRAMMAR.to_string(),
},
})
}
fn create_ps_repl_reset_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "ps_repl_reset".to_string(),
description:
"Restarts the ps_repl kernel for this run and clears persisted PowerShell session state."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(false.into()),
},
})
}
fn create_js_repl_reset_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "js_repl_reset".to_string(),
@@ -1658,6 +1702,8 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::MultiAgentHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::PsReplHandler;
use crate::tools::handlers::PsReplResetHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SearchToolBm25Handler;
@@ -1683,6 +1729,8 @@ pub(crate) fn build_specs(
default_mode_request_user_input: config.default_mode_request_user_input,
});
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let ps_repl_handler = Arc::new(PsReplHandler);
let ps_repl_reset_handler = Arc::new(PsReplResetHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
let request_permission_enabled = config.request_permission_enabled;
@@ -1737,6 +1785,13 @@ pub(crate) fn build_specs(
builder.push_spec(PLAN_TOOL.clone());
builder.register_handler("update_plan", plan_handler);
if config.ps_repl_enabled {
builder.push_spec(create_ps_repl_tool());
builder.push_spec(create_ps_repl_reset_tool());
builder.register_handler("ps_repl", ps_repl_handler);
builder.register_handler("ps_repl_reset", ps_repl_reset_handler);
}
if config.js_repl_enabled {
builder.push_spec(create_js_repl_tool());
builder.push_spec(create_js_repl_reset_tool());
@@ -2234,6 +2289,62 @@ mod tests {
);
}
#[test]
fn ps_repl_requires_feature_flag() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert!(
!tools.iter().any(|tool| tool.spec.name() == "ps_repl"),
"ps_repl should be disabled when the feature is off"
);
assert!(
!tools.iter().any(|tool| tool.spec.name() == "ps_repl_reset"),
"ps_repl_reset should be disabled when the feature is off"
);
}
#[test]
fn ps_repl_enabled_adds_tools() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::PsRepl);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(&tools, &["ps_repl", "ps_repl_reset"]);
}
#[test]
fn ps_repl_freeform_grammar_mentions_pragma_and_ps_source() {
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_ps_repl_tool() else {
panic!("ps_repl should use a freeform tool spec");
};
assert_eq!(format.syntax, "lark");
assert!(format.definition.contains("PRAGMA_LINE"));
assert!(format.definition.contains("PS_SOURCE"));
assert!(format.definition.contains("codex-ps-repl:"));
assert!(!format.definition.contains("(?!"));
}
#[test]
fn js_repl_enabled_adds_tools() {
let config = test_config();

View File

@@ -97,6 +97,7 @@ mod personality;
mod personality_migration;
mod plugins;
mod prompt_caching;
mod ps_repl;
mod quota_exceeded;
mod read_file;
mod realtime_conversation;

View File

@@ -0,0 +1,484 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::features::Feature;
use codex_protocol::protocol::EventMsg;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use serde_json::Value;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::tempdir;
use wiremock::MockServer;
fn custom_tool_output_text_and_success(
req: &ResponsesRequest,
call_id: &str,
) -> (String, Option<bool>) {
let (output, success) = req
.custom_tool_call_output_content_and_success(call_id)
.expect("custom tool output should be present");
(output.unwrap_or_default(), success)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body["tools"]
.as_array()
.expect("tools array should be present")
.iter()
.map(|tool| {
tool.get("name")
.and_then(|value| value.as_str())
.or_else(|| tool.get("type").and_then(|value| value.as_str()))
.expect("tool should have a name or type")
.to_string()
})
.collect()
}
fn write_too_old_pwsh_script(dir: &Path) -> Result<std::path::PathBuf> {
#[cfg(windows)]
{
let path = dir.join("old-pwsh.cmd");
fs::write(&path, "@echo off\r\necho PowerShell 5.1.0\r\n")?;
Ok(path)
}
#[cfg(unix)]
{
let path = dir.join("old-pwsh.sh");
fs::write(&path, "#!/bin/sh\necho PowerShell 5.1.0\n")?;
let mut permissions = fs::metadata(&path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions)?;
Ok(path)
}
#[cfg(not(any(unix, windows)))]
{
anyhow::bail!("unsupported platform for ps_repl test fixture");
}
}
fn write_test_png(dir: &Path) -> Result<std::path::PathBuf> {
let path = dir.join("dot.png");
let png_bytes = BASE64_STANDARD.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
)?;
fs::write(&path, png_bytes)?;
Ok(path)
}
fn ps_single_quote(input: &Path) -> String {
input.display().to_string().replace('\'', "''")
}
async fn run_ps_repl_turn(
server: &MockServer,
prompt: &str,
calls: &[(&str, &str)],
) -> Result<ResponseMock> {
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(server)
.await?;
let mut first_events = vec![ev_response_created("resp-1")];
for (call_id, ps_input) in calls {
first_events.push(ev_custom_tool_call(call_id, "ps_repl", ps_input));
}
first_events.push(ev_completed("resp-1"));
responses::mount_sse_once(server, sse(first_events)).await;
let second_mock = responses::mount_sse_once(
server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn(prompt).await?;
Ok(second_mock)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_is_not_advertised_when_startup_pwsh_is_incompatible() -> Result<()> {
skip_if_no_network!(Ok(()));
if std::env::var_os("CODEX_PS_REPL_PATH").is_some() {
return Ok(());
}
let server = responses::start_mock_server().await;
let temp = tempdir()?;
let old_pwsh = write_too_old_pwsh_script(temp.path())?;
let test = test_codex()
.with_config(move |config| {
config.features.enable(Feature::PsRepl);
config.ps_repl_path = Some(old_pwsh);
})
.build(&server)
.await?;
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Warning(ev) if ev.message.contains("Disabled `ps_repl` for this session") => {
Some(ev.message.clone())
}
_ => None,
})
.await;
assert!(
warning.contains("PowerShell runtime"),
"warning should explain the PowerShell compatibility issue: {warning}"
);
let request_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
test.submit_turn("hello").await?;
let body = request_mock.single_request().body_json();
let tools = tool_names(&body);
assert!(
!tools.iter().any(|tool| tool == "ps_repl"),
"ps_repl should be omitted when startup validation fails: {tools:?}"
);
assert!(
!tools.iter().any(|tool| tool == "ps_repl_reset"),
"ps_repl_reset should be omitted when startup validation fails: {tools:?}"
);
let instructions = body["instructions"].as_str().unwrap_or_default();
assert!(
!instructions.contains("## PowerShell REPL (pwsh)"),
"startup instructions should not mention ps_repl when it is disabled: {instructions}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_persists_variables_functions_and_modules() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(&server)
.await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"ps_repl",
r#"
$x = 41
New-Module -Name CodexPsTestModule -ScriptBlock {
function Get-CodexValue { 42 }
} | Import-Module
function Add-One {
param([int]$Value)
$Value + 1
}
Write-Output "state-ready"
"#,
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call(
"call-2",
"ps_repl",
r#"
Write-Output ($x + 1)
Write-Output (Add-One -Value 1)
Write-Output (Get-CodexValue)
"#,
),
ev_completed("resp-2"),
]),
)
.await;
let third_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
)
.await;
test.submit_turn("run ps_repl twice").await?;
let req2 = second_mock.single_request();
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
assert_ne!(
first_success,
Some(false),
"first ps_repl call failed unexpectedly: {first_output}"
);
assert!(first_output.contains("state-ready"));
let req3 = third_mock.single_request();
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
assert_ne!(
second_success,
Some(false),
"second ps_repl call failed unexpectedly: {second_output}"
);
let lines = second_output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
assert!(
lines.iter().filter(|line| **line == "42").count() >= 2,
"expected persisted variable and module output, got: {second_output}"
);
assert!(
lines.contains(&"2"),
"expected persisted function output, got: {second_output}"
);
assert!(
!second_output.contains("Get-CodexValue"),
"unexpected formatting leak: {second_output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_can_invoke_builtin_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"use ps_repl to call a tool",
&[(
"call-1",
"$toolOut = Invoke-CodexTool -Name list_mcp_resources -Arguments @{}; Write-Output $toolOut.type",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(output.contains("function_call_output"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_tool_call_rejects_recursive_ps_repl_invocation() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"use ps_repl recursively",
&[(
"call-1",
r#"
try {
Invoke-CodexTool -Name ps_repl -Arguments "Write-Output 'recursive'" | Out-Null
Write-Output "unexpected-success"
} catch {
Write-Output $_.Exception.Message
}
"#,
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(
output.contains("ps_repl cannot invoke itself"),
"expected recursion guard message, got output: {output}"
);
assert!(
!output.contains("unexpected-success"),
"recursive ps_repl call unexpectedly succeeded: {output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_resets_after_timeout_and_accepts_followup_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(&server)
.await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"ps_repl",
"# codex-ps-repl: timeout_ms=50\nStart-Sleep -Milliseconds 500",
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call("call-2", "ps_repl", "Write-Output 'healthy'"),
ev_completed("resp-2"),
]),
)
.await;
let third_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
)
.await;
test.submit_turn("run ps_repl after timeout").await?;
let req2 = second_mock.single_request();
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
assert_ne!(
first_success,
Some(true),
"timeout should not report success: {first_output}"
);
assert!(
first_output.contains("ps_repl execution timed out"),
"expected timeout output, got: {first_output}"
);
let req3 = third_mock.single_request();
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
assert_ne!(
second_success,
Some(false),
"ps_repl follow-up execution failed unexpectedly: {second_output}"
);
assert!(second_output.contains("healthy"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_captures_standard_powershell_output_streams() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"capture powershell output",
&[(
"call-1",
"Write-Output 'stdout'; Write-Warning 'warn-stream'",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(output.contains("stdout"));
assert!(output.contains("warn-stream"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_view_image_propagates_content_items() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let temp = tempdir()?;
let png_path = write_test_png(temp.path())?;
let png_path = ps_single_quote(&png_path);
let script =
format!("$null = Invoke-CodexTool -Name view_image -Arguments @{{ path = '{png_path}' }}");
let mock = run_ps_repl_turn(
&server,
"render an image via ps_repl",
&[("call-1", &script)],
)
.await?;
let req = mock.single_request();
let custom_output = req.custom_tool_call_output("call-1");
let output_items = custom_output
.get("output")
.and_then(Value::as_array)
.expect("custom_tool_call_output should be a content item array");
let image_url = output_items
.iter()
.find_map(|item| {
(item.get("type").and_then(Value::as_str) == Some("input_image"))
.then(|| item.get("image_url").and_then(Value::as_str))
.flatten()
})
.expect("image_url present in ps_repl custom tool output");
assert!(
image_url.starts_with("data:image/png;base64,"),
"expected png data URL, got {image_url}"
);
Ok(())
}

View File

@@ -277,6 +277,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
model_provider: model_provider.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: None,
zsh_path: None,

View File

@@ -20,7 +20,7 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
```bash
git clone https://github.com/bminor/bash
git clone https://git.savannah.gnu.org/git/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply /path/to/patches/bash-exec-wrapper.patch
./configure --without-bash-malloc

View File

@@ -1201,6 +1201,15 @@ impl App {
}
}
let has_non_primary_agent_thread = self
.agent_picker_threads
.keys()
.any(|thread_id| Some(*thread_id) != self.primary_thread_id);
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
self.chat_widget.open_multi_agent_enable_prompt();
return;
}
if self.agent_picker_threads.is_empty() {
self.chat_widget
.add_info_message("No agents available yet.".to_string(), None);
@@ -3601,6 +3610,7 @@ mod tests {
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use assert_matches::assert_matches;
use codex_core::CodexAuth;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -3966,6 +3976,51 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
{
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
Ok(())
}
#[tokio::test]
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
let mut app = make_test_app().await;

View File

@@ -4108,10 +4108,10 @@ impl ChatComposer {
!footer_props.is_task_running && self.collaboration_mode_indicator.is_some();
let show_shortcuts_hint = match footer_props.mode {
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
| FooterMode::EscHint => false,
};
let show_queue_hint = match footer_props.mode {
FooterMode::ComposerHasDraft => footer_props.is_task_running,
@@ -4141,10 +4141,13 @@ impl ChatComposer {
.as_ref()
.map(|line| line.clone().dim());
let status_line_candidate = footer_props.status_line_enabled
&& matches!(
footer_props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
);
&& match footer_props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !footer_props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let mut truncated_status_line = if status_line_candidate {
status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
@@ -4210,7 +4213,7 @@ impl ChatComposer {
can_show_left_with_context(hint_rect, left_width, right_width);
let has_override =
self.footer_flash_visible() || self.footer_hint_override.is_some();
let single_line_layout = if has_override {
let single_line_layout = if has_override || status_line_active {
None
} else {
match footer_props.mode {

View File

@@ -172,10 +172,10 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
false
}
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
@@ -562,13 +562,18 @@ fn footer_from_props_lines(
show_shortcuts_hint: bool,
show_queue_hint: bool,
) -> Vec<Line<'static>> {
// If status line content is present, show it for base modes.
// If status line content is present, show it for passive composer states.
// Active draft states still prefer the queue hint over the passive status
// line so the footer stays actionable while a task is running.
if props.status_line_enabled
&& let Some(status_line) = &props.status_line_value
&& matches!(
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)
&& match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
}
{
return vec![status_line.clone().dim()];
}
@@ -601,6 +606,8 @@ fn footer_from_props_lines(
let state = LeftSideState {
hint: if show_queue_hint {
SummaryHintKind::QueueMessage
} else if show_shortcuts_hint {
SummaryHintKind::Shortcuts
} else {
SummaryHintKind::None
},
@@ -1013,10 +1020,10 @@ mod tests {
let show_cycle_hint = !props.is_task_running;
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
| FooterMode::EscHint => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
@@ -1025,13 +1032,21 @@ mod tests {
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let left_mode_indicator = if props.status_line_enabled {
let status_line_active = props.status_line_enabled
&& match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let left_mode_indicator = if status_line_active {
None
} else {
collaboration_mode_indicator
};
let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
let mut truncated_status_line = if props.status_line_enabled
let mut truncated_status_line = if status_line_active
&& matches!(
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
@@ -1044,7 +1059,7 @@ mod tests {
} else {
None
};
let mut left_width = if props.status_line_enabled {
let mut left_width = if status_line_active {
truncated_status_line
.as_ref()
.map(|line| line.width() as u16)
@@ -1058,7 +1073,7 @@ mod tests {
show_queue_hint,
)
};
let right_line = if props.status_line_enabled {
let right_line = if status_line_active {
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(collaboration_mode_indicator, false);
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
@@ -1077,7 +1092,7 @@ mod tests {
.as_ref()
.map(|line| line.width() as u16)
.unwrap_or(0);
if props.status_line_enabled
if status_line_active
&& let Some(max_left) = max_left_width_for_right(area, right_width)
&& left_width > max_left
&& let Some(line) = props
@@ -1097,21 +1112,24 @@ mod tests {
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
) {
let (summary_left, show_context) = single_line_footer_layout(
area,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
match summary_left {
SummaryLeft::Default => {
if props.status_line_enabled {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(area, f.buffer_mut(), line);
}
} else {
if status_line_active {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(area, f.buffer_mut(), line);
}
if can_show_left_and_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
} else {
let (summary_left, show_context) = single_line_footer_layout(
area,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
match summary_left {
SummaryLeft::Default => {
render_footer_from_props(
area,
f.buffer_mut(),
@@ -1122,14 +1140,14 @@ mod tests {
show_queue_hint,
);
}
SummaryLeft::Custom(line) => {
render_footer_line(area, f.buffer_mut(), line);
}
SummaryLeft::None => {}
}
SummaryLeft::Custom(line) => {
render_footer_line(area, f.buffer_mut(), line);
if show_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
SummaryLeft::None => {}
}
if show_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
} else {
render_footer_from_props(
@@ -1416,6 +1434,38 @@ mod tests {
snapshot_footer("footer_status_line_overrides_shortcuts", props);
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
};
snapshot_footer("footer_status_line_yields_to_queue_hint", props);
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
};
snapshot_footer("footer_status_line_overrides_draft_idle", props);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Status line content "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" tab to queue message 100% context left "

View File

@@ -164,6 +164,10 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable multi-agent?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
const MULTI_AGENT_ENABLE_NOTICE: &str = "Multi-agent will be enabled in the next session.";
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
@@ -1568,6 +1572,41 @@ impl ChatWidget {
});
}
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
let items = vec![
SelectionItem {
name: MULTI_AGENT_ENABLE_YES.to_string(),
description: Some(
"Save the setting now. You will need a new session to use it.".to_string(),
),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::UpdateFeatureFlags {
updates: vec![(Feature::Collab, true)],
});
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()),
)));
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: MULTI_AGENT_ENABLE_NO.to_string(),
description: Some("Keep multi-agent disabled.".to_string()),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()),
subtitle: Some("Multi-agent is currently disabled in your config.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
match info {
Some(info) => self.apply_token_info(info),

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 6001
expression: popup
---
Enable multi-agent?
Multi-agent is currently disabled in your config.
1. Yes, enable Save the setting now. You will need a new session to use it.
2. Not now Keep multi-agent disabled.
Press enter to confirm or esc to go back

View File

@@ -5991,6 +5991,35 @@ async fn experimental_popup_shows_js_repl_node_requirement() {
);
}
#[tokio::test]
async fn multi_agent_enable_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_multi_agent_enable_prompt();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("multi_agent_enable_prompt", popup);
}
#[tokio::test]
async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_multi_agent_enable_prompt();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(120));
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
}
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;

68
docs/ps_repl.md Normal file
View File

@@ -0,0 +1,68 @@
# PowerShell REPL (`ps_repl`)
`ps_repl` runs PowerShell in a persistent `pwsh`-backed kernel.
## Feature gate
`ps_repl` is disabled by default and only appears when:
```toml
[features]
ps_repl = true
```
The initial rollout stage is under development.
## PowerShell runtime
`ps_repl` requires PowerShell 7 or newer.
Runtime resolution order:
1. `CODEX_PS_REPL_PATH` environment variable
2. `ps_repl_path` in config/profile
3. `pwsh` discovered on `PATH`
You can configure an explicit runtime path:
```toml
ps_repl_path = "/absolute/path/to/pwsh"
```
If only Windows PowerShell (`powershell.exe`) is available, `ps_repl` stays disabled and emits a startup warning telling you to install PowerShell 7.
## Usage
- `ps_repl` is a freeform tool: send raw PowerShell source text.
- Optional first-line pragma:
- `# codex-ps-repl: timeout_ms=15000`
- Variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE` persist across calls.
- Use `ps_repl_reset` to clear the kernel state.
## Helper APIs inside the kernel
`ps_repl` exposes these helpers:
- `$CodexTmpDir`: per-session scratch directory path.
- `Invoke-CodexTool -Name <string> -Arguments <object|string>`: executes a normal Codex tool call from inside `ps_repl`.
- `$Codex.TmpDir`: alias for the scratch directory.
- `$Codex.Tool(<name>, <args>)`: thin alias to `Invoke-CodexTool`.
`Invoke-CodexTool` returns the raw tool output object. Nested tool calls can also return multimodal content such as `view_image` results.
To share generated images with the model, write a file under `$CodexTmpDir`, call:
```powershell
Invoke-CodexTool -Name view_image -Arguments @{ path = "/absolute/path" }
```
Then delete the file.
## Output and transport
`ps_repl` uses a JSON-line transport over stdio.
- Safe output forms: pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, `Write-Warning`
- Avoid: direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams
Bypassing PowerShell streams can corrupt the transport between the Rust host and the persistent kernel.